sync update from LAN repo (source ji @ 85078a31)

This commit is contained in:
gozeros committed 2026-10-21 15:09:16 +08:00
1 parent 5d6627af50
commit e602d88695
864 files changed
+99150 -6346

No files matched your search

@@ -32,13 +32,13 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import {
buildWriteFileVirtualUrl,
hasCompleteArtifactExtension,
@@ -82,7 +82,7 @@ import { Tooltip } from "../tooltip";
import { SourceCard } from "../skill-display-sources";
import { MarkdownContent } from "./markdown-content";
import { SingleRoundRagCitationDialog } from "./message-list-item";
import { SingleRoundRagCitationDrawer } from "./message-list-item";
export function MessageGroup({
className,
@@ -271,7 +271,7 @@ export function MessageGroup({
</>
)}
</ChainOfThought>
<SingleRoundRagCitationDialog
<SingleRoundRagCitationDrawer
source={selectedCitation}
onOpenChange={(open) => {
if (!open) setSelectedCitation(null);
@@ -1010,32 +1010,46 @@ function SearchResultCard({ item }: { item: SearchResultItem }) {
</div>
)}
</button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="pr-6 text-base leading-relaxed">
{item.title ?? "检索结果"}
</DialogTitle>
{(sourceLabel || date) && (
<DialogDescription className="flex items-center gap-2 text-xs">
{sourceLabel && <span>{sourceLabel}</span>}
{sourceLabel && date && <span>·</span>}
{date && <span>{date}</span>}
</DialogDescription>
)}
</DialogHeader>
{content && (
<div className="text-foreground max-h-[60vh] overflow-y-auto whitespace-pre-wrap text-sm leading-relaxed">
{content}
</div>
)}
{item.url && (
<div className="text-muted-foreground border-t pt-3 text-xs break-all">
{item.url}
</div>
)}
</DialogContent>
</Dialog>
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent
side="right"
className="w-[min(92vw,760px)] gap-0 overflow-hidden p-0 sm:max-w-none"
>
<SheetHeader className="shrink-0 border-b px-5 py-4 pr-12 text-left">
<SheetTitle className="text-base leading-relaxed">
{item.title ?? "检索结果"}
</SheetTitle>
{(sourceLabel || date) && (
<SheetDescription className="flex items-center gap-2 text-xs">
{sourceLabel && <span>{sourceLabel}</span>}
{sourceLabel && date && <span>·</span>}
{date && <span>{date}</span>}
</SheetDescription>
)}
{!sourceLabel && !date && (
<SheetDescription className="sr-only">检索结果详情</SheetDescription>
)}
</SheetHeader>
<div className="min-h-0 flex-1 overflow-y-auto bg-muted/20 p-4 sm:p-6">
<div className="mx-auto max-w-3xl space-y-4">
{content ? (
<article className="text-foreground rounded-xl border bg-background px-4 py-5 text-sm leading-7 whitespace-pre-wrap shadow-sm sm:px-6">
{content}
</article>
) : (
<div className="flex min-h-64 items-center justify-center rounded-xl border border-dashed bg-background text-sm text-muted-foreground">
暂无可展示的检索内容
</div>
)}
{item.url && (
<div className="text-muted-foreground border-t pt-3 text-xs break-all">
{item.url}
</div>
)}
</div>
</div>
</SheetContent>
</Sheet>
</>
);
}
@@ -1,9 +1,10 @@
import type { Message } from '@langchain/langgraph-sdk';
import {
BookMarkedIcon,
ChevronDownIcon,
ClockIcon,
ExternalLinkIcon,
import {
ArrowLeftIcon,
BookMarkedIcon,
ChevronRightIcon,
ClockIcon,
ExternalLinkIcon,
FileIcon,
Loader2Icon,
PencilIcon,
@@ -34,20 +35,15 @@ import {
ReasoningTrigger,
} from '@/components/ai-elements/reasoning';
import { Task, TaskTrigger } from '@/components/ai-elements/task';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import { Textarea } from '@/components/ui/textarea';
import {
UploadedFilePreviewDialog,
@@ -55,10 +51,11 @@ import {
} from '@/components/workspace/messages/uploaded-file-preview-dialog';
import { resolveArtifactURL } from '@/core/artifacts/utils';
import { useChatAskTimes } from '@/core/chat-timestamps';
import { getBackendBaseURL } from '@/core/config';
import { readRuntimeEnv } from '@/env';
import { useI18n } from '@/core/i18n/hooks';
import {
import { getBackendBaseURL } from '@/core/config';
import { readRuntimeEnv } from '@/env';
import { useI18n } from '@/core/i18n/hooks';
import { stripKnowledgeRetrievalDiagnostics } from '@/core/llmwiki/answer-display';
import {
extractContentFromMessage,
extractReasoningContentFromMessage,
parseUploadedFiles,
@@ -76,14 +73,17 @@ import {
getMultiRoundRagTrace,
isMultiRoundRagMessage,
} from '@/core/threads/use-multi-round-rag';
import { cn } from '@/lib/utils';
import { cn } from '@/lib/utils';
import type { AnswerFeedbackContext } from '@/strategy-components/api/answer-feedback';
import { CitationLink } from '../citations/citation-link';
import { CopyButton } from '../copy-button';
import { Tooltip } from '../tooltip';
import { SkillDisplayBlock } from '../skill-display-block';
import { ThreadContext } from './context';
import { DeleteMessageButton } from './delete-message-button';
import { ThreadContext } from './context';
import { AnswerFeedbackControl } from './answer-feedback-control';
import { isEligibleAnswerFeedbackMessage } from './answer-feedback-eligibility';
import { DeleteMessageButton } from './delete-message-button';
import { DownloadWordButton } from './download-word-button';
import { MarkdownContent, type MarkdownContentProps } from './markdown-content';
import { MessageTokenUsage } from './message-token-usage';
@@ -166,9 +166,11 @@ export function MessageListItem({
extraRehypePlugins,
onEditResubmit,
onEditSave,
onMessageDeleted,
turnTime,
}: {
onMessageDeleted,
turnTime,
answerFeedbackEnabled = false,
answerFeedbackContext,
}: {
className?: string;
fitRowToContent?: boolean;
/**
@@ -193,8 +195,12 @@ export function MessageListItem({
* timestamp of its own — without it the answer would fall back to the current
* wall clock at render time and appear to "drift" to now on every reload.
*/
turnTime?: string;
}) {
turnTime?: string;
/** Opt-in feedback affordance for completed, persisted assistant answers. */
answerFeedbackEnabled?: boolean;
/** Authoritative run/agent mapping hydrated in one conversation batch. */
answerFeedbackContext?: AnswerFeedbackContext;
}) {
const { t } = useI18n();
const isHuman = message.type === 'human';
const messageMarkdown =
@@ -211,13 +217,16 @@ export function MessageListItem({
}
}, [assistantEditing, messageMarkdown]);
const canSaveAssistantEdit =
const canSaveAssistantEdit =
!isLoading &&
!isHuman &&
!!onEditSave &&
!!message.id &&
!message.id.startsWith('opt-') &&
!!messageMarkdown;
!!messageMarkdown;
const canEvaluateAnswer =
answerFeedbackEnabled &&
isEligibleAnswerFeedbackMessage(message, Boolean(isLoading));
const confirmAssistantEdit = useCallback(async () => {
const value = assistantDraft.trim();
if (!value || !message.id || !onEditSave || assistantSaving) {
@@ -325,9 +334,16 @@ export function MessageListItem({
'absolute right-0 left-0 z-20 opacity-0 transition-opacity delay-200 duration-300 group-hover/conversation-message:opacity-100',
)}
>
<div className="flex gap-1">
<CopyButton clipboardData={messageMarkdown} />
{messageMarkdown && (
<div className="flex gap-1">
<CopyButton clipboardData={messageMarkdown} />
{canEvaluateAnswer && message.id && answerFeedbackContext && (
<AnswerFeedbackControl
threadId={threadId}
messageId={message.id}
context={answerFeedbackContext}
/>
)}
{messageMarkdown && (
<DownloadWordButton
markdown={messageMarkdown}
fileName="message"
@@ -686,22 +702,25 @@ function MessageContent_({
return files as FileInMessage[];
}, [message.additional_kwargs?.files, rawContent]);
const contentToDisplay = useMemo(() => {
if (isHuman) {
return rawContent
? stripArtifactTag(stripUploadedFilesTag(rawContent))
: '';
}
if (isMultiRoundRag) {
return stripIncompleteCitationMarker(rawContent ?? '');
}
const withCitations = applyInlineCitations(rawContent ?? '', displaySources);
return stripIncompleteCitationMarker(withCitations);
}, [rawContent, isHuman, isMultiRoundRag, displaySources]);
const markdownRehypePlugins = useMemo(
() => [...rehypePlugins, [rehypeKatex, { output: 'html' as const }]],
[rehypePlugins],
);
const contentToDisplay = useMemo(() => {
if (isHuman) {
return rawContent
? stripArtifactTag(stripUploadedFilesTag(rawContent))
: '';
}
const sanitizedContent = stripKnowledgeRetrievalDiagnostics(rawContent ?? '');
if (isMultiRoundRag) {
return stripIncompleteCitationMarker(sanitizedContent);
}
const withCitations = applyInlineCitations(sanitizedContent, displaySources);
return stripIncompleteCitationMarker(withCitations);
}, [rawContent, isHuman, isMultiRoundRag, displaySources]);
const markdownRehypePlugins = useMemo<
NonNullable<MarkdownContentProps['rehypePlugins']>
>(
() => [...rehypePlugins, [rehypeKatex, { output: 'html' as const }]],
[rehypePlugins],
);
const parsedHumanContent = useMemo(
() =>
isHuman
@@ -807,11 +826,8 @@ function MessageContent_({
/>
)}
</div>
{isSingleRoundRag && displaySources.length > 0 && (
<FrontendRagSourcesPanel
sources={displaySources}
onSelect={setSelectedCitation}
/>
{isSingleRoundRag && displaySources.length > 0 && (
<FrontendRagSourcesPanel sources={displaySources} />
)}
<MessageTokenUsage
enabled={tokenUsageEnabled}
@@ -823,7 +839,7 @@ function MessageContent_({
) : null}
</AIElementMessageContent>
{isSingleRoundRag && (
<SingleRoundRagCitationDialog
<SingleRoundRagCitationDrawer
source={selectedCitation}
onOpenChange={(open) => {
if (!open) setSelectedCitation(null);
@@ -834,112 +850,176 @@ function MessageContent_({
);
}
/**
* 单轮 RAG 专属的「引用来源」折叠面板。
*
* 多轮 RAG 的检索卡片已经画在思考过程里,正文下面不再重复一份来源列表。
*/
function FrontendRagSourcesPanel({
sources,
onSelect,
}: {
sources: RuntimeDisplaySource[];
onSelect: (source: RuntimeDisplaySource) => void;
}) {
const [open, setOpen] = useState(false);
if (sources.length === 0) return null;
return (
<Collapsible open={open} onOpenChange={setOpen} className="mt-3 w-full max-w-5xl">
<CollapsibleTrigger asChild>
<button
type="button"
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1.5 text-xs transition-colors"
>
<BookMarkedIcon className="size-3.5" />
<span className="font-medium">引用来源</span>
<span className="text-muted-foreground/70">({sources.length})</span>
<ChevronDownIcon
className={cn(
'size-3.5 transition-transform duration-200',
!open && '-rotate-90',
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="mt-2 grid gap-2">
{sources.map((source, idx) => (
<button
key={source.id ?? `${source.title}-${idx}`}
type="button"
onClick={() => onSelect(source)}
className="bg-card hover:bg-accent block w-full cursor-pointer rounded-lg border p-3 text-left transition-colors"
>
<div className="flex items-center gap-2">
<span className="bg-primary/10 text-primary flex size-5 shrink-0 items-center justify-center rounded-full text-xs font-semibold">
{source.index}
</span>
<span
className="text-foreground line-clamp-1 min-w-0 flex-1 text-sm font-medium"
title={source.title}
>
{source.title}
</span>
{source.type && (
<span className="bg-muted text-muted-foreground shrink-0 rounded px-1.5 py-0.5 text-xs">
{source.type}
</span>
)}
</div>
{source.snippet && (
<p className="text-muted-foreground mt-1.5 line-clamp-2 text-xs leading-relaxed">
{source.snippet}
</p>
)}
</button>
))}
</CollapsibleContent>
</Collapsible>
);
}
export function SingleRoundRagCitationDialog({
source,
onOpenChange,
}: {
/**
* 单轮 RAG 的参考文献入口。先打开右侧抽屉查看列表,
* 选中条目后在同一抽屉中阅读详情,避免跳出居中弹框打断问答。
*/
function FrontendRagSourcesPanel({ sources }: { sources: RuntimeDisplaySource[] }) {
const [open, setOpen] = useState(false);
const [selectedSource, setSelectedSource] = useState<RuntimeDisplaySource | null>(null);
if (sources.length === 0) return null;
const handleOpenChange = (nextOpen: boolean) => {
setOpen(nextOpen);
if (!nextOpen) setSelectedSource(null);
};
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
className="text-muted-foreground hover:text-foreground mt-3 inline-flex items-center gap-1.5 rounded-md py-1 text-xs transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
aria-label={`查看 ${sources.length} 条参考文献`}
>
<BookMarkedIcon className="size-3.5" />
<span className="font-medium">参考文献</span>
<span className="text-muted-foreground/70">({sources.length})</span>
<span>查看</span>
<ChevronRightIcon className="size-3.5" />
</button>
<Sheet open={open} onOpenChange={handleOpenChange}>
<SheetContent
side="right"
className="w-[min(92vw,760px)] gap-0 overflow-hidden p-0 sm:max-w-none"
>
<div className="flex h-full min-h-0 flex-col">
<SheetHeader className="shrink-0 border-b px-5 py-4 pr-12 text-left">
<div className="flex items-start gap-3">
{selectedSource && (
<Button
type="button"
size="icon-sm"
variant="ghost"
className="mt-0.5 shrink-0"
onClick={() => setSelectedSource(null)}
aria-label="返回参考文献列表"
>
<ArrowLeftIcon className="size-4" />
</Button>
)}
<div className="min-w-0">
<SheetTitle className="truncate text-base">
{selectedSource?.title || "参考文献"}
</SheetTitle>
<SheetDescription className="mt-1 text-xs">
{selectedSource
? "引用详情;可返回列表继续查看其他文献。"
: `本次回答共引用 ${sources.length} 条资料,点击条目查看具体内容。`}
</SheetDescription>
</div>
</div>
</SheetHeader>
{selectedSource ? (
<CitationSourceDetail source={selectedSource} />
) : (
<div className="min-h-0 flex-1 overflow-y-auto bg-muted/20 p-4 sm:p-5">
<div className="grid gap-2">
{sources.map((source, idx) => (
<button
key={source.id ?? `${source.title}-${idx}`}
type="button"
onClick={() => setSelectedSource(source)}
className="group bg-card hover:border-primary/40 hover:bg-accent block w-full cursor-pointer rounded-xl border p-3.5 text-left shadow-sm transition-colors"
>
<div className="flex items-start gap-2.5">
<span className="bg-primary/10 text-primary mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full text-xs font-semibold">
{source.index}
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-foreground line-clamp-2 min-w-0 flex-1 text-sm font-medium">
{source.title}
</span>
{source.type && (
<span className="bg-muted text-muted-foreground shrink-0 rounded px-1.5 py-0.5 text-[11px]">
{source.type}
</span>
)}
</div>
{(source.source || source.time) && (
<p className="text-muted-foreground mt-1 text-[11px]">
{[source.source, source.time].filter(Boolean).join(" · ")}
</p>
)}
{(source.snippet || source.content) && (
<p className="text-muted-foreground mt-1.5 line-clamp-2 text-xs leading-relaxed">
{source.snippet || source.content}
</p>
)}
</div>
<ChevronRightIcon className="text-muted-foreground mt-1 size-4 shrink-0 transition-transform group-hover:translate-x-0.5" />
</div>
</button>
))}
</div>
</div>
)}
</div>
</SheetContent>
</Sheet>
</>
);
}
export function SingleRoundRagCitationDrawer({
source,
onOpenChange,
}: {
source: RuntimeDisplaySource | null;
onOpenChange: (open: boolean) => void;
}) {
const sourceUrl = resolveSingleRoundRagSourceUrl(source);
return (
<Dialog open={!!source} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[min(42rem,calc(100vh-2rem))] max-w-2xl overflow-hidden p-5 sm:max-w-2xl">
<DialogHeader className="pr-8">
<DialogTitle className="leading-relaxed">
{source?.title || "引用资料"}
</DialogTitle>
<DialogDescription className="space-y-1 text-xs">
<span className="block">来源:{source?.source || source?.type || "未知来源"}</span>
{source?.time && <span className="block">时间:{source.time}</span>}
</DialogDescription>
</DialogHeader>
{sourceUrl && (
<a
href={sourceUrl}
target="_blank"
rel="noopener noreferrer"
className="border-input hover:bg-accent inline-flex w-fit items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors"
>
<ExternalLinkIcon className="size-4" />
查看原文链接
</a>
)}
<div className="bg-muted/50 max-h-[min(26rem,calc(100vh-16rem))] overflow-auto rounded-lg p-3 text-sm leading-7 whitespace-pre-wrap">
{source?.content || source?.snippet || "暂无引用内容"}
</div>
</DialogContent>
</Dialog>
);
}
}) {
return (
<Sheet open={!!source} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className="w-[min(92vw,760px)] gap-0 overflow-hidden p-0 sm:max-w-none"
>
<SheetHeader className="shrink-0 border-b px-5 py-4 pr-12 text-left">
<SheetTitle className="leading-relaxed">
{source?.title || "引用资料"}
</SheetTitle>
<SheetDescription className="space-y-1 text-xs">
<span className="block">来源:{source?.source || source?.type || "未知来源"}</span>
{source?.time && <span className="block">时间:{source.time}</span>}
</SheetDescription>
</SheetHeader>
{source && <CitationSourceDetail source={source} />}
</SheetContent>
</Sheet>
);
}
function CitationSourceDetail({ source }: { source: RuntimeDisplaySource }) {
const sourceUrl = resolveSingleRoundRagSourceUrl(source);
return (
<div className="min-h-0 flex-1 overflow-y-auto bg-muted/20 p-4 sm:p-6">
<div className="mx-auto max-w-3xl space-y-4">
{sourceUrl && (
<a
href={sourceUrl}
target="_blank"
rel="noopener noreferrer"
className="border-input bg-background hover:bg-accent inline-flex w-fit items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors"
>
<ExternalLinkIcon className="size-4" />
查看原文链接
</a>
)}
<article className="bg-background rounded-xl border px-4 py-5 text-sm leading-7 whitespace-pre-wrap shadow-sm sm:px-6">
{source.content || source.snippet || "暂无引用内容"}
</article>
{sourceUrl && (
<p className="text-muted-foreground border-t pt-3 text-xs break-all">
{sourceUrl}
</p>
)}
</div>
</div>
);
}
function resolveSingleRoundRagSourceUrl(
source: RuntimeDisplaySource | null,
@@ -1160,10 +1240,12 @@ function RichFileCard({
if (!fileUrl) return null;
const preview = (
<UploadedFilePreviewDialog
filename={file.filename}
fileUrl={fileUrl}
open={previewOpen}
<UploadedFilePreviewDialog
filename={file.filename}
fileUrl={fileUrl}
threadId={threadId}
virtualPath={file.path}
open={previewOpen}
onOpenChange={setPreviewOpen}
/>
);
@@ -42,14 +42,16 @@ import { useUpdateSubtask } from "@/core/tasks/context";
import type { AgentThreadState } from "@/core/threads";
import { isSingleRoundRagMessage } from "@/core/threads/use-single-round-rag";
import { isMultiRoundRagMessage } from "@/core/threads/use-multi-round-rag";
import { cn } from "@/lib/utils";
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
import { cn } from "@/lib/utils";
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
import { useAnswerFeedbackContexts } from "@/strategy-components/api/answer-feedback";
import { ArtifactFileList } from "../artifacts/artifact-file-list";
import { getQuestionAnchorId } from "../question-navigator";
import { StreamingIndicator } from "../streaming-indicator";
import { ReferenceBatchesContext, ThreadContext } from "./context";
import { ReferenceBatchesContext, ThreadContext } from "./context";
import { isEligibleAnswerFeedbackMessage } from "./answer-feedback-eligibility";
import { MarkdownContent } from "./markdown-content";
import { DocumentRewriteTimeline } from "./document-rewrite-timeline";
import { MessageGroup } from "./message-group";
@@ -65,7 +67,7 @@ export const MESSAGE_LIST_DEFAULT_PADDING_BOTTOM = 160;
export const MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM = 80;
/** 用户发送新问题时强制滚到底部(即使用户此前上滑看过历史)。 */
function ScrollToBottomOnUserMessage({
function ScrollToBottomOnUserMessage({
threadId,
messages,
}: {
@@ -109,10 +111,54 @@ function ScrollToBottomOnUserMessage({
return () => window.removeEventListener(DOCUMENT_REWRITE_STARTED_EVENT, handleDocumentRewriteStarted);
}, [scrollToBottom, threadId]);
return null;
}
export function MessageList({
return null;
}
/**
* Exposes the conversation's scroll position to hosts that render controls
* outside the scroll container (for example the docked follow-up questions).
*/
function ConversationBottomObserver({
onAtBottomChange,
}: {
onAtBottomChange?: (isAtBottom: boolean) => void;
}) {
const { scrollRef, contentRef, escapedFromLock } = useStickToBottomContext();
const [isExactlyAtBottom, setIsExactlyAtBottom] = useState(true);
useEffect(() => {
const scrollElement = scrollRef.current;
if (!scrollElement) return;
const update = () => {
const distance =
scrollElement.scrollHeight -
scrollElement.scrollTop -
scrollElement.clientHeight;
setIsExactlyAtBottom(distance <= 2);
};
update();
scrollElement.addEventListener("scroll", update, { passive: true });
const resizeObserver =
typeof ResizeObserver === "undefined" ? null : new ResizeObserver(update);
resizeObserver?.observe(scrollElement);
if (contentRef.current) resizeObserver?.observe(contentRef.current);
return () => {
scrollElement.removeEventListener("scroll", update);
resizeObserver?.disconnect();
};
}, [contentRef, scrollRef]);
const displayControlsAtBottom = isExactlyAtBottom && !escapedFromLock;
useEffect(() => {
onAtBottomChange?.(displayControlsAtBottom);
}, [displayControlsAtBottom, onAtBottomChange]);
return null;
}
export function MessageList({
className,
contentClassName,
fitMessageRowsToContent = false,
@@ -120,9 +166,10 @@ export function MessageList({
thread,
paddingBottom = MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
tokenUsageEnabled = false,
extraRehypePlugins,
scrollButtonClassName,
onClarificationSubmit,
extraRehypePlugins,
scrollButtonClassName,
onAtBottomChange,
onClarificationSubmit,
onEditResubmit,
onEditSave,
messageContentOverrides,
@@ -139,9 +186,10 @@ export function MessageList({
businessCardSlot,
speakerShell,
onOpenArtifact,
onDownloadArtifact,
afterDocumentRewriteItems,
}: {
onDownloadArtifact,
afterDocumentRewriteItems,
answerFeedbackEnabled = false,
}: {
className?: string;
contentClassName?: string;
fitMessageRowsToContent?: boolean;
@@ -149,8 +197,10 @@ export function MessageList({
thread: BaseStream<AgentThreadState>;
paddingBottom?: number;
tokenUsageEnabled?: boolean;
/** 额外定位「回到最新」悬浮按钮——不同页面输入框高度不同,需上移让位。 */
scrollButtonClassName?: string;
/** 额外定位「回到最新」悬浮按钮——不同页面输入框高度不同,需上移让位。 */
scrollButtonClassName?: string;
/** Reports whether the message viewport is currently at its bottom edge. */
onAtBottomChange?: (isAtBottom: boolean) => void;
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
onClarificationSubmit?: (answer: string) => void | Promise<void>;
onEditResubmit?: (text: string) => void;
@@ -194,9 +244,15 @@ export function MessageList({
onOpenArtifact?: (filepath: string) => void;
/** Host override for downloading virtual/session-owned files in a present-files card. */
onDownloadArtifact?: (filepath: string) => void | Promise<void>;
/** Optional host content placed immediately after document-rewrite timelines. */
afterDocumentRewriteItems?: React.ReactNode;
}) {
/** Optional host content placed immediately after document-rewrite timelines. */
afterDocumentRewriteItems?: React.ReactNode;
/**
* Enables per-answer user feedback for persisted assistant replies. Kept
* opt-in because MessageList is also reused by read-only previews, setup
* flows and virtual business-card feeds.
*/
answerFeedbackEnabled?: boolean;
}) {
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
const rehypePlugins = useMemo(
() => (extraRehypePlugins?.length ? [...baseRehypePlugins, ...extraRehypePlugins] : baseRehypePlugins),
@@ -243,7 +299,7 @@ export function MessageList({
: ({ ...message, content: override } as Message);
});
}, [messageContentOverrides, thread.messages]);
const messages = useMemo(
const messages = useMemo(
() => {
const externalHidden = externallyHiddenMessageIds ?? new Set<string>();
return hiddenMessageIds.size === 0 && externalHidden.size === 0
@@ -255,8 +311,41 @@ export function MessageList({
);
},
[allMessages, externallyHiddenMessageIds, hiddenMessageIds],
);
const documentRewriteItems = documentRewritePresentation?.forThread(threadId) ?? [];
);
const feedbackAvailable = answerFeedbackEnabled && allowDelete;
const answerFeedbackMessageIds = useMemo(() => {
if (!feedbackAvailable) return [];
const streamingAssistantId = thread.isLoading
? [...messages].reverse().find((message) => message.type === "ai")?.id
: undefined;
return messages.flatMap((message) => {
const kwargs = message.additional_kwargs ?? {};
const explicitlyStreaming =
kwargs.deep_research_summary_streaming === true ||
kwargs.deep_research_follow_up_streaming === true;
const loading =
explicitlyStreaming ||
(Boolean(streamingAssistantId) && message.id === streamingAssistantId);
return isEligibleAnswerFeedbackMessage(message, loading) && message.id
? [message.id]
: [];
});
}, [feedbackAvailable, messages, thread.isLoading]);
const { data: answerFeedbackContexts } = useAnswerFeedbackContexts(
threadId,
answerFeedbackMessageIds,
feedbackAvailable,
);
const ratableAnswerMessageIds = useMemo(() => {
const lastMessageByRun = new Map<string, string>();
for (const message of messages) {
if (!message.id) continue;
const context = answerFeedbackContexts?.[message.id];
if (context) lastMessageByRun.set(context.run_id, message.id);
}
return new Set(lastMessageByRun.values());
}, [answerFeedbackContexts, messages]);
const documentRewriteItems = documentRewritePresentation?.forThread(threadId) ?? [];
const askTimes = useChatAskTimes();
@@ -320,8 +409,9 @@ export function MessageList({
const { data: citationDisplay } = useCitationDisplaySettings();
const referenceModeEntryEnabled =
citationDisplay?.reference_mode_enabled ?? false;
const knowledgeReferencesEnabled =
threadContext?.knowledgeReferencesEnabled === true;
const knowledgeReferencesEnabled =
threadContext?.knowledgeReferencesEnabled === true ||
!!threadContext?.knowledgeReferences;
const ragModeEnabled =
(referenceModeEntryEnabled && settings.display.ragModeEnabled) ||
isThreadRagModeLocked(threadId) ||
@@ -522,9 +612,10 @@ export function MessageList({
return (
<Conversation
className={cn("flex size-full flex-col justify-center", className)}
>
<ScrollToBottomOnUserMessage threadId={threadId} messages={messages} />
<ConversationContent
>
<ScrollToBottomOnUserMessage threadId={threadId} messages={messages} />
<ConversationBottomObserver onAtBottomChange={onAtBottomChange} />
<ConversationContent
className={cn(
"mx-auto w-full max-w-(--container-width-lg)",
hasSingleRoundRag ? "gap-4 pt-4" : "gap-8 pt-12",
@@ -584,8 +675,14 @@ export function MessageList({
onMessageDeleted={
allowDelete ? handleMessageDeleted : undefined
}
turnTime={msg.id ? turnTimes.get(msg.id) : undefined}
/>
turnTime={msg.id ? turnTimes.get(msg.id) : undefined}
answerFeedbackEnabled={answerFeedbackEnabled}
answerFeedbackContext={
msg.id && ratableAnswerMessageIds.has(msg.id)
? answerFeedbackContexts?.[msg.id]
: undefined
}
/>
);
});
if (speakerShell && group.type === "assistant") {
@@ -956,8 +1053,9 @@ export type SetupWritingApprovalPayload = {
outline?: string;
};
export type SetupReportStructureApprovalPayload = {
title?: string;
export type SetupReportStructureApprovalPayload = {
kind?: "report_structure" | "gongwen_template";
title?: string;
name?: string;
content?: string;
structure_mode?: string;
@@ -1060,8 +1158,8 @@ function SetupAgentApprovalCard({
return null;
}
const approvalPayload = payload;
const busy = disabled || submitting !== null;
const approvalPayload = payload;
const busy = disabled || submitting !== null;
const skills = approvalPayload.skills ?? [];
async function run(action: "approve" | "reject") {
@@ -1357,7 +1455,7 @@ const STRUCTURE_MODE_LABELS: Record<string, string> = {
adaptive: "弹性大纲",
};
function SetupReportStructureApprovalCard({
function SetupReportStructureApprovalCard({
disabled,
message,
onApprove,
@@ -1375,7 +1473,8 @@ function SetupReportStructureApprovalCard({
return null;
}
const approvalPayload = payload;
const approvalPayload = payload;
const isGongwenTemplate = approvalPayload.kind === "gongwen_template";
const busy = disabled || submitting !== null;
const directions = approvalPayload.retrieval_directions ?? [];
const skills = approvalPayload.retrieval_skills ?? [];
@@ -1400,8 +1499,8 @@ function SetupReportStructureApprovalCard({
<div className="border-border bg-card text-card-foreground w-full rounded-lg border p-4 shadow-sm">
<div className="space-y-3">
<div>
<div className="text-muted-foreground text-xs font-medium">报告结构建议</div>
<div className="text-sm font-medium">确认后将应用到右侧表单,再点保存入库。</div>
<div className="text-muted-foreground text-xs font-medium">{isGongwenTemplate ? "公文模板结构建议" : "报告结构建议"}</div>
<div className="text-sm font-medium">{isGongwenTemplate ? "确认后将应用到右侧模板表单,再保存到模板中心。" : "确认后将应用到右侧表单,再点保存入库。"}</div>
</div>
{approvalPayload.title ? (
@@ -1443,7 +1542,7 @@ function SetupReportStructureApprovalCard({
{approvalPayload.content ? (
<div className="space-y-1">
<div className="text-muted-foreground text-xs">报告大纲</div>
<div className="text-muted-foreground text-xs">{isGongwenTemplate ? "模板正文结构" : "报告大纲"}</div>
<pre className="bg-muted max-h-52 overflow-auto rounded-md p-3 text-xs whitespace-pre-wrap">
{approvalPayload.content}
</pre>
@@ -1,6 +1,6 @@
"use client";
import { DownloadIcon, Loader2 } from "lucide-react";
import { DownloadIcon, InfoIcon, Loader2 } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
@@ -13,8 +13,10 @@ import {
} from "@/components/ui/dialog";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import { useI18n } from "@/core/i18n/hooks";
import { reasoningPlugins } from "@/core/streamdown";
import { DEFAULT_UPLOAD_POLICY, useUploadPolicy } from "@/core/uploads";
const markdownClassName =
"!h-auto !w-full max-w-none min-w-0 text-sm leading-7 " +
@@ -56,14 +58,101 @@ function kindOf(filename: string): PreviewKind {
return "binary";
}
function withReplacedExtension(url: string): string {
// Replace the path extension with .md, keeping any query string intact.
function withAppendedMarkdownExtension(url: string): string {
const [base, query] = url.split("?");
const appended = `${base}.md`;
return query ? `${appended}?${query}` : appended;
}
function withLegacyReplacedExtension(url: string): string {
// Compatibility with uploads converted before sidecars switched from
// `report.md` to the collision-safe `report.docx.md` naming scheme.
const [base, query] = url.split("?");
const i = base.lastIndexOf(".");
const swapped = (i >= 0 ? base.slice(0, i) : base) + ".md";
return query ? `${swapped}?${query}` : swapped;
}
type ArtifactPreviewResponse = {
path: string;
name: string;
total_bytes: number;
content: string;
truncated: boolean;
strategy: string;
stats?: Record<string, unknown> | null;
warnings?: string[];
};
type PreviewMeta = Pick<
ArtifactPreviewResponse,
"total_bytes" | "truncated" | "strategy" | "warnings"
>;
function parseTotalBytes(response: Response): number | undefined {
const contentRange = response.headers.get("Content-Range");
const rangeTotal = contentRange?.match(/\/(\d+)$/)?.[1];
if (rangeTotal) return Number(rangeTotal);
const contentLengthHeader = response.headers.get("Content-Length");
if (!contentLengthHeader) return undefined;
const contentLength = Number(contentLengthHeader);
return Number.isFinite(contentLength) ? contentLength : undefined;
}
/** Read a streamed response up to maxBytes and cancel the rest. */
async function readBoundedTextResponse(
response: Response,
maxBytes: number,
): Promise<{ content: string; totalBytes: number; truncated: boolean }> {
if (!response.body) throw new Error("浏览器不支持流式预览");
const reader = response.body.getReader();
const decoder = new TextDecoder();
const declaredTotal = parseTotalBytes(response);
let received = 0;
let content = "";
let completed = false;
while (received < maxBytes) {
const { done, value } = await reader.read();
if (done) {
completed = true;
break;
}
if (!value) continue;
const remaining = maxBytes - received;
const slice = value.byteLength > remaining ? value.subarray(0, remaining) : value;
received += slice.byteLength;
content += decoder.decode(slice, { stream: received < maxBytes });
if (value.byteLength > remaining || received >= maxBytes) {
await reader.cancel();
break;
}
}
content += decoder.decode();
const totalBytes = declaredTotal ?? received;
return {
content,
totalBytes,
truncated: !completed || totalBytes > received,
};
}
function strategyLabel(strategy: string): string {
if (strategy.includes("sampling") || strategy.includes("sample")) {
return "流式统计 + 开头/中段/结尾分块采样";
}
if (strategy === "complete-text") return "完整文本";
if (strategy === "bounded-prefix") return "有界前缀";
if (strategy === "client-bounded-prefix") return "兼容模式有界前缀";
return strategy;
}
function formatBytes(bytes: number): string {
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(2)} MiB`;
if (bytes >= 1024) return `${(bytes / 1024).toFixed(2)} KiB`;
return `${bytes} B`;
}
/**
* Fetch an auth-gated artifact (the artifact route requires an
* `Authorization: Bearer` header, which a plain <img>/<iframe> cannot send —
@@ -122,16 +211,31 @@ export function useAuthedObjectUrl(url: string | null, enabled: boolean) {
export function UploadedFilePreviewDialog({
filename,
fileUrl,
threadId,
virtualPath,
open,
onOpenChange,
}: {
filename: string;
/** Absolute artifact URL for the original uploaded file. */
fileUrl: string;
/** Used by the bounded preview endpoint; optional for old/public messages. */
threadId?: string;
virtualPath?: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { t } = useI18n();
const { data: uploadPolicy } = useUploadPolicy();
const effectivePolicy = uploadPolicy ?? DEFAULT_UPLOAD_POLICY;
const maxPreviewChars = Math.max(
256,
Math.min(
32_000,
effectivePolicy.large_file.max_result_chars,
effectivePolicy.large_file.hard_max_result_chars,
),
);
const kind = useMemo(() => kindOf(filename), [filename]);
const ext = useMemo(() => extOf(filename), [filename]);
@@ -141,11 +245,34 @@ export function UploadedFilePreviewDialog({
useAuthedObjectUrl(fileUrl, blobEnabled);
// text / office → fetch text (office reads the converted sibling .md).
const textUrl = useMemo(
() => (kind === "office" ? withReplacedExtension(fileUrl) : fileUrl),
const textUrls = useMemo(
() =>
kind === "office"
? Array.from(
new Set([
withAppendedMarkdownExtension(fileUrl),
withLegacyReplacedExtension(fileUrl),
]),
)
: [fileUrl],
[kind, fileUrl],
);
const previewPaths = useMemo(
() =>
virtualPath
? kind === "office"
? Array.from(
new Set([
withAppendedMarkdownExtension(virtualPath),
withLegacyReplacedExtension(virtualPath),
]),
)
: [virtualPath]
: [],
[kind, virtualPath],
);
const [text, setText] = useState<string | null>(null);
const [previewMeta, setPreviewMeta] = useState<PreviewMeta | null>(null);
const [textLoading, setTextLoading] = useState(false);
const [textError, setTextError] = useState<string | null>(null);
@@ -155,14 +282,71 @@ export function UploadedFilePreviewDialog({
setTextLoading(true);
setTextError(null);
setText(null);
apiFetch(textUrl)
.then(async (res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.text();
})
.then((body) => {
if (!cancelled) setText(body);
})
setPreviewMeta(null);
const load = async () => {
if (threadId && previewPaths.length > 0) {
for (const previewPath of previewPaths) {
try {
const endpoint = `${getBackendBaseURL()}/api/threads/${encodeURIComponent(
threadId,
)}/artifact-preview?path=${encodeURIComponent(
previewPath,
)}&max_chars=${maxPreviewChars}`;
const response = await apiFetch(endpoint);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const body = (await response.json()) as ArtifactPreviewResponse;
const rawContent = String(body.content ?? "");
const content = rawContent.slice(0, maxPreviewChars);
if (!cancelled) {
setText(content);
setPreviewMeta({
total_bytes: Number(body.total_bytes) || 0,
truncated:
Boolean(body.truncated) || rawContent.length > content.length,
strategy: String(body.strategy || "bounded-prefix"),
warnings: Array.isArray(body.warnings) ? body.warnings : [],
});
}
return;
} catch {
// Try a legacy Office sidecar, then fall through to the bounded
// Range fallback during a rolling upgrade or public share.
}
}
}
const maxBytes = Math.max(4_096, maxPreviewChars * 4);
let lastError: Error | null = null;
for (const textUrl of textUrls) {
try {
const response = await apiFetch(textUrl, {
headers: { Range: `bytes=0-${maxBytes - 1}` },
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const bounded = await readBoundedTextResponse(response, maxBytes);
if (!cancelled) {
const content = bounded.content.slice(0, maxPreviewChars);
setText(content);
setPreviewMeta({
total_bytes: bounded.totalBytes,
truncated:
bounded.truncated || bounded.content.length > content.length,
strategy: "client-bounded-prefix",
warnings: [
"采样预览接口暂不可用,当前仅显示受限的文件开头,未读取剩余内容。",
],
});
}
return;
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
}
}
throw lastError ?? new Error("无法读取文件预览");
};
void load()
.catch((err: unknown) => {
if (!cancelled)
setTextError(err instanceof Error ? err.message : String(err));
@@ -173,7 +357,7 @@ export function UploadedFilePreviewDialog({
return () => {
cancelled = true;
};
}, [open, kind, textUrl]);
}, [open, kind, maxPreviewChars, previewPaths, textUrls, threadId]);
const renderAsMarkdown =
kind === "office" || ext === "md" || ext === "markdown";
@@ -242,18 +426,41 @@ export function UploadedFilePreviewDialog({
loadingView
) : textError ? (
<BinaryFallback fileUrl={fileUrl} label={t.common.download} />
) : renderAsMarkdown ? (
<MarkdownContent
className={markdownClassName}
content={text ?? ""}
isLoading={false}
remarkPlugins={reasoningPlugins.remarkPlugins}
rehypePlugins={reasoningPlugins.rehypePlugins}
/>
) : (
<pre className="text-foreground/80 bg-muted/40 max-w-full overflow-x-auto rounded-lg border p-4 font-mono text-xs leading-relaxed whitespace-pre-wrap">
{text ?? ""}
</pre>
<div className="space-y-3">
{previewMeta &&
(previewMeta.truncated ||
previewMeta.strategy !== "complete-text" ||
(previewMeta.warnings?.length ?? 0) > 0) && (
<div className="border-amber-300/70 bg-amber-50/70 text-amber-950 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-100 rounded-lg border px-3 py-2 text-xs">
<div className="flex items-start gap-2 font-medium">
<InfoIcon className="mt-0.5 size-3.5 shrink-0" />
<span>
{previewMeta.truncated ? "当前为截断/采样预览" : "预览读取方式"}
:{strategyLabel(previewMeta.strategy)};文件大小 {formatBytes(previewMeta.total_bytes)},本次最多展示 {maxPreviewChars.toLocaleString()} 个字符。
</span>
</div>
{previewMeta.warnings?.map((warning) => (
<p key={warning} className="mt-1 pl-5 opacity-80">
{warning}
</p>
))}
</div>
)}
{renderAsMarkdown ? (
<MarkdownContent
className={markdownClassName}
content={text ?? ""}
isLoading={false}
remarkPlugins={reasoningPlugins.remarkPlugins}
rehypePlugins={reasoningPlugins.rehypePlugins}
/>
) : (
<pre className="text-foreground/80 bg-muted/40 max-w-full overflow-x-auto rounded-lg border p-4 font-mono text-xs leading-relaxed whitespace-pre-wrap">
{text ?? ""}
</pre>
)}
</div>
)
) : (
<BinaryFallback fileUrl={fileUrl} label={t.common.download} />
@@ -1,4 +1,9 @@
import { FolderKanban } from "lucide-react";
import {
FileText,
MessageSquareText,
Sparkles,
type LucideIcon,
} from "lucide-react";
import {
MY_PROJECT_CONVERSATION_LABELS,
@@ -14,6 +19,20 @@ const BADGE_STYLES: Record<MyProjectConversationType, string> = {
"border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-700/70 dark:bg-emerald-950/55 dark:text-emerald-300",
};
const ICONS: Record<MyProjectConversationType, LucideIcon> = {
qa: MessageSquareText,
strategy: Sparkles,
solution: FileText,
};
const ICON_STYLES: Record<MyProjectConversationType, string> = {
qa: "bg-sky-50 text-sky-600 dark:bg-sky-950/70 dark:text-sky-300",
strategy:
"bg-violet-50 text-violet-600 dark:bg-violet-950/70 dark:text-violet-300",
solution:
"bg-emerald-50 text-emerald-600 dark:bg-emerald-950/70 dark:text-emerald-300",
};
export function MyProjectThreadIcon({
type,
className,
@@ -21,31 +40,47 @@ export function MyProjectThreadIcon({
type: MyProjectConversationType;
className?: string;
}) {
const Icon = ICONS[type];
const label = `我的项目 · ${MY_PROJECT_CONVERSATION_LABELS[type]}`;
return (
<FolderKanban
aria-label={`我的项目 · ${MY_PROJECT_CONVERSATION_LABELS[type]}`}
className={cn("size-3.5 shrink-0 text-violet-500", className)}
/>
<span
aria-label={label}
className={cn(
"inline-flex size-5 shrink-0 items-center justify-center rounded-md",
ICON_STYLES[type],
className,
)}
role="img"
title={label}
>
<Icon aria-hidden="true" className="size-3.5" />
</span>
);
}
export function MyProjectThreadBadge({
type,
projectName,
className,
}: {
type: MyProjectConversationType;
projectName?: string | null;
className?: string;
}) {
const typeLabel = `${MY_PROJECT_CONVERSATION_LABELS[type]}项目`;
const ownerLabel = projectName?.trim() || "我的项目";
return (
<span
className={cn(
"shrink-0 rounded border px-1 py-0.5 text-[9px] font-medium leading-none",
"inline-flex min-w-0 max-w-full items-center gap-1 self-start rounded border px-1.5 py-0.5 text-[9px] font-medium leading-none",
BADGE_STYLES[type],
className,
)}
title={`来自我的项目 · ${MY_PROJECT_CONVERSATION_LABELS[type]}`}
title={`来自我的项目 · ${typeLabel} · ${ownerLabel}`}
>
{MY_PROJECT_CONVERSATION_LABELS[type]}
<span className="shrink-0">{typeLabel}</span>
<span aria-hidden="true" className="opacity-55">·</span>
<span className="min-w-0 truncate">{ownerLabel}</span>
</span>
);
}
@@ -47,7 +47,8 @@ import {
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar";
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
import {
@@ -68,7 +69,8 @@ import {
useToggleThreadPin,
} from "@/core/threads/hooks";
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
import { myProjectConversationTypeOfThread } from "@/core/threads/project-metadata";
import { myProjectConversationInfoOfThread } from "@/core/threads/project-metadata";
import { useProjectThreadNames } from "@/core/threads/use-project-thread-names";
import {
isBootstrapThread,
isHiddenFromRecentChats,
@@ -80,9 +82,14 @@ import {
titleOfThread,
} from "@/core/threads/utils";
import { env } from "@/env";
import { desensitize } from "@/lib/desensitize";
import { isIMEComposing } from "@/lib/ime";
import { cn } from "@/lib/utils";
import { desensitize } from "@/lib/desensitize";
import { isIMEComposing } from "@/lib/ime";
import { cn } from "@/lib/utils";
import { strategyProjectWorkbenchPath } from "@/strategy-selection/project-routes";
import {
mergeRecentWorkspaceItems,
useRecentStrategyProjects,
} from "@/strategy-selection/recent-projects";
export function RecentChatList({
compact = false,
@@ -97,7 +104,8 @@ export function RecentChatList({
}) {
const { t } = useI18n();
const router = useRouter();
const pathname = usePathname();
const pathname = usePathname();
const { isMobile, setOpenMobile } = useSidebar();
const [internalCollapsed, setInternalCollapsed] = useState(
() => localStorage.getItem("sidebar:recent-chats-collapsed") === "true",
);
@@ -114,9 +122,10 @@ export function RecentChatList({
useParams<{
thread_id: string;
agent_id?: string;
}>();
const { data: threads = [] } = useThreads();
const { mutate: deleteThread } = useDeleteThread();
}>();
const { data: threads = [] } = useThreads();
const { data: recentStrategyProjects = [] } = useRecentStrategyProjects();
const { mutate: deleteThread } = useDeleteThread();
const { mutate: renameThread } = useRenameThread();
const { toggle: togglePin } = useToggleThreadPin();
const { toggle: toggleFavorite } = useToggleThreadFavorite();
@@ -139,15 +148,15 @@ export function RecentChatList({
});
if (threadIndex > -1) {
if (threads[threadIndex + 1]) {
nextThreadPath = listPathOfThread(threads[threadIndex + 1]!);
nextThreadPath = listPathOfThread(threads[threadIndex + 1]!, pathname);
} else if (threads[threadIndex - 1]) {
nextThreadPath = listPathOfThread(threads[threadIndex - 1]!);
nextThreadPath = listPathOfThread(threads[threadIndex - 1]!, pathname);
}
}
void router.push(nextThreadPath);
}
},
[agentIdFromPath, deleteThread, router, threadIdFromPath, threads],
[agentIdFromPath, deleteThread, pathname, router, threadIdFromPath, threads],
);
const handleRenameClick = useCallback(
@@ -200,15 +209,19 @@ export function RecentChatList({
// 智能体创建(bootstrap)会话也展示在常用对话里(带机器人图标、点击回创建页);
// 排除调度 / 会商 / 报告结构&写作配置向导等系统会话。
const visibleThreads = sortThreadsPinnedFirst(
threads.filter((thread) => !isHiddenFromRecentChats(thread)),
);
if (visibleThreads.length === 0) {
return null;
}
const shownThreads =
limit != null ? visibleThreads.slice(0, limit) : visibleThreads;
const visibleThreads = sortThreadsPinnedFirst(
threads.filter((thread) => !isHiddenFromRecentChats(thread)),
);
const projectThreadNames = useProjectThreadNames(visibleThreads);
const recentItems = mergeRecentWorkspaceItems(
visibleThreads,
recentStrategyProjects,
);
if (recentItems.length === 0) {
return null;
}
const shownItems = limit != null ? recentItems.slice(0, limit) : recentItems;
return (
<>
<SidebarGroup
@@ -229,9 +242,9 @@ export function RecentChatList({
: t.sidebar.demoChats}
</span>
{compact && (
<span className="text-muted-foreground bg-muted rounded-full px-1.5 py-0.5 text-[10px] font-normal tracking-normal tabular-nums">
{visibleThreads.length}
</span>
<span className="text-muted-foreground bg-muted rounded-full px-1.5 py-0.5 text-[10px] font-normal tracking-normal tabular-nums">
{recentItems.length}
</span>
)}
</span>
{!compact && (
@@ -247,33 +260,84 @@ export function RecentChatList({
>
<SidebarMenu>
<div className="flex w-full flex-col gap-0.5">
{shownThreads.map((thread) => {
const href = listPathOfThread(thread);
{shownItems.map((item) => {
if (item.kind === "strategy-project") {
const { project } = item;
const href = strategyProjectWorkbenchPath(
"/page/strategy/qa/projects",
project.id,
);
return (
<SidebarMenuItem
key={`strategy-project:${project.id}`}
className="group/side-menu-item"
>
<SidebarMenuButton
isActive={href === pathname}
asChild
className={cn(
"h-auto min-h-11 rounded-lg px-3 py-1.5 text-[13px] text-muted-foreground hover:bg-sidebar-accent/60 hover:text-foreground",
"data-[active=true]:bg-white data-[active=true]:text-foreground data-[active=true]:shadow-[0_1px_2px_rgba(15,23,42,0.05)]",
"dark:data-[active=true]:bg-slate-700/80 dark:data-[active=true]:text-slate-100 dark:data-[active=true]:shadow-none",
compact &&
"min-h-11 gap-2 rounded-md px-2.5 data-[active=true]:border data-[active=true]:border-border",
)}
>
<Link
className="flex w-full items-center gap-1 whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
href={href}
onClick={() => {
if (isMobile) setOpenMobile(false);
}}
>
<MyProjectThreadIcon type="strategy" />
<span className="min-w-0 flex-1 flex flex-col items-start gap-0.5">
<span className="w-full truncate">
{desensitize(project.title)}
</span>
<MyProjectThreadBadge
className="mr-3 max-w-[calc(100%_-_0.75rem)]"
projectName={project.title}
type="strategy"
/>
</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
);
}
const { thread } = item;
const href = listPathOfThread(thread, pathname);
const isActive = href === pathname;
const isBoot = isBootstrapThread(thread);
const projectType = myProjectConversationTypeOfThread(thread);
const projectInfo = myProjectConversationInfoOfThread(
thread,
projectThreadNames,
);
const pinned = isThreadPinned(thread);
const favorited = isThreadFavorite(thread);
return (
<SidebarMenuItem
key={thread.thread_id}
<SidebarMenuItem
key={`thread:${thread.thread_id}`}
className="group/side-menu-item"
>
<SidebarMenuButton
isActive={isActive}
asChild
className={
"rounded-lg h-7 px-3 text-[13px] text-muted-foreground hover:text-foreground hover:bg-sidebar-accent/60 " +
"data-[active=true]:bg-white data-[active=true]:text-foreground data-[active=true]:shadow-[0_1px_2px_rgba(15,23,42,0.05)] " +
"dark:data-[active=true]:bg-slate-700/80 dark:data-[active=true]:text-slate-100 dark:data-[active=true]:shadow-none" +
(compact
? " h-9 gap-2 rounded-md px-2.5 data-[active=true]:border data-[active=true]:border-border"
: "")
}
className={cn(
"h-7 rounded-lg px-3 text-[13px] text-muted-foreground hover:bg-sidebar-accent/60 hover:text-foreground",
"data-[active=true]:bg-white data-[active=true]:text-foreground data-[active=true]:shadow-[0_1px_2px_rgba(15,23,42,0.05)]",
"dark:data-[active=true]:bg-slate-700/80 dark:data-[active=true]:text-slate-100 dark:data-[active=true]:shadow-none",
compact &&
"h-9 gap-2 rounded-md px-2.5 data-[active=true]:border data-[active=true]:border-border",
projectInfo && "h-auto min-h-11 py-1.5",
)}
>
<Link
className="flex w-full items-center gap-1 whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
href={href}
href={href}
onClick={() => { if (isMobile) setOpenMobile(false); }}
>
{isBoot ? (
<Bot
@@ -285,8 +349,8 @@ export function RecentChatList({
}
aria-label="智能体创建会话"
/>
) : projectType ? (
<MyProjectThreadIcon type={projectType} />
) : projectInfo ? (
<MyProjectThreadIcon type={projectInfo.type} />
) : (
compact && (
<MessageSquare
@@ -302,15 +366,23 @@ export function RecentChatList({
{pinned && (
<Pin className="size-3 shrink-0 text-muted-foreground/70" />
)}
<span className="min-w-0 flex-1 truncate">
{desensitize(titleOfThread(thread, t.pages.newChat))}
<span
className={cn(
"min-w-0 flex-1",
projectInfo && "flex flex-col items-start gap-0.5",
)}
>
<span className="w-full truncate">
{desensitize(titleOfThread(thread, t.pages.newChat))}
</span>
{projectInfo && (
<MyProjectThreadBadge
className="mr-3 max-w-[calc(100%_-_0.75rem)]"
projectName={projectInfo.projectName}
type={projectInfo.type}
/>
)}
</span>
{projectType && (
<MyProjectThreadBadge
className="mr-3"
type={projectType}
/>
)}
</Link>
</SidebarMenuButton>
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
@@ -0,0 +1,67 @@
"use client";
import { BrainIcon, Loader2Icon } from "lucide-react";
import { toast } from "sonner";
import { Switch } from "@/components/ui/switch";
import {
useMemoryRuntimeSettings,
useUpdateMemoryRuntimeSettings,
} from "@/core/system-settings/hooks";
export function MemoryRuntimeSettingsPage() {
const { data, isLoading, isError } = useMemoryRuntimeSettings();
const update = useUpdateMemoryRuntimeSettings();
const enabled = data?.enabled ?? false;
const handleChange = async (checked: boolean) => {
try {
await update.mutateAsync({ enabled: checked });
toast.success(checked ? "问答记忆已开启" : "问答记忆已关闭");
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
}
};
return (
<section className="space-y-6">
<div className="space-y-1">
<h3 className="flex items-center gap-2 text-lg font-semibold">
<BrainIcon className="size-5" />
问答记忆
</h3>
<p className="text-muted-foreground text-sm">
全局控制问答是否读取和写入长期记忆。系统默认永久关闭,只有管理员在这里显式开启后才会生效。
</p>
</div>
<div className="flex items-center justify-between gap-6 rounded-lg border p-4">
<div className="space-y-1">
<div className="font-medium">启用问答记忆</div>
<p className="text-muted-foreground text-sm">
关闭时不会加载记忆中间件、不会向提示词注入记忆,也不会在回答结束后调用模型提取记忆。
</p>
</div>
{isLoading ? (
<Loader2Icon className="text-muted-foreground size-5 animate-spin" />
) : (
<Switch
checked={enabled}
disabled={isError || update.isPending}
onCheckedChange={handleChange}
aria-label="启用问答记忆"
/>
)}
</div>
{isError && (
<p className="text-destructive text-sm">
无法读取记忆开关,系统将按关闭处理。
</p>
)}
<p className="text-muted-foreground text-xs">
修改对下一次发起的问答生效;已生成的历史记忆文件不会被自动删除。
</p>
</section>
);
}
@@ -1,6 +1,6 @@
"use client";
import { BellIcon, CpuIcon, PaletteIcon, SparklesIcon, UserIcon, WrenchIcon } from "lucide-react";
import { BellIcon, BrainIcon, CpuIcon, PaletteIcon, SparklesIcon, UploadCloudIcon, UserIcon, WrenchIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import {
@@ -13,8 +13,9 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { AboutSettingsPage } from "@/components/workspace/settings/about-settings-page";
import { AccountSettingsPage } from "@/components/workspace/settings/account-settings-page";
import { AppearanceSettingsPage } from "@/components/workspace/settings/appearance-settings-page";
import { MemorySettingsPage } from "@/components/workspace/settings/memory-settings-page";
import { MemoryRuntimeSettingsPage } from "@/components/workspace/settings/memory-runtime-settings-page";
import { ToolSettingsPage } from "@/components/workspace/settings/tool-settings-page";
import { UploadSettingsPage } from "@/components/workspace/settings/upload-settings-page";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import { cn } from "@/lib/utils";
@@ -24,6 +25,7 @@ type SettingsSection =
| "account"
| "appearance"
| "memory"
| "uploads"
| "tools"
| "encoder"
| "about";
@@ -61,15 +63,15 @@ export function SettingsDialog(props: SettingsDialogProps) {
// },
// { id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
// { id: "skills", label: t.settings.sections.skills, icon: SparklesIcon },
// 记忆已移至侧边栏入口 (/page/workspace/memory)
// {
// id: "memory",
// label: t.settings.sections.memory,
// icon: BrainIcon,
// },
...(isAdmin
? [
{ id: "memory", label: "问答记忆", icon: BrainIcon },
{ id: "uploads", label: "上传设置", icon: UploadCloudIcon },
]
: []),
{ id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
...(isAdmin
? [{ id: "encoder", label: "本地编码器", icon: CpuIcon }]
? [{ id: "encoder", label: "Wiki 镜像编码器", icon: CpuIcon }]
: []),
{
id: "account",
@@ -132,7 +134,12 @@ export function SettingsDialog(props: SettingsDialogProps) {
{activeSection === "appearance" && (
<AppearanceSettingsPage onClose={() => props.onOpenChange?.(false)} />
)}
{activeSection === "memory" && <MemorySettingsPage />}
{activeSection === "memory" && isAdmin && (
<MemoryRuntimeSettingsPage />
)}
{activeSection === "uploads" && isAdmin && (
<UploadSettingsPage />
)}
{activeSection === "tools" && <ToolSettingsPage />}
{activeSection === "encoder" && isAdmin && <LocalEncoderSettingsCard />}
{activeSection === "about" && <AboutSettingsPage />}
@@ -19,6 +19,8 @@ import {
LockIcon,
PinIcon,
PlusIcon,
Power,
PowerOff,
RefreshCwIcon,
SparklesIcon,
TagsIcon,
@@ -71,10 +73,11 @@ import { useTheme } from "@/contexts/ThemeContext";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import type { ValidateSkillUploadResponse } from "@/core/skills/api";
import {
useDeleteBuiltinSkill,
useDeleteCustomSkill,
import {
useDeleteBuiltinSkill,
useDeleteCustomSkill,
useDuplicateSkill,
useEnableSkill,
useGenerateMissingSkillNameZh,
useGenerateSkillNameZh,
useInstallSkillUpload,
@@ -87,13 +90,25 @@ import {
useSkills,
useValidateSkillUpload,
} from "@/core/skills/hooks";
import {
canToggleSkillAvailability,
isSkillOffline,
type SkillAvailabilityControl,
} from "@/core/skill-availability";
import {
usePublishSquares,
useSkillCompressionSettings,
useViewMode,
} from "@/core/system-settings/hooks";
import {
WX_RESTRICTED_TAB_DEFAULT_SQUARE,
WX_RESTRICTED_TAB_NANJING_DB,
isWxRestrictedPlaceholderTab,
wxRestrictedDefaultSquareTabValue,
wxRestrictedNanjingTabValue,
} from "@/core/system-settings/wx-type-filter";
import type { Skill } from "@/core/skills/type";
import type { SkillKnowledgeBinding } from "@/strategy-components/api/skill-knowledge";
import type { SkillKnowledgeBinding } from "@/strategy-components/api/skill-knowledge";
import { formatDateTime } from "@/core/utils/datetime";
import { env } from "@/env";
import { desensitize } from "@/lib/desensitize";
@@ -102,21 +117,28 @@ import { cn } from "@/lib/utils";
import { SettingsSection } from "./settings-section";
import { SkillStateSheet } from "./skill-state-sheet";
export function SkillSettingsPage({
onClose,
embedded = false,
}: {
onClose?: () => void;
embedded?: boolean;
} = {}) {
const { t } = useI18n();
if (embedded) return <SkillSettingsList onClose={onClose} />;
return (
export function SkillSettingsPage({
onClose,
embedded = false,
restrictWxTypes = false,
}: {
onClose?: () => void;
embedded?: boolean;
/** wx 侧栏入口:类型 Tab 只保留 我的 / 默认广场 / 南京-db。 */
restrictWxTypes?: boolean;
} = {}) {
const { t } = useI18n();
if (embedded) {
return (
<SkillSettingsList onClose={onClose} restrictWxTypes={restrictWxTypes} />
);
}
return (
<SettingsSection
title={t.settings.skills.title}
description={t.settings.skills.description}
>
<SkillSettingsList onClose={onClose} />
<SkillSettingsList onClose={onClose} restrictWxTypes={restrictWxTypes} />
</SettingsSection>
);
}
@@ -165,7 +187,13 @@ const rescanErrorToastDarkOptions = {
icon: <AlertCircle className="size-4 shrink-0 text-[#F5222D]" />,
} as const;
function SkillSettingsList({ onClose }: { onClose?: () => void }) {
function SkillSettingsList({
onClose,
restrictWxTypes = false,
}: {
onClose?: () => void;
restrictWxTypes?: boolean;
}) {
const { t } = useI18n();
const { theme } = useTheme();
const router = useRouter();
@@ -187,6 +215,10 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
// Each configured publish square (发布广场) becomes its own browse tab; the
// backend always returns at least one (the built-in default).
const squareTabs = publishSquares?.squares ?? [];
const defaultSquareTabValue = wxRestrictedDefaultSquareTabValue(
publishSquares?.default_square_id,
);
const nanjingTabValue = wxRestrictedNanjingTabValue(squareTabs);
const reconcileMutation = useReconcileCustomSkills();
const fillNamesMutation = useGenerateMissingSkillNameZh();
// Optional model override for the admin "一键补充中文名" action (null = default).
@@ -253,6 +285,9 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
const isSquareTab = filter !== "mine";
const scopedSkills = filter === "mine" ? activeMine : activeAll;
const filteredSkills = useMemo(() => {
if (restrictWxTypes && isWxRestrictedPlaceholderTab(filter)) {
return [];
}
let list = publisher
? scopedSkills.filter((s) => s.owner_user_id === publisher.id)
: scopedSkills;
@@ -266,7 +301,14 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
});
}
return list;
}, [scopedSkills, publisher, filter, isSquareTab, publishSquares?.default_square_id]);
}, [
scopedSkills,
publisher,
filter,
isSquareTab,
publishSquares?.default_square_id,
restrictWxTypes,
]);
const orderedSkills = useMemo(
() =>
filteredSkills
@@ -296,6 +338,30 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
// If the active square tab disappears (admin removed that square), fall back
// to "我的" so the page never points at a dead tab.
useEffect(() => {
if (restrictWxTypes) {
if (
filter === WX_RESTRICTED_TAB_NANJING_DB &&
nanjingTabValue !== WX_RESTRICTED_TAB_NANJING_DB
) {
setFilter(nanjingTabValue);
return;
}
if (
filter === WX_RESTRICTED_TAB_DEFAULT_SQUARE &&
defaultSquareTabValue !== WX_RESTRICTED_TAB_DEFAULT_SQUARE
) {
setFilter(defaultSquareTabValue);
return;
}
if (
filter !== "mine" &&
filter !== defaultSquareTabValue &&
filter !== nanjingTabValue
) {
setFilter("mine");
}
return;
}
if (
filter !== "mine" &&
squareTabs.length > 0 &&
@@ -303,7 +369,7 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
) {
setFilter("mine");
}
}, [filter, squareTabs]);
}, [filter, squareTabs, restrictWxTypes, defaultSquareTabValue, nanjingTabValue]);
const pagedSkills = useMemo(
() => orderedSkills.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
[orderedSkills, page],
@@ -362,12 +428,22 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
<div className="flex flex-wrap items-center gap-2">
<Tabs value={filter} onValueChange={setFilter}>
<TabsList variant="line" className="flex-wrap">
<TabsTrigger value="mine">{t.settings.skills.tabMine}</TabsTrigger>
{squareTabs.map((sq) => (
<TabsTrigger key={sq.id} value={sq.id} title={sq.description || sq.name}>
{sq.name}
</TabsTrigger>
))}
{restrictWxTypes ? (
<>
<TabsTrigger value="mine">{t.settings.skills.tabMine}</TabsTrigger>
<TabsTrigger value={defaultSquareTabValue}>默认广场</TabsTrigger>
<TabsTrigger value={nanjingTabValue}>南京-db</TabsTrigger>
</>
) : (
<>
<TabsTrigger value="mine">{t.settings.skills.tabMine}</TabsTrigger>
{squareTabs.map((sq) => (
<TabsTrigger key={sq.id} value={sq.id} title={sq.description || sq.name}>
{sq.name}
</TabsTrigger>
))}
</>
)}
</TabsList>
</Tabs>
<Button
@@ -479,11 +555,11 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
</Button>
</div>
</header>
<TagSearchBar
targetType="skill"
onSearchChange={setSearch}
tagIds={tagIds}
onTagIdsChange={setTagIds}
<TagSearchBar
targetType="skill"
onSearchChange={setSearch}
tagIds={tagIds}
onTagIdsChange={setTagIds}
actions={
<div className="flex items-center gap-2">
<ViewModeToggle value={viewMode} onChange={setViewMode} />
@@ -506,16 +582,16 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
<SelectItem value="name_desc">名称 Z-A</SelectItem>
</SelectContent>
</Select>
</div>
}
/>
{!isLoading && !error && (
<div className="text-muted-foreground flex justify-end text-sm tabular-nums">
共 {orderedSkills.length} 个技能
</div>
)}
{publisher && (
<div className="flex items-center gap-2">
</div>
}
/>
{!isLoading && !error && (
<div className="text-muted-foreground flex justify-end text-sm tabular-nums">
共 {orderedSkills.length} 个技能
</div>
)}
{publisher && (
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-xs">筛选发布者:</span>
<button
type="button"
@@ -547,9 +623,10 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
<SkillRow
key={skill.name}
skill={skill}
currentUserId={currentUserId}
isAdmin={isAdmin}
layout={viewMode === "card" ? "card" : "row"}
currentUserId={currentUserId}
isAdmin={isAdmin}
availabilityControl="owner"
layout={viewMode === "card" ? "card" : "row"}
onPublisherClick={setPublisher}
/>
))}
@@ -604,23 +681,26 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
}
export function SkillRow({
skill,
currentUserId,
isAdmin,
skill,
currentUserId,
isAdmin,
availabilityControl,
layout = "row",
onPublisherClick,
knowledgeBindings = [],
knowledgeBindings = [],
}: {
skill: Skill;
currentUserId: string | null;
isAdmin: boolean;
currentUserId: string | null;
isAdmin: boolean;
/** Which permission boundary exposes the global online/offline control. */
availabilityControl?: SkillAvailabilityControl;
/** "row" = full-width horizontal item (default); "card" = grid card cell. */
layout?: "row" | "card";
/** Click the 发布者 line to filter the list to that publisher. Receives the
* owner_user_id + its desensitized display label. */
onPublisherClick?: (publisher: { id: string; label: string }) => void;
/** Admin list can inject its single batched binding query here (never N+1). */
knowledgeBindings?: SkillKnowledgeBinding[];
/** Admin list can inject its single batched binding query here (never N+1). */
knowledgeBindings?: SkillKnowledgeBinding[];
}) {
const isCard = layout === "card";
const { t } = useI18n();
@@ -629,6 +709,7 @@ export function SkillRow({
const setPinned = useSetSkillPinned();
const setFeatured = useSetSkillFeatured();
const setAlwaysOn = useSetSkillAlwaysOn();
const setEnabled = useEnableSkill();
const { data: compression } = useSkillCompressionSettings();
const deleteCustom = useDeleteCustomSkill();
const deleteBuiltin = useDeleteBuiltinSkill();
@@ -639,6 +720,7 @@ export function SkillRow({
const [duplicateOpen, setDuplicateOpen] = useState(false);
const [duplicateName, setDuplicateName] = useState("");
const [publishPickerOpen, setPublishPickerOpen] = useState(false);
const [confirmOfflineOpen, setConfirmOfflineOpen] = useState(false);
const isCustom = skill.category === "custom";
// Built-in flag falls back to "no DB owner" so legacy responses without
@@ -647,12 +729,33 @@ export function SkillRow({
const isOwned =
isCustom && !isBuiltin && !!skill.owner_user_id && skill.owner_user_id === currentUserId;
const isFromOthers = isCustom && !isBuiltin && !isOwned;
// Admin can manage custom skills from any owner; non-admin only their own.
const canManage = isCustom && (isOwned || isAdmin);
const canDelete = isAdmin || isOwned;
// Admin can manage custom skills from any owner; non-admin only their own.
const canManage = isCustom && (isOwned || isAdmin);
const canDelete = isAdmin || isOwned;
const canTogglePublish = canManage && !isBuiltin;
// 常驻(免压缩):内置技能仅管理员可设;自定义技能其所有者或管理员可设(与后端一致)。
const canSetAlwaysOn = isAdmin || isOwned;
const canToggleAvailability = canToggleSkillAvailability({
skill,
currentUserId,
isAdmin,
control: availabilityControl,
});
const isOffline = isSkillOffline(skill);
async function handleSetEnabled(enabled: boolean) {
try {
await setEnabled.mutateAsync({ skillName: skill.name, enabled });
toast.success(
enabled
? t.settings.skills.onlineSuccess
: t.settings.skills.offlineSuccess,
);
setConfirmOfflineOpen(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleToggleAlwaysOn(next: boolean) {
try {
@@ -700,15 +803,15 @@ export function SkillRow({
}
}
async function handleDelete() {
try {
if (isBuiltin) {
await deleteBuiltin.mutateAsync(skill.name);
} else {
await deleteCustom.mutateAsync(skill.name);
}
toast.success(t.settings.skills.deleteSuccess);
setConfirmDeleteOpen(false);
async function handleDelete() {
try {
if (isBuiltin) {
await deleteBuiltin.mutateAsync(skill.name);
} else {
await deleteCustom.mutateAsync(skill.name);
}
toast.success(t.settings.skills.deleteSuccess);
setConfirmDeleteOpen(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
@@ -883,6 +986,16 @@ export function SkillRow({
{knowledgeBindings.length > 3 && (
<Badge variant="secondary" className="text-xs">+{knowledgeBindings.length - 3}</Badge>
)}
{isOffline && (
<Badge
variant="outline"
className="border-red-500/35 bg-red-500/5 text-red-700 dark:text-red-300 text-xs"
title={t.settings.skills.badgeOfflineTitle}
>
<PowerOff className="size-3" />
{t.settings.skills.badgeOffline}
</Badge>
)}
</div>
);
@@ -941,6 +1054,43 @@ export function SkillRow({
const actionButtons = (
<>
{canToggleAvailability && (
<Button
size="sm"
variant={isOffline ? "default" : "outline"}
className={cn(
"h-8 shrink-0 gap-1.5 px-2.5",
!isOffline && "text-destructive hover:text-destructive",
)}
disabled={
setEnabled.isPending ||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"
}
onClick={() => {
if (isOffline) {
void handleSetEnabled(true);
} else {
setConfirmOfflineOpen(true);
}
}}
title={
isOffline
? t.settings.skills.onlineAction
: t.settings.skills.offlineAction
}
>
{setEnabled.isPending ? (
<Loader2 className="size-3.5 animate-spin" />
) : isOffline ? (
<Power className="size-3.5" />
) : (
<PowerOff className="size-3.5" />
)}
{isOffline
? t.settings.skills.onlineAction
: t.settings.skills.offlineAction}
</Button>
)}
<Button
size="icon"
variant="ghost"
@@ -950,7 +1100,7 @@ export function SkillRow({
>
<TagsIcon className="size-3.5" />
</Button>
{canManage && (
{canManage && (
<Button
size="icon"
variant="ghost"
@@ -971,12 +1121,12 @@ export function SkillRow({
/>
</Button>
)}
{canManage && (
<Button
size="icon"
variant="ghost"
className={cn(
"h-8 w-8 shrink-0",
{canManage && (
<Button
size="icon"
variant="ghost"
className={cn(
"h-8 w-8 shrink-0",
skill.pinned && "text-emerald-600 hover:text-emerald-700",
)}
disabled={setPinned.isPending}
@@ -1043,11 +1193,11 @@ export function SkillRow({
>
<CopyIcon className="size-3.5" />
</Button>
{canDelete && (
<Button
size="icon"
variant="ghost"
className="text-destructive hover:text-destructive h-8 w-8 shrink-0"
{canDelete && (
<Button
size="icon"
variant="ghost"
className="text-destructive hover:text-destructive h-8 w-8 shrink-0"
onClick={() => setConfirmDeleteOpen(true)}
title={t.settings.skills.deleteAction}
>
@@ -1060,7 +1210,12 @@ export function SkillRow({
return (
<>
{isCard ? (
<div className="flex h-full min-w-0 flex-col rounded-lg border p-3 transition-shadow hover:shadow-sm dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white">
<div
className={cn(
"flex h-full min-w-0 flex-col rounded-lg border p-3 transition-shadow hover:shadow-sm dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white",
isOffline && "bg-muted/60 border-dashed dark:bg-[#071D36]",
)}
>
<div
className="min-w-0 flex-1 cursor-pointer"
onClick={() => setDetailOpen(true)}
@@ -1076,7 +1231,13 @@ export function SkillRow({
</div>
</div>
) : (
<Item className="w-full dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white" variant="outline">
<Item
className={cn(
"w-full dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white",
isOffline && "bg-muted/60 border-dashed dark:bg-[#071D36]",
)}
variant="outline"
>
<ItemContent
className="cursor-pointer"
onClick={() => setDetailOpen(true)}
@@ -1141,7 +1302,7 @@ export function SkillRow({
<Button
variant="outline"
onClick={() => setConfirmDeleteOpen(false)}
disabled={deleteCustom.isPending || deleteBuiltin.isPending}
disabled={deleteCustom.isPending || deleteBuiltin.isPending}
>
{t.common.cancel}
</Button>
@@ -1153,6 +1314,37 @@ export function SkillRow({
{deleteCustom.isPending || deleteBuiltin.isPending
? t.common.loading
: t.common.delete}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={confirmOfflineOpen} onOpenChange={setConfirmOfflineOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t.settings.skills.offlineConfirmTitle}</DialogTitle>
<DialogDescription>
{t.settings.skills.offlineConfirmDescription.replace(
"{name}",
desensitize(skill.name),
)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setConfirmOfflineOpen(false)}
disabled={setEnabled.isPending}
>
{t.common.cancel}
</Button>
<Button
variant="destructive"
onClick={() => void handleSetEnabled(false)}
disabled={setEnabled.isPending}
>
{setEnabled.isPending
? t.common.loading
: t.settings.skills.offlineAction}
</Button>
</DialogFooter>
</DialogContent>
@@ -1226,24 +1418,24 @@ function InstallSkillDialog({
const validateMutation = useValidateSkillUpload();
const installMutation = useInstallSkillUpload();
const setNameZhMutation = useSetSkillNameZh();
const generateNameZhMutation = useGenerateSkillNameZh();
const validateFile = useCallback(
async (selected: File, category: "custom" | "public") => {
setValidation(null);
setValidationError(null);
try {
const result = await validateMutation.mutateAsync({
file: selected,
targetCategory: category,
});
setValidation(result);
} catch (err) {
setValidationError(err instanceof Error ? err.message : String(err));
}
},
[validateMutation],
);
const generateNameZhMutation = useGenerateSkillNameZh();
const validateFile = useCallback(
async (selected: File, category: "custom" | "public") => {
setValidation(null);
setValidationError(null);
try {
const result = await validateMutation.mutateAsync({
file: selected,
targetCategory: category,
});
setValidation(result);
} catch (err) {
setValidationError(err instanceof Error ? err.message : String(err));
}
},
[validateMutation],
);
const reset = useCallback(() => {
setFile(null);
@@ -1282,12 +1474,12 @@ function InstallSkillDialog({
if (!lower.endsWith(".zip") && !lower.endsWith(".skill")) {
toast.error(dialog.rejectInvalidExt);
return;
}
setFile(selected);
await validateFile(selected, targetCategory);
},
[dialog.rejectInvalidExt, targetCategory, validateFile],
);
}
setFile(selected);
await validateFile(selected, targetCategory);
},
[dialog.rejectInvalidExt, targetCategory, validateFile],
);
const onDrop = useCallback(
(e: React.DragEvent<HTMLDivElement>) => {
@@ -1364,29 +1556,34 @@ function InstallSkillDialog({
}
}, [dialog.nameZhSaved, installedName, nameZhDraft, onClose, reset, setNameZhMutation]);
const isPublicConflict = validation?.conflict && validation.conflict_kind === "public";
const isCustomConflict = validation?.conflict && validation.conflict_kind === "custom";
// Admin targeting public directory can overwrite an existing public skill.
const adminCanOverwritePublic = isAdmin && targetCategory === "public" && !!isPublicConflict;
const canInstall = !!validation && !validationError && validation.can_install_as_is;
const canOverwrite = !!validation && !validationError && validation.can_overwrite;
const ownCustomCanOverwrite = !!isCustomConflict && !!validation?.owned_by_me && canOverwrite;
const otherCustomConflict = !!isCustomConflict && !!validation?.owned_by_other;
const customConflictMessage = !validation
? ""
: otherCustomConflict
? dialog.conflictOwnedByOther
.replace("{name}", validation.skill_name)
.replace(
"{owner}",
validation.owner_username ?? validation.owner_user_id ?? "—",
)
: validation.owned_by_me
? (ownCustomCanOverwrite
? dialog.conflictOwnedByMe
: dialog.conflictOwnedByMeWrongTarget
).replace("{name}", validation.skill_name)
: dialog.conflictCustom.replace("{name}", validation.skill_name);
const isPublicConflict = validation?.conflict && validation.conflict_kind === "public";
const isCustomConflict = validation?.conflict && validation.conflict_kind === "custom";
// Admin targeting public directory can overwrite an existing public skill.
const adminCanOverwritePublic = isAdmin && targetCategory === "public" && !!isPublicConflict;
const canInstall = !!validation && !validationError && validation.can_install_as_is;
const canOverwrite = !!validation && !validationError && validation.can_overwrite;
const ownCustomCanOverwrite = !!isCustomConflict && !!validation?.owned_by_me && canOverwrite;
const otherCustomConflict = !!isCustomConflict && !!validation?.owned_by_other;
// On-disk leftover with no ownership record: replaceable by whoever uploads it.
const unclaimedCustomConflict =
!!isCustomConflict && !validation?.owned_by_me && !validation?.owned_by_other && canOverwrite;
const customConflictMessage = !validation
? ""
: otherCustomConflict
? dialog.conflictOwnedByOther
.replace("{name}", validation.skill_name)
.replace(
"{owner}",
validation.owner_username ?? validation.owner_user_id ?? "—",
)
: validation.owned_by_me
? (ownCustomCanOverwrite
? dialog.conflictOwnedByMe
: dialog.conflictOwnedByMeWrongTarget
).replace("{name}", validation.skill_name)
: unclaimedCustomConflict
? dialog.conflictUnclaimed.replace("{name}", validation.skill_name)
: dialog.conflictCustom.replace("{name}", validation.skill_name);
return (
<Dialog open={open} onOpenChange={(o) => !o && handleClose()}>
@@ -1505,12 +1702,12 @@ function InstallSkillDialog({
<input
type="radio"
name="targetCategory"
value={cat}
checked={targetCategory === cat}
onChange={() => {
setTargetCategory(cat);
if (file) void validateFile(file, cat);
}}
value={cat}
checked={targetCategory === cat}
onChange={() => {
setTargetCategory(cat);
if (file) void validateFile(file, cat);
}}
className="accent-primary"
/>
<span>{cat === "custom" ? dialog.categoryCustom : dialog.categoryPublic}</span>
@@ -1568,18 +1765,18 @@ function InstallSkillDialog({
</ul>
</Field>
)}
{isCustomConflict && (
<div
className={cn(
"mt-1 flex items-start gap-2 text-xs",
ownCustomCanOverwrite ? "text-amber-600" : "text-destructive",
)}
>
<AlertCircle className="mt-0.5 size-3.5 shrink-0" />
<span>
{desensitize(customConflictMessage)}
</span>
</div>
{isCustomConflict && (
<div
className={cn(
"mt-1 flex items-start gap-2 text-xs",
ownCustomCanOverwrite || unclaimedCustomConflict ? "text-amber-600" : "text-destructive",
)}
>
<AlertCircle className="mt-0.5 size-3.5 shrink-0" />
<span>
{desensitize(customConflictMessage)}
</span>
</div>
)}
{isPublicConflict && (
<div className={cn("mt-1 flex items-start gap-2 text-xs", adminCanOverwritePublic ? "text-amber-600" : "text-destructive")}>
@@ -0,0 +1,346 @@
"use client";
import {
Loader2Icon,
RotateCcwIcon,
SaveIcon,
UploadCloudIcon,
} from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
DEFAULT_UPLOAD_POLICY,
MEBIBYTE,
normalizeUploadExtension,
type UploadPolicy,
type UploadPolicyGroupKey,
useResetUploadPolicy,
useUpdateUploadPolicy,
useUploadPolicy,
} from "@/core/uploads";
type UploadPolicyDraft = {
maxFiles: string;
maxTotalMiB: string;
groups: Record<
UploadPolicyGroupKey,
{ maxMiB: string; extensions: string }
>;
thresholdMiB: string;
maxResultChars: string;
hardMaxResultChars: string;
};
const GROUP_META: Array<{
key: UploadPolicyGroupKey;
label: string;
description: string;
}> = [
{ key: "document", label: "文档", description: "Word、PPT、TXT、PDF 等" },
{ key: "image", label: "图片", description: "JPG、PNG 等" },
{ key: "spreadsheet", label: "表格", description: "Excel 等" },
];
function toDraft(policy: UploadPolicy): UploadPolicyDraft {
return {
maxFiles: String(policy.max_files_per_message),
maxTotalMiB: String(policy.max_total_bytes / MEBIBYTE),
groups: {
document: {
maxMiB: String(policy.groups.document.max_bytes / MEBIBYTE),
extensions: policy.groups.document.extensions.join(", "),
},
image: {
maxMiB: String(policy.groups.image.max_bytes / MEBIBYTE),
extensions: policy.groups.image.extensions.join(", "),
},
spreadsheet: {
maxMiB: String(policy.groups.spreadsheet.max_bytes / MEBIBYTE),
extensions: policy.groups.spreadsheet.extensions.join(", "),
},
},
thresholdMiB: String(policy.large_file.threshold_bytes / MEBIBYTE),
maxResultChars: String(policy.large_file.max_result_chars),
hardMaxResultChars: String(policy.large_file.hard_max_result_chars),
};
}
function positiveNumber(value: string, label: string): number {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
throw new Error(`${label}必须大于 0`);
}
return parsed;
}
function extensionsOf(value: string, label: string): string[] {
const extensions = Array.from(
new Set(
value
.split(/[,,\s]+/)
.map(normalizeUploadExtension)
.filter(Boolean),
),
);
if (extensions.length === 0) throw new Error(`${label}至少填写一个扩展名`);
return extensions;
}
function fromDraft(draft: UploadPolicyDraft): UploadPolicy {
const maxResultChars = Math.floor(
positiveNumber(draft.maxResultChars, "常规输出上限"),
);
const hardMaxResultChars = Math.floor(
positiveNumber(draft.hardMaxResultChars, "硬输出上限"),
);
if (maxResultChars > hardMaxResultChars) {
throw new Error("常规输出上限不能大于硬输出上限");
}
const groups = {} as UploadPolicy["groups"];
for (const { key, label } of GROUP_META) {
groups[key] = {
max_bytes: Math.floor(
positiveNumber(draft.groups[key].maxMiB, `${label}单文件上限`) *
MEBIBYTE,
),
extensions: extensionsOf(draft.groups[key].extensions, label),
};
}
return {
max_files_per_message: Math.floor(
positiveNumber(draft.maxFiles, "单条消息文件数"),
),
max_total_bytes: Math.floor(
positiveNumber(draft.maxTotalMiB, "附件总大小") * MEBIBYTE,
),
groups,
large_file: {
threshold_bytes: Math.floor(
positiveNumber(draft.thresholdMiB, "大文件阈值") * MEBIBYTE,
),
max_result_chars: maxResultChars,
hard_max_result_chars: hardMaxResultChars,
},
};
}
export function UploadSettingsPage() {
const { data, isLoading } = useUploadPolicy();
const update = useUpdateUploadPolicy();
const reset = useResetUploadPolicy();
const [draft, setDraft] = useState<UploadPolicyDraft>(() =>
toDraft(DEFAULT_UPLOAD_POLICY),
);
useEffect(() => {
if (data) setDraft(toDraft(data));
}, [data]);
const setGroup = (
key: UploadPolicyGroupKey,
patch: Partial<UploadPolicyDraft["groups"][UploadPolicyGroupKey]>,
) => {
setDraft((current) => ({
...current,
groups: {
...current.groups,
[key]: { ...current.groups[key], ...patch },
},
}));
};
const save = async () => {
try {
const saved = await update.mutateAsync(fromDraft(draft));
setDraft(toDraft(saved));
toast.success("上传设置已保存");
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存上传设置失败");
}
};
const restoreDefaults = async () => {
if (!window.confirm("确定恢复系统默认上传策略吗?当前自定义设置会被覆盖。")) {
return;
}
try {
const saved = await reset.mutateAsync();
setDraft(toDraft(saved));
toast.success("已恢复默认上传设置");
} catch (error) {
toast.error(error instanceof Error ? error.message : "恢复默认上传设置失败");
}
};
return (
<section className="space-y-6">
<div className="space-y-1">
<h3 className="flex items-center gap-2 text-lg font-semibold">
<UploadCloudIcon className="size-5" />
上传设置
</h3>
<p className="text-muted-foreground text-sm">
控制聊天附件的数量、格式和大小,并限制大文件读取结果,防止文件内容一次性占满模型上下文。
</p>
</div>
<div className="grid gap-4 rounded-lg border p-4 sm:grid-cols-2">
<NumberField
id="upload-max-files"
label="单条消息最多文件数"
value={draft.maxFiles}
onChange={(maxFiles) => setDraft((value) => ({ ...value, maxFiles }))}
step="1"
/>
<NumberField
id="upload-max-total"
label="单条消息附件总上限(MiB)"
value={draft.maxTotalMiB}
onChange={(maxTotalMiB) =>
setDraft((value) => ({ ...value, maxTotalMiB }))
}
/>
</div>
<div className="space-y-3">
<h4 className="text-sm font-semibold">文件分组</h4>
{GROUP_META.map(({ key, label, description }) => (
<div
key={key}
className="grid gap-4 rounded-lg border p-4 sm:grid-cols-[180px_minmax(0,1fr)]"
>
<NumberField
id={`upload-${key}-size`}
label={`${label}单文件上限(MiB)`}
value={draft.groups[key].maxMiB}
onChange={(maxMiB) => setGroup(key, { maxMiB })}
/>
<div className="space-y-2">
<label
className="text-sm font-medium"
htmlFor={`upload-${key}-extensions`}
>
{label}扩展名
</label>
<Input
id={`upload-${key}-extensions`}
value={draft.groups[key].extensions}
onChange={(event) =>
setGroup(key, { extensions: event.currentTarget.value })
}
placeholder=".doc, .docx"
/>
<p className="text-muted-foreground text-xs">
{description};用逗号或空格分隔,保存时自动补全小数点。
</p>
</div>
</div>
))}
</div>
<div className="space-y-3 rounded-lg border p-4">
<div>
<h4 className="text-sm font-semibold">大文件读取保护</h4>
<p className="text-muted-foreground mt-1 text-xs">
达到阈值后使用头部、中部、尾部采样;输出始终受字符预算约束,并在界面中标明截断或采样。
</p>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<NumberField
id="upload-large-threshold"
label="大文件阈值(MiB)"
value={draft.thresholdMiB}
onChange={(thresholdMiB) =>
setDraft((value) => ({ ...value, thresholdMiB }))
}
/>
<NumberField
id="upload-result-chars"
label="常规输出上限(字符)"
value={draft.maxResultChars}
onChange={(maxResultChars) =>
setDraft((value) => ({ ...value, maxResultChars }))
}
step="100"
/>
<NumberField
id="upload-hard-result-chars"
label="硬输出上限(字符)"
value={draft.hardMaxResultChars}
onChange={(hardMaxResultChars) =>
setDraft((value) => ({ ...value, hardMaxResultChars }))
}
step="100"
/>
</div>
</div>
<div className="flex items-center justify-end gap-2">
{isLoading && (
<span className="text-muted-foreground flex items-center gap-1 text-xs">
<Loader2Icon className="size-3.5 animate-spin" /> 正在读取配置
</span>
)}
<Button
type="button"
variant="outline"
onClick={() => void restoreDefaults()}
disabled={update.isPending || reset.isPending}
>
{reset.isPending ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<RotateCcwIcon className="size-4" />
)}
恢复默认
</Button>
<Button
onClick={() => void save()}
disabled={update.isPending || reset.isPending}
>
{update.isPending ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<SaveIcon className="size-4" />
)}
保存
</Button>
</div>
</section>
);
}
function NumberField({
id,
label,
value,
onChange,
step = "0.1",
}: {
id: string;
label: string;
value: string;
onChange: (value: string) => void;
step?: string;
}) {
return (
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor={id}>
{label}
</label>
<Input
id={id}
type="number"
min="0.01"
step={step}
value={value}
onChange={(event) => onChange(event.currentTarget.value)}
/>
</div>
);
}
@@ -2,12 +2,12 @@ import { BookOpenTextIcon, ChevronRightIcon, ExternalLinkIcon, FileTextIcon } fr
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
import { resolveWeKnoraSourceTarget } from "@/core/llmwiki/source-links";
import { useLocalSettings } from "@/core/settings";
@@ -79,12 +79,16 @@ export function SourceCard({
? source.raw.summary
: "";
const snippet = plainTextReferencePreview(summary || content).slice(0, 280);
const score =
typeof source.score === "number"
? source.score <= 1
? `${Math.round(source.score * 100)}%`
: String(source.score)
: null;
const scoreSemantics = typeof source.raw?.rank_score_semantics === "string"
? source.raw.rank_score_semantics
: "";
const scoreMeta = typeof source.score !== "number"
? null
: scoreSemantics === "rrf"
? `融合排序分 ${source.score.toFixed(4)}`
: scoreSemantics === "provider_relevance"
? `上游通道分 ${source.score.toFixed(4)}`
: `相关度 ${source.score <= 1 ? `${Math.round(source.score * 100)}%` : String(source.score)}`;
const meta = [
source.source,
isWikiSource ? "检索对象:Wiki 文档" : null,
@@ -92,7 +96,7 @@ export function SourceCard({
source.author ? `作者 ${source.author}` : null,
!isWikiSource && source.type && source.type !== "result" ? source.type : null,
source.time,
score ? `相关度 ${score}` : null,
scoreMeta,
].filter(Boolean);
const isCitation = source.mode === "citation";
const showReferenceNumber = isCitation;
@@ -183,9 +187,12 @@ export function SourceCard({
onOpenChange={setDetailOpen}
/>
)}
{!previewTarget && <Dialog open={relatedOpen} onOpenChange={setRelatedOpen}>
<DialogContent className="max-h-[90vh] w-[calc(100vw-2rem)] max-w-3xl overflow-hidden">
<DialogHeader>
{!previewTarget && <Sheet open={relatedOpen} onOpenChange={setRelatedOpen}>
<SheetContent
side="right"
className="w-[min(92vw,760px)] gap-0 overflow-hidden p-0 sm:max-w-none"
>
<SheetHeader className="shrink-0 border-b px-5 py-4 pr-12 text-left">
<div className="flex items-start justify-between gap-3 pr-8">
<div className="min-w-0 flex-1">
<div className="flex items-start gap-2">
@@ -194,16 +201,19 @@ export function SourceCard({
{source.index}
</span>
)}
<DialogTitle className="text-base leading-relaxed">
<SheetTitle className="text-base leading-relaxed">
{source.title}
</DialogTitle>
</SheetTitle>
</div>
{meta.length > 0 && (
<DialogDescription className="mt-2 flex flex-wrap gap-x-2 gap-y-1 text-xs">
<SheetDescription className="mt-2 flex flex-wrap gap-x-2 gap-y-1 text-xs">
{meta.map((item, index) => (
<span key={`${item}-${index}`}>{item}</span>
))}
</DialogDescription>
</SheetDescription>
)}
{meta.length === 0 && (
<SheetDescription className="sr-only">参考文献详情</SheetDescription>
)}
</div>
{source.url && !previewTarget && (
@@ -219,33 +229,39 @@ export function SourceCard({
</Button>
)}
</div>
</DialogHeader>
{content ? (
<div className="max-h-[62vh] overflow-y-auto rounded-xl border bg-slate-50/70 p-4 dark:bg-slate-950/40 sm:p-6">
{isWikiSource && (
<div className="mb-4 flex items-center gap-2 border-b pb-3 text-xs font-medium text-emerald-700 dark:text-emerald-300">
<BookOpenTextIcon className="size-4" />
本次检索命中的 Wiki 文档内容
</div>
)}
<article className="mx-auto max-w-3xl rounded-lg bg-background px-4 py-5 shadow-sm ring-1 ring-border/70 sm:px-7">
<MarkdownContent
className="max-w-none"
content={content}
isLoading={false}
remarkPlugins={reasoningPlugins.remarkPlugins}
rehypePlugins={reasoningPlugins.rehypePlugins}
/>
</article>
</div>
) : null}
{source.url && !previewTarget && (
<div className="text-muted-foreground border-t pt-3 text-xs break-all">
{source.url}
</div>
)}
</DialogContent>
</Dialog>}
</SheetHeader>
<div className="min-h-0 flex-1 overflow-y-auto bg-slate-50/70 p-4 dark:bg-slate-950/40 sm:p-6">
{content ? (
<>
{isWikiSource && (
<div className="mb-4 flex items-center gap-2 border-b pb-3 text-xs font-medium text-emerald-700 dark:text-emerald-300">
<BookOpenTextIcon className="size-4" />
本次检索命中的 Wiki 文档内容
</div>
)}
<article className="mx-auto max-w-3xl rounded-lg bg-background px-4 py-5 shadow-sm ring-1 ring-border/70 sm:px-7">
<MarkdownContent
className="max-w-none"
content={content}
isLoading={false}
remarkPlugins={reasoningPlugins.remarkPlugins}
rehypePlugins={reasoningPlugins.rehypePlugins}
/>
</article>
</>
) : (
<div className="flex min-h-64 items-center justify-center rounded-xl border border-dashed bg-background text-sm text-muted-foreground">
暂无可展示的引用内容
</div>
)}
{source.url && !previewTarget && (
<div className="text-muted-foreground mt-4 border-t pt-3 text-xs break-all">
{source.url}
</div>
)}
</div>
</SheetContent>
</Sheet>}
</>
);
}
@@ -0,0 +1,214 @@
import { useEffect, useMemo, useRef, useState } from "react";
import * as maplibregl from "maplibre-gl";
import type { StyleSpecification } from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
import { AlertTriangle, MapPinned, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { getMapTileAttribution, getMapTileUrl } from "@/core/utils/map-tile-config";
import type { MapSpec } from "./visualization-spec";
import { VisualizationCard } from "./visualization-card";
function createStyle(): StyleSpecification {
return {
version: 8,
sources: {
"google-raster": {
type: "raster",
tiles: [getMapTileUrl()],
tileSize: 256,
attribution: getMapTileAttribution(),
},
},
layers: [{ id: "google-raster", type: "raster", source: "google-raster" }],
};
}
function allCoordinates(spec: MapSpec): Array<[number, number]> {
return [
...spec.markers.map((marker) => marker.coordinate),
...spec.lines.flatMap((line) => line.coordinates),
...spec.polygons.flatMap((polygon) => polygon.coordinates),
];
}
function defaultCenter(spec: MapSpec): [number, number] {
if (spec.center) return spec.center;
const coordinates = allCoordinates(spec);
if (coordinates.length === 0) return [104.1954, 35.8617];
const total = coordinates.reduce(([lng, lat], coordinate) => [lng + coordinate[0], lat + coordinate[1]], [0, 0]);
return [total[0] / coordinates.length, total[1] / coordinates.length];
}
export default function MapRenderer({ spec }: { spec: MapSpec }) {
const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<maplibregl.Map | null>(null);
const [tileFailed, setTileFailed] = useState(false);
const [reloadToken, setReloadToken] = useState(0);
const coordinates = useMemo(() => allCoordinates(spec), [spec]);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
setTileFailed(false);
const map = new maplibregl.Map({
container,
style: createStyle(),
center: defaultCenter(spec),
zoom: spec.zoom ?? (coordinates.length > 1 ? 4 : 8),
minZoom: 1,
maxZoom: 20,
attributionControl: { compact: true },
});
mapRef.current = map;
map.addControl(new maplibregl.NavigationControl({ showCompass: false }), "top-right");
const markers: maplibregl.Marker[] = [];
for (const marker of spec.markers) {
const element = document.createElement("button");
element.type = "button";
element.className = "size-5 rounded-full border-2 border-white bg-primary shadow-md focus:outline-none focus:ring-2 focus:ring-primary/50";
element.title = marker.name;
element.setAttribute("aria-label", marker.name);
const popup = new maplibregl.Popup({ offset: 14 }).setHTML(
`<div style="max-width:240px"><strong>${escapeHtml(marker.name)}</strong>${marker.description ? `<p style="margin:6px 0 0">${escapeHtml(marker.description)}</p>` : ""}</div>`,
);
markers.push(new maplibregl.Marker({ element }).setLngLat(marker.coordinate).setPopup(popup).addTo(map));
}
let baseTileLoaded = false;
const onSourceData = (event: maplibregl.MapSourceDataEvent) => {
if (event.sourceId !== "google-raster" || event.sourceDataType !== "content") return;
baseTileLoaded = true;
setTileFailed(false);
};
const onError = (event: maplibregl.ErrorEvent) => {
const message = event.error?.message ?? "";
if (!baseTileLoaded && /tile|fetch|network|request|http/i.test(message)) setTileFailed(true);
};
map.on("sourcedata", onSourceData);
map.on("error", onError);
map.on("load", () => {
if (spec.lines.length > 0) {
map.addSource("visual-lines", {
type: "geojson",
data: {
type: "FeatureCollection",
features: spec.lines.map((line) => ({
type: "Feature",
properties: { name: line.name ?? "" },
geometry: { type: "LineString", coordinates: line.coordinates },
})),
},
});
map.addLayer({
id: "visual-lines",
type: "line",
source: "visual-lines",
paint: { "line-color": "#2563eb", "line-width": 3, "line-opacity": 0.85 },
});
}
if (spec.polygons.length > 0) {
map.addSource("visual-polygons", {
type: "geojson",
data: {
type: "FeatureCollection",
features: spec.polygons.map((polygon) => ({
type: "Feature",
properties: { name: polygon.name ?? "" },
geometry: { type: "Polygon", coordinates: [[...polygon.coordinates, polygon.coordinates[0]]] },
})),
},
});
map.addLayer({
id: "visual-polygons-fill",
type: "fill",
source: "visual-polygons",
paint: { "fill-color": "#2563eb", "fill-opacity": 0.18 },
});
map.addLayer({
id: "visual-polygons-border",
type: "line",
source: "visual-polygons",
paint: { "line-color": "#2563eb", "line-width": 2 },
});
}
if (!spec.center && coordinates.length > 1) {
const bounds = coordinates.reduce(
(current, coordinate) => current.extend(coordinate),
new maplibregl.LngLatBounds(coordinates[0], coordinates[0]),
);
map.fitBounds(bounds, { padding: 52, maxZoom: 12, duration: 0 });
}
});
return () => {
map.off("sourcedata", onSourceData);
map.off("error", onError);
markers.forEach((marker) => marker.remove());
mapRef.current = null;
map.remove();
};
}, [coordinates, reloadToken, spec]);
return (
<VisualizationCard>
<div className="flex min-h-12 items-center gap-2 border-b px-3 py-2">
<MapPinned aria-hidden="true" className="size-4 shrink-0 text-primary" />
<span className="truncate text-sm font-medium">{spec.title}</span>
<span className="ml-auto text-xs text-muted-foreground">{spec.markers.length} 个地点</span>
</div>
<div className="relative">
<div ref={containerRef} className="h-[420px] w-full" role="img" aria-label={`${spec.title},包含 ${spec.markers.length} 个地点`} />
{tileFailed ? (
<div className="absolute inset-x-3 top-3 z-10 flex items-center gap-2 rounded-lg border border-amber-300 bg-amber-50/95 p-3 text-xs text-amber-900 shadow-sm" role="status">
<AlertTriangle aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
<span className="flex-1">地图底图当前不可访问;请检查网络。地点和线路数据不会丢失。</span>
<Button
aria-label="重新加载地图"
className="shrink-0 text-amber-950 hover:bg-amber-100"
onClick={() => {
setTileFailed(false);
setReloadToken((value) => value + 1);
}}
size="sm"
variant="ghost"
>
<RefreshCw aria-hidden="true" />
重试
</Button>
</div>
) : null}
</div>
{spec.markers.length > 0 ? (
<details className="border-t px-3 py-2 text-xs text-muted-foreground">
<summary className="cursor-pointer select-none">查看地点列表</summary>
<ul className="mt-2 space-y-1">
{spec.markers.map((marker, index) => (
<li key={`${marker.name}-${index}`}>
<span className="font-medium text-foreground">{marker.name}</span>
<span>({marker.coordinate[0]},{marker.coordinate[1]})</span>
{marker.description ? <span>:{marker.description}</span> : null}
</li>
))}
</ul>
</details>
) : null}
</VisualizationCard>
);
}
function escapeHtml(value: string): string {
return value.replace(/[&<>'"]/g, (character) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
"'": "&#39;",
'"': "&quot;",
})[character] ?? character);
}
@@ -0,0 +1,682 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import {
Background,
BackgroundVariant,
Handle,
Panel,
Position,
ReactFlow,
ReactFlowProvider,
useReactFlow,
type Edge,
type Node,
type NodeProps,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import {
Download,
FileJson,
ImageDown,
LocateFixed,
Maximize2,
Minus,
Plus,
X,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { renderSvgElementToPngBlob, triggerBlobDownload } from "@/core/utils/mermaid";
import { cn } from "@/lib/utils";
import { VisualizationCard } from "./visualization-card";
import type { MindmapNodeKind, MindmapSpec } from "./visualization-spec";
const NODE_WIDTH = 184;
const NODE_HEIGHT = 68;
const LAYER_GAP = 244;
const ROW_GAP = 88;
const PREVIEW_DEPTH = 2;
const KIND_META: Record<MindmapNodeKind, { label: string; className: string }> = {
topic: { label: "主题", className: "border-primary/50 bg-primary/10" },
dimension: { label: "维度", className: "border-sky-500/45 bg-sky-500/10" },
finding: { label: "发现", className: "border-violet-500/45 bg-violet-500/10" },
evidence: { label: "依据", className: "border-emerald-500/45 bg-emerald-500/10" },
risk: { label: "风险", className: "border-amber-500/50 bg-amber-500/10" },
action: { label: "建议", className: "border-rose-500/45 bg-rose-500/10" },
};
type MindmapNode = MindmapSpec["nodes"][number];
type LayoutNode = MindmapNode & {
depth: number;
x: number;
y: number;
hiddenCount: number;
hasChildren: boolean;
collapsed: boolean;
};
type MindmapLayout = {
nodes: LayoutNode[];
edges: Array<{ id: string; from: string; to: string }>;
width: number;
height: number;
};
function childrenByParent(spec: MindmapSpec) {
const children = new Map<string, MindmapNode[]>();
for (const node of spec.nodes) {
if (node.parentId === null) continue;
const list = children.get(node.parentId) ?? [];
list.push(node);
children.set(node.parentId, list);
}
return children;
}
function getRoot(spec: MindmapSpec): MindmapNode {
return spec.nodes.find((node) => node.parentId === null)!;
}
function descendantsCount(id: string, children: Map<string, MindmapNode[]>): number {
let count = 0;
for (const child of children.get(id) ?? []) {
count += 1 + descendantsCount(child.id, children);
}
return count;
}
/**
* A rooted analysis tree uses a left-to-right layout in messages. It reads more
* naturally than a radial graph inside a narrow chat column and stays stable
* when the same persisted message is replayed.
*/
function layoutMindmap(
spec: MindmapSpec,
maxDepth?: number,
collapsedIds: ReadonlySet<string> = new Set(),
): MindmapLayout {
const children = childrenByParent(spec);
const root = getRoot(spec);
const visible = new Map<string, { node: MindmapNode; depth: number }>();
const collect = (node: MindmapNode, depth: number) => {
visible.set(node.id, { node, depth });
if ((maxDepth !== undefined && depth >= maxDepth) || collapsedIds.has(node.id)) return;
for (const child of children.get(node.id) ?? []) collect(child, depth + 1);
};
collect(root, 0);
const unitHeight = (id: string): number => {
const childIds = (children.get(id) ?? []).filter((child) => visible.has(child.id));
if (childIds.length === 0) return 1;
return childIds.reduce((sum, child) => sum + unitHeight(child.id), 0);
};
const positions = new Map<string, { x: number; y: number }>();
const assign = (id: string, depth: number, top: number): number => {
const ownUnits = unitHeight(id);
positions.set(id, {
x: depth * LAYER_GAP,
y: top + ((ownUnits - 1) * ROW_GAP) / 2,
});
let nextTop = top;
for (const child of children.get(id) ?? []) {
if (!visible.has(child.id)) continue;
nextTop = assign(child.id, depth + 1, nextTop) + ROW_GAP;
}
return top + (ownUnits - 1) * ROW_GAP;
};
assign(root.id, 0, 0);
const nodes = [...visible.values()].map(({ node, depth }) => ({
...node,
depth,
x: positions.get(node.id)?.x ?? 0,
y: positions.get(node.id)?.y ?? 0,
hiddenCount:
(maxDepth !== undefined && depth === maxDepth) || collapsedIds.has(node.id)
? descendantsCount(node.id, children)
: 0,
hasChildren: (children.get(node.id) ?? []).length > 0,
collapsed: collapsedIds.has(node.id),
}));
const nodeIds = new Set(nodes.map((node) => node.id));
const edges = nodes
.filter((node) => node.parentId && nodeIds.has(node.parentId))
.map((node) => ({ id: `${node.parentId}->${node.id}`, from: node.parentId!, to: node.id }));
const maxX = Math.max(...nodes.map((node) => node.x), 0);
const maxY = Math.max(...nodes.map((node) => node.y), 0);
return {
nodes,
edges,
width: maxX + NODE_WIDTH + 64,
height: maxY + NODE_HEIGHT + 64,
};
}
type FlowNodeData = {
node: LayoutNode;
selected: boolean;
onToggle?: (id: string) => void;
};
function MindmapFlowNode({ data }: NodeProps) {
const { node, selected, onToggle } = data as FlowNodeData;
const meta = KIND_META[node.kind];
return (
<div
className={cn(
"relative w-[184px] rounded-xl border bg-background px-3 py-2 shadow-sm transition-shadow",
"hover:shadow-md focus-within:ring-2 focus-within:ring-primary/50",
meta.className,
selected && "ring-2 ring-primary ring-offset-2 ring-offset-background",
)}
title={node.label}
>
{node.parentId ? <Handle type="target" position={Position.Left} className="!size-1.5 !border-0 !bg-border" /> : null}
<div className="mb-1 flex items-center gap-1.5 text-[11px] leading-none text-muted-foreground">
<span className="font-medium">{meta.label}</span>
{node.hiddenCount > 0 ? (
<span className="rounded-full bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
+{node.hiddenCount}
</span>
) : null}
</div>
<div className="line-clamp-2 text-sm font-medium leading-5 text-foreground">{node.label}</div>
{node.hasChildren ? (
<>
<Handle type="source" position={Position.Right} className="!size-6 !border-0 !bg-transparent" />
<button
type="button"
className="absolute -right-3 top-1/2 z-20 flex size-6 -translate-y-1/2 items-center justify-center rounded-full border border-slate-400 bg-background text-slate-600 shadow-sm transition-colors hover:border-primary hover:bg-primary hover:text-primary-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
aria-label={`${node.collapsed ? "展开" : "收起"}${node.label}`}
aria-expanded={!node.collapsed}
title={node.collapsed ? "展开子节点" : "收起子节点"}
onClick={(event) => {
event.stopPropagation();
onToggle?.(node.id);
}}
>
{node.collapsed ? <Plus aria-hidden="true" className="size-3.5" /> : <Minus aria-hidden="true" className="size-3.5" />}
</button>
</>
) : null}
</div>
);
}
const NODE_TYPES = { mindmap: MindmapFlowNode };
function MindmapCanvasControls({
compact,
onClose,
}: {
compact: boolean;
onClose?: () => void;
}) {
const rf = useReactFlow();
const buttonClassName = compact ? "size-7" : "size-8";
const padding = compact ? 0.16 : 0.12;
return (
<Panel position="top-right" className="flex items-center gap-1 rounded-lg border bg-background/95 p-1 shadow-sm backdrop-blur">
<Button
type="button"
variant="ghost"
size="icon"
className={buttonClassName}
title="放大"
aria-label="放大思维导图"
onClick={() => void rf.zoomIn({ duration: 150 })}
>
<Plus aria-hidden="true" className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className={buttonClassName}
title="缩小"
aria-label="缩小思维导图"
onClick={() => void rf.zoomOut({ duration: 150 })}
>
<Minus aria-hidden="true" className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className={buttonClassName}
title="重置位置与缩放"
aria-label="重置思维导图位置与缩放"
onClick={() => void rf.fitView({ duration: 220, padding })}
>
<LocateFixed aria-hidden="true" className="size-4" />
</Button>
{onClose ? (
<>
<span aria-hidden="true" className="mx-0.5 h-4 w-px bg-border" />
<Button
type="button"
variant="ghost"
size="icon"
className={buttonClassName}
title="关闭"
aria-label="关闭思维导图"
onClick={onClose}
>
<X aria-hidden="true" className="size-4" />
</Button>
</>
) : null}
</Panel>
);
}
function overviewNodeColor(kind: MindmapNodeKind): string {
return svgColors(kind).fill;
}
function MindmapOverview({ layout }: { layout: MindmapLayout }) {
const nodesById = new Map(layout.nodes.map((node) => [node.id, node]));
return (
<Panel position="bottom-right" className="overflow-hidden rounded-lg border bg-background/95 p-1 shadow-md backdrop-blur">
<div className="mb-1 px-1.5 pt-0.5 text-[10px] font-medium text-muted-foreground">全图预览</div>
<svg
className="block h-28 w-44 rounded bg-muted/30"
viewBox={`0 0 ${layout.width} ${layout.height}`}
preserveAspectRatio="xMidYMid meet"
role="img"
aria-label="思维导图全图预览"
>
{layout.edges.map((edge) => {
const from = nodesById.get(edge.from);
const to = nodesById.get(edge.to);
if (!from || !to) return null;
return (
<path
key={edge.id}
d={`M ${from.x + NODE_WIDTH} ${from.y + NODE_HEIGHT / 2} L ${to.x} ${to.y + NODE_HEIGHT / 2}`}
fill="none"
stroke="#64748b"
strokeWidth="4"
strokeOpacity="0.8"
/>
);
})}
{layout.nodes.map((node) => (
<rect
key={node.id}
x={node.x}
y={node.y}
width={NODE_WIDTH}
height={NODE_HEIGHT}
rx="10"
fill={overviewNodeColor(node.kind)}
stroke="#94a3b8"
strokeWidth="2"
/>
))}
</svg>
</Panel>
);
}
function MindmapCanvas({
spec,
preview,
selectedId,
onSelect,
onClose,
}: {
spec: MindmapSpec;
preview: boolean;
selectedId?: string | null;
onSelect?: (id: string) => void;
onClose?: () => void;
}) {
const rf = useReactFlow();
const [collapsedIds, setCollapsedIds] = useState<Set<string>>(() => new Set());
const toggleNode = useCallback((id: string) => {
setCollapsedIds((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const layout = useMemo(
() => layoutMindmap(spec, preview ? PREVIEW_DEPTH : undefined, collapsedIds),
[collapsedIds, preview, spec],
);
const nodes = useMemo<Node[]>(
() =>
layout.nodes.map((node) => ({
id: node.id,
type: "mindmap",
position: { x: node.x, y: node.y },
width: NODE_WIDTH,
height: NODE_HEIGHT,
data: { node, selected: node.id === selectedId, onToggle: toggleNode } satisfies FlowNodeData,
})),
[layout.nodes, selectedId, toggleNode],
);
const edges = useMemo<Edge[]>(
() =>
layout.edges.map((edge) => ({
id: edge.id,
source: edge.from,
target: edge.to,
type: "smoothstep",
style: { stroke: "#64748b", strokeWidth: 2, strokeOpacity: 0.8 },
})),
[layout.edges],
);
const handleNodeClick = useCallback((id: string) => {
const node = layout.nodes.find((item) => item.id === id);
if (node?.hasChildren) {
toggleNode(id);
return;
}
onSelect?.(id);
}, [layout.nodes, onSelect, toggleNode]);
useEffect(() => {
const timer = window.setTimeout(() => {
void rf.fitView({ padding: preview ? 0.16 : 0.12, duration: 180 });
}, 0);
return () => window.clearTimeout(timer);
}, [collapsedIds, preview, rf, spec]);
return (
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={NODE_TYPES}
fitView
fitViewOptions={{ padding: preview ? 0.16 : 0.12 }}
minZoom={0.12}
maxZoom={2}
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable={Boolean(onSelect)}
panOnDrag
panOnScroll={false}
zoomOnScroll
zoomOnDoubleClick
zoomOnPinch
preventScrolling
onNodeClick={(_, node) => handleNodeClick(node.id)}
aria-label={`${spec.title}思维导图`}
proOptions={{ hideAttribution: true }}
>
<Background variant={BackgroundVariant.Dots} gap={18} size={1} color="var(--border)" />
<MindmapCanvasControls compact={preview} onClose={onClose} />
{!preview ? <MindmapOverview layout={layout} /> : null}
</ReactFlow>
);
}
function svgEscape(value: string): string {
return value.replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&apos;" })[char]!);
}
function splitSvgLabel(label: string): string[] {
const chunks = label.match(/.{1,16}/gu) ?? [label];
return chunks.slice(0, 2);
}
function buildMindmapSvg(spec: MindmapSpec): string {
const layout = layoutMindmap(spec);
const offset = 32;
const lines = layout.edges
.map((edge) => {
const from = layout.nodes.find((node) => node.id === edge.from)!;
const to = layout.nodes.find((node) => node.id === edge.to)!;
const x1 = from.x + NODE_WIDTH + offset;
const y1 = from.y + NODE_HEIGHT / 2 + offset;
const x2 = to.x + offset;
const y2 = to.y + NODE_HEIGHT / 2 + offset;
const control = Math.max(42, (x2 - x1) / 2);
return `<path d="M ${x1} ${y1} C ${x1 + control} ${y1}, ${x2 - control} ${y2}, ${x2} ${y2}" fill="none" stroke="#94a3b8" stroke-width="1.5"/>`;
})
.join("");
const nodes = layout.nodes
.map((node) => {
const meta = KIND_META[node.kind];
const { fill, stroke } = svgColors(node.kind);
const lines = splitSvgLabel(node.label)
.map((line, index) => `<tspan x="${node.x + offset + 14}" dy="${index === 0 ? 0 : 18}">${svgEscape(line)}</tspan>`)
.join("");
return [
`<g>`,
`<rect x="${node.x + offset}" y="${node.y + offset}" width="${NODE_WIDTH}" height="${NODE_HEIGHT}" rx="12" fill="${fill}" stroke="${stroke}" stroke-width="1.5"/>`,
`<text x="${node.x + offset + 14}" y="${node.y + offset + 18}" font-family="Microsoft YaHei, Arial, sans-serif" font-size="10" fill="#64748b">${meta.label}</text>`,
`<text x="${node.x + offset + 14}" y="${node.y + offset + 42}" font-family="Microsoft YaHei, Arial, sans-serif" font-size="13" font-weight="600" fill="#0f172a">${lines}</text>`,
`</g>`,
].join("");
})
.join("");
return `<svg xmlns="http://www.w3.org/2000/svg" width="${layout.width + offset}" height="${layout.height + offset}" viewBox="0 0 ${layout.width + offset} ${layout.height + offset}" role="img" aria-label="${svgEscape(spec.title)}"><rect width="100%" height="100%" fill="#ffffff"/><text x="32" y="26" font-family="Microsoft YaHei, Arial, sans-serif" font-size="16" font-weight="700" fill="#0f172a">${svgEscape(spec.title)}</text>${lines}${nodes}</svg>`;
}
function svgColors(kind: MindmapNodeKind): { fill: string; stroke: string } {
switch (kind) {
case "topic": return { fill: "#eff6ff", stroke: "#60a5fa" };
case "dimension": return { fill: "#f0f9ff", stroke: "#38bdf8" };
case "evidence": return { fill: "#ecfdf5", stroke: "#34d399" };
case "risk": return { fill: "#fffbeb", stroke: "#fbbf24" };
case "action": return { fill: "#fff1f2", stroke: "#fb7185" };
default: return { fill: "#f5f3ff", stroke: "#a78bfa" };
}
}
function safeDownloadName(title: string, extension: string): string {
const clean = title.replace(/[\\/:*?"<>|]/g, "-").trim().slice(0, 80) || "分析思维导图";
return `${clean}.${extension}`;
}
function MindmapDownloads({ spec }: { spec: MindmapSpec }) {
const [downloading, setDownloading] = useState<"png" | "svg" | "json" | null>(null);
const [open, setOpen] = useState(false);
const download = useCallback(async (format: "png" | "svg" | "json") => {
setDownloading(format);
try {
if (format === "json") {
triggerBlobDownload(
new Blob([JSON.stringify(spec, null, 2)], { type: "application/json;charset=utf-8" }),
safeDownloadName(spec.title, "mindmap.json"),
);
setOpen(false);
return;
}
const svgMarkup = buildMindmapSvg(spec);
if (format === "svg") {
triggerBlobDownload(
new Blob([svgMarkup], { type: "image/svg+xml;charset=utf-8" }),
safeDownloadName(spec.title, "svg"),
);
setOpen(false);
return;
}
const host = document.createElement("div");
host.style.cssText = "position:fixed;left:-10000px;top:-10000px;pointer-events:none;opacity:0";
host.innerHTML = svgMarkup;
document.body.appendChild(host);
try {
const svg = host.querySelector("svg");
if (!(svg instanceof SVGSVGElement)) throw new Error("无法生成思维导图图片");
const blob = await renderSvgElementToPngBlob(svg);
triggerBlobDownload(blob, safeDownloadName(spec.title, "png"));
setOpen(false);
} finally {
host.remove();
}
} catch (error) {
console.error("Failed to download mindmap", error);
toast.error(error instanceof Error ? error.message : "下载思维导图失败");
} finally {
setDownloading(null);
}
}, [spec]);
return (
<>
<Button
type="button"
variant="outline"
size="icon"
className="size-9"
title="下载思维导图"
aria-label="下载思维导图"
onClick={() => setOpen(true)}
>
<Download aria-hidden="true" className="size-4" />
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-sm p-5">
<DialogHeader className="text-left">
<DialogTitle>下载思维导图</DialogTitle>
<DialogDescription>选择需要导出的文件格式。</DialogDescription>
</DialogHeader>
<div className="grid gap-2">
<Button
type="button"
variant="outline"
className="h-auto min-h-11 justify-start gap-3 px-3 py-2.5 text-left"
onClick={() => void download("png")}
disabled={downloading !== null}
>
<ImageDown aria-hidden="true" className="size-4 shrink-0" />
<span>
<span className="block text-sm font-medium">PNG 图片</span>
<span className="block text-xs font-normal text-muted-foreground">
{downloading === "png" ? "正在生成…" : "便于在文档或图片中使用"}
</span>
</span>
</Button>
<Button
type="button"
variant="outline"
className="h-auto min-h-11 justify-start gap-3 px-3 py-2.5 text-left"
onClick={() => void download("svg")}
disabled={downloading !== null}
>
<Download aria-hidden="true" className="size-4 shrink-0" />
<span>
<span className="block text-sm font-medium">SVG 矢量图</span>
<span className="block text-xs font-normal text-muted-foreground">适合无损缩放和二次编辑</span>
</span>
</Button>
<Button
type="button"
variant="outline"
className="h-auto min-h-11 justify-start gap-3 px-3 py-2.5 text-left"
onClick={() => void download("json")}
disabled={downloading !== null}
>
<FileJson aria-hidden="true" className="size-4 shrink-0" />
<span>
<span className="block text-sm font-medium">JSON 数据</span>
<span className="block text-xs font-normal text-muted-foreground">保留完整节点结构</span>
</span>
</Button>
</div>
</DialogContent>
</Dialog>
</>
);
}
function MindmapDialog({
open,
onOpenChange,
spec,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
spec: MindmapSpec;
}) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
showCloseButton={false}
className="flex h-[calc(100dvh-2rem)] !w-[calc(100vw-2rem)] !max-w-[calc(100vw-2rem)] flex-col gap-0 overflow-hidden rounded-xl p-0 sm:!max-w-[calc(100vw-2rem)]"
>
<DialogTitle className="sr-only">{spec.title}</DialogTitle>
<DialogDescription className="sr-only">
思维导图画布。支持鼠标滚轮缩放、拖动画布,并可使用右上角按钮调整视图。
</DialogDescription>
<div className="relative min-h-0 flex-1 bg-muted/20">
<div className="absolute inset-0">
<ReactFlowProvider>
<MindmapCanvas spec={spec} preview={false} onClose={() => onOpenChange(false)} />
</ReactFlowProvider>
</div>
</div>
</DialogContent>
</Dialog>
);
}
export default function MindmapRenderer({ spec }: { spec: MindmapSpec }) {
const [open, setOpen] = useState(false);
const root = getRoot(spec);
const ariaSummary = spec.nodes.map((node) => `${KIND_META[node.kind].label}:${node.label}`).join(";");
return (
<>
<VisualizationCard className="my-4" >
<section aria-label={`${spec.title}思维导图`}>
<div className="flex flex-wrap items-start justify-between gap-3 border-b px-4 py-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<h2 className="truncate text-sm font-semibold">{spec.title}</h2>
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[11px] font-medium text-primary">思维导图</span>
</div>
<p className="mt-1 line-clamp-2 text-xs leading-5 text-muted-foreground">
{spec.summary || `围绕“${root.label}”整理的 ${spec.nodes.length} 个分析节点。`}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<MindmapDownloads spec={spec} />
<Button type="button" size="sm" className="h-8 gap-1.5" onClick={() => setOpen(true)}>
<Maximize2 aria-hidden="true" className="size-3.5" />
展开查看
</Button>
</div>
</div>
<p className="sr-only">{ariaSummary}</p>
<div className="h-[20rem] bg-muted/15">
<ReactFlowProvider>
<MindmapCanvas
spec={spec}
preview
onSelect={() => {
setOpen(true);
}}
/>
</ReactFlowProvider>
</div>
<div className="flex items-center justify-between gap-3 border-t px-4 py-2 text-xs text-muted-foreground">
<span>预览显示前 {PREVIEW_DEPTH + 1} 层;完整导图可缩放查看。</span>
<span>{spec.nodes.length} 个节点</span>
</div>
</section>
</VisualizationCard>
<MindmapDialog open={open} onOpenChange={setOpen} spec={spec} />
</>
);
}
@@ -0,0 +1,129 @@
import { Component, lazy, Suspense, useMemo, type ErrorInfo, type ReactNode } from "react";
import { AlertTriangle, Loader2 } from "lucide-react";
import {
parseVisualizationSpec,
type MapSpec,
type MindmapSpec,
type VisualizationLanguage,
} from "./visualization-spec";
const MapRenderer = lazy(() => import("./map-renderer"));
const MindmapRenderer = lazy(() => import("./mindmap-renderer"));
type VisualizationBlockProps = {
language: VisualizationLanguage;
source: string;
complete: boolean;
isLoading: boolean;
};
function LoadingCard({ label = "正在生成可视化内容…" }: { label?: string }) {
return (
<div
className="my-4 flex min-h-32 w-full max-w-none items-center justify-center rounded-xl border bg-muted/20 text-muted-foreground"
role="status"
>
<Loader2 aria-hidden="true" className="mr-2 size-4 animate-spin" />
<span className="text-sm">{label}</span>
</div>
);
}
function ErrorCard({
message,
source,
title = "可视化内容暂时无法显示",
}: {
message: string;
source?: string;
title?: string;
}) {
return (
<div
className="my-4 w-full max-w-none rounded-xl border border-destructive/30 bg-destructive/5 p-4"
role="alert"
>
<div className="flex items-start gap-2 text-sm text-destructive">
<AlertTriangle aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
<div>
<div className="font-medium">{title}</div>
<div className="mt-1 text-xs opacity-80">{message}</div>
</div>
</div>
{source ? (
<details className="mt-3 text-xs text-muted-foreground">
<summary className="cursor-pointer select-none">查看原始数据</summary>
<pre className="mt-2 max-h-48 overflow-auto whitespace-pre-wrap rounded-lg bg-muted/50 p-3">
{source}
</pre>
</details>
) : null}
</div>
);
}
class VisualizationErrorBoundary extends Component<
{ children: ReactNode; source: string },
{ error: string | null }
> {
state = { error: null as string | null };
static getDerivedStateFromError(error: unknown) {
return { error: error instanceof Error ? error.message : "可视化渲染失败" };
}
componentDidCatch(error: unknown, info: ErrorInfo) {
console.error("Visualization renderer failed", error, info.componentStack);
}
render() {
if (this.state.error) {
return <ErrorCard message={this.state.error} source={this.props.source} />;
}
return this.props.children;
}
}
export function VisualizationBlock({
language,
source,
complete,
isLoading,
}: VisualizationBlockProps) {
const result = useMemo(
() => parseVisualizationSpec(language, source),
[language, source],
);
if (!result.ok) {
if (isLoading && !complete) {
return <LoadingCard label={language === "mindmap-json" ? "正在整理思维导图…" : undefined} />;
}
return (
<ErrorCard
title={language === "mindmap-json" ? "思维导图暂时无法显示" : "地图内容暂时无法显示"}
message={result.error}
source={source}
/>
);
}
return (
<VisualizationErrorBoundary key={`${language}-${source.length}`} source={source}>
<Suspense
fallback={
<LoadingCard
label={language === "mindmap-json" ? "正在加载思维导图组件…" : "正在加载本地地图组件…"}
/>
}
>
{language === "mindmap-json" ? (
<MindmapRenderer spec={result.spec as MindmapSpec} />
) : (
<MapRenderer spec={result.spec as MapSpec} />
)}
</Suspense>
</VisualizationErrorBoundary>
);
}
@@ -0,0 +1,22 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
export function VisualizationCard({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<section
className={cn(
"my-4 w-full max-w-none overflow-hidden rounded-xl border bg-background shadow-sm",
className,
)}
>
{children}
</section>
);
}
@@ -0,0 +1,183 @@
import assert from "node:assert/strict";
import test from "node:test";
// @ts-expect-error Node's type-stripping test runner requires the explicit .ts suffix.
import { parseVisualizationSpec, splitVisualizationContent } from "./visualization-spec.ts";
test("ordinary markdown remains one unchanged segment", () => {
const content = "普通回答\n\n```ts\nconst value = 1;\n```";
assert.deepEqual(splitVisualizationContent(content), [{ type: "markdown", content }]);
});
test("splits a dedicated map block while keeping surrounding markdown", () => {
const content = [
"下面是地图:\n",
"```map-json",
'{"title":"地点","markers":[{"name":"北京","coordinate":[116.4,39.9]}]}',
"```",
"\n地图结束。",
].join("\n");
const segments = splitVisualizationContent(content);
assert.equal(segments.length, 3);
assert.equal(segments[0]?.type, "markdown");
assert.equal(segments[1]?.type, "visualization");
assert.equal(segments[2]?.type, "markdown");
});
test("accepts the map contract", () => {
const map = parseVisualizationSpec(
"map-json",
JSON.stringify({ markers: [{ name: "北京", coordinate: [116.4, 39.9] }] }),
);
assert.equal(map.ok, true);
});
test("accepts a dedicated mindmap contract", () => {
const result = parseVisualizationSpec(
"mindmap-json",
JSON.stringify({
title: "形势分析",
rootId: "root",
nodes: [
{ id: "root", parentId: null, label: "形势分析", kind: "topic" },
{ id: "risk", parentId: "root", label: "主要风险", kind: "risk" },
{ id: "action", parentId: "root", label: "处置建议", kind: "action" },
],
}),
);
assert.equal(result.ok, true);
assert.equal(result.ok && result.spec.type, "mindmap");
});
test("renders a completed legacy mindmap fence as a mindmap", () => {
const content = [
"```mindmap",
'{"rootId":"root","nodes":[{"id":"root","parentId":null,"label":"主题"}]}',
"```",
].join("\n");
const visual = splitVisualizationContent(content).find((segment) => segment.type === "visualization");
assert.ok(visual && visual.type === "visualization");
assert.equal(visual.language, "mindmap-json");
assert.equal(visual.complete, true);
});
test("rejects mindmaps with a cycle or missing parent", () => {
const cycle = parseVisualizationSpec(
"mindmap-json",
JSON.stringify({
nodes: [
{ id: "root", parentId: null, label: "主题" },
{ id: "a", parentId: "b", label: "A" },
{ id: "b", parentId: "a", label: "B" },
],
}),
);
assert.equal(cycle.ok, false);
const missingParent = parseVisualizationSpec(
"mindmap-json",
JSON.stringify({
nodes: [
{ id: "root", parentId: null, label: "主题" },
{ id: "a", parentId: "missing", label: "A" },
],
}),
);
assert.equal(missingParent.ok, false);
});
test("rejects dense mindmaps before they can overload the canvas", () => {
const tooManyChildren = parseVisualizationSpec(
"mindmap-json",
JSON.stringify({
nodes: [
{ id: "root", parentId: null, label: "主题" },
{ id: "a", parentId: "root", label: "维度 A" },
{ id: "b", parentId: "root", label: "维度 B" },
{ id: "c", parentId: "root", label: "维度 C" },
{ id: "d", parentId: "root", label: "维度 D" },
{ id: "e", parentId: "root", label: "维度 E" },
{ id: "f", parentId: "root", label: "维度 F" },
{ id: "g", parentId: "root", label: "维度 G" },
{ id: "h", parentId: "root", label: "维度 H" },
],
}),
);
assert.equal(tooManyChildren.ok, false);
const tooManyNodes = parseVisualizationSpec(
"mindmap-json",
JSON.stringify({
nodes: [
{ id: "root", parentId: null, label: "主题" },
...Array.from({ length: 36 }, (_, index) => ({
id: `node-${index}`,
parentId: "root",
label: `节点 ${index + 1}`,
})),
],
}),
);
assert.equal(tooManyNodes.ok, false);
assert.equal(
tooManyNodes.ok ? "" : tooManyNodes.error,
"思维导图节点过多(37 个);请将同类信息合并后控制在 36 个以内",
);
});
test("renders a valid map contract from a generic json fence", () => {
const content = [
"下面是地图:",
"```json",
'{"title":"城市分布","markers":[{"name":"北京","coordinate":[116.4,39.9]}]}',
"```",
].join("\n");
const segments = splitVisualizationContent(content);
const visual = segments.find((segment) => segment.type === "visualization");
assert.ok(visual && visual.type === "visualization");
assert.equal(visual.language, "map-json");
});
test("keeps non-map json fences on the ordinary Markdown path", () => {
const content = '```json\n{"name":"普通数据","items":[1,2,3]}\n```';
assert.deepEqual(splitVisualizationContent(content), [{ type: "markdown", content }]);
});
test("keeps generic mindmap-shaped JSON on the ordinary Markdown path", () => {
const content = [
"```json",
'{"type":"mindmap","nodes":[{"id":"root","parentId":null,"label":"主题"}]}',
"```",
].join("\n");
assert.deepEqual(splitVisualizationContent(content), [{ type: "markdown", content }]);
});
test("keeps unfinished generic json fences on the ordinary Markdown path", () => {
const content = '```json\n{"markers":[';
assert.deepEqual(splitVisualizationContent(content), [{ type: "markdown", content }]);
});
test("rejects invalid map coordinates", () => {
const result = parseVisualizationSpec(
"map-json",
JSON.stringify({ markers: [{ name: "错误地点", coordinate: [200, 100] }] }),
);
assert.equal(result.ok, false);
});
test("flow and chart fences stay on the ordinary Markdown path", () => {
const content = "```flow-json\n{}\n```\n```chart-json\n{}\n```";
assert.deepEqual(splitVisualizationContent(content), [{ type: "markdown", content }]);
});
test("keeps an unfinished map fence for streaming fallback", () => {
const segments = splitVisualizationContent("说明\n```map-json\n{\"title\":\"地点\"");
const visual = segments.find((segment) => segment.type === "visualization");
assert.ok(visual && visual.type === "visualization");
assert.equal(visual.complete, false);
});
test("shows unfinished dedicated mindmap JSON before mounting the renderer", () => {
const content = "说明\n```mindmap-json\n{\"title\":\"分析导图\"";
assert.deepEqual(splitVisualizationContent(content), [{ type: "markdown", content }]);
});
@@ -0,0 +1,271 @@
import { z } from "zod";
export const VISUALIZATION_LANGUAGES = ["map-json", "mindmap-json"] as const;
export type VisualizationLanguage = (typeof VISUALIZATION_LANGUAGES)[number];
const safeText = z.string().trim().min(1).max(500);
const optionalText = z.string().trim().max(2_000).optional();
const longitude = z.number().finite().min(-180).max(180);
const latitude = z.number().finite().min(-90).max(90);
const coordinateSchema = z.tuple([longitude, latitude]);
const mapSchema = z.object({
type: z.literal("map").default("map"),
title: z.string().trim().max(200).default("地图"),
center: coordinateSchema.optional(),
zoom: z.number().finite().min(1).max(20).optional(),
markers: z
.array(
z.object({
name: safeText,
coordinate: coordinateSchema,
description: optionalText,
}),
)
.max(500)
.default([]),
lines: z
.array(
z.object({
name: z.string().trim().max(120).optional(),
coordinates: z.array(coordinateSchema).min(2).max(2_000),
}),
)
.max(100)
.default([]),
polygons: z
.array(
z.object({
name: z.string().trim().max(120).optional(),
coordinates: z.array(coordinateSchema).min(3).max(2_000),
}),
)
.max(100)
.default([]),
});
export type MapSpec = z.infer<typeof mapSchema>;
export const MINDMAP_NODE_KINDS = [
"topic",
"dimension",
"finding",
"evidence",
"risk",
"action",
] as const;
export type MindmapNodeKind = (typeof MINDMAP_NODE_KINDS)[number];
const mindmapNodeSchema = z.object({
id: z.string().trim().min(1).max(80),
parentId: z.string().trim().min(1).max(80).nullable(),
label: z.string().trim().min(1).max(120),
kind: z.enum(MINDMAP_NODE_KINDS).default("finding"),
detail: z.string().trim().max(1_200).optional(),
sourceRefs: z.array(z.string().trim().min(1).max(160)).max(12).optional(),
});
const mindmapSchema = z.object({
type: z.literal("mindmap").default("mindmap"),
title: z.string().trim().max(200).default("分析思维导图"),
summary: z.string().trim().max(800).optional(),
rootId: z.string().trim().min(1).max(80).optional(),
// A mind map is an overview, not a transcription of every paragraph in a
// report. Keeping this small is also important because every node becomes a
// rendered React Flow element in the message list and full-screen canvas.
nodes: z.array(mindmapNodeSchema).min(1).max(36),
});
export type MindmapSpec = z.infer<typeof mindmapSchema>;
type MindmapNode = MindmapSpec["nodes"][number];
export type VisualizationSpec = MapSpec | MindmapSpec;
export type VisualizationParseResult =
| { ok: true; spec: VisualizationSpec }
| { ok: false; error: string };
function normalizeLanguageType(
language: VisualizationLanguage,
value: unknown,
): unknown {
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
const record = { ...(value as Record<string, unknown>) };
if (language === "map-json") record.type = "map";
if (language === "mindmap-json") record.type = "mindmap";
return record;
}
function formatZodError(error: z.ZodError): string {
const issue = error.issues[0];
if (!issue) return "可视化数据格式不正确";
const path = issue.path.length > 0 ? `${issue.path.join(".")}:` : "";
return `${path}${issue.message}`;
}
function validateMindmap(spec: MindmapSpec): string | null {
const nodesById = new Map<string, MindmapSpec["nodes"][number]>();
for (const node of spec.nodes) {
if (nodesById.has(node.id)) return `节点 id 重复:${node.id}`;
nodesById.set(node.id, node);
}
const roots = spec.nodes.filter((node) => node.parentId === null);
if (roots.length !== 1) return "思维导图必须且只能有一个根节点";
const root = roots[0]!;
if (spec.rootId && spec.rootId !== root.id) return "rootId 必须指向唯一的根节点";
for (const node of spec.nodes) {
if (node.parentId === null) continue;
if (node.parentId === node.id) return `节点不能以自身为父节点:${node.id}`;
if (!nodesById.has(node.parentId)) return `节点 ${node.id} 的父节点不存在:${node.parentId}`;
}
const children = new Map<string, MindmapNode[]>();
for (const node of spec.nodes) {
if (node.parentId === null) continue;
const siblings = children.get(node.parentId) ?? [];
siblings.push(node);
children.set(node.parentId, siblings);
}
for (const [parentId, siblings] of children) {
const limit = parentId === root.id ? 6 : 3;
if (siblings.length > limit) {
const parentLabel = nodesById.get(parentId)?.label ?? parentId;
return parentId === root.id
? `中心主题最多保留 6 个一级维度,当前“${parentLabel}”下有 ${siblings.length} 个`
: `节点“${parentLabel}”最多保留 3 个子节点,当前有 ${siblings.length} 个;请合并同类信息`;
}
}
for (const node of spec.nodes) {
let depth = 0;
let cursor: MindmapSpec["nodes"][number] | undefined = node;
const visited = new Set<string>();
while (cursor && cursor.parentId !== null) {
if (visited.has(cursor.id)) return "思维导图不能包含循环父子关系";
visited.add(cursor.id);
cursor = nodesById.get(cursor.parentId);
depth += 1;
if (depth > 3) return "思维导图最多支持 4 层层级";
}
}
return null;
}
function mindmapShapeError(value: unknown): string | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const nodes = (value as Record<string, unknown>).nodes;
if (Array.isArray(nodes) && nodes.length > 36) {
return `思维导图节点过多(${nodes.length} 个);请将同类信息合并后控制在 36 个以内`;
}
return null;
}
export function parseVisualizationSpec(
language: VisualizationLanguage,
source: string,
): VisualizationParseResult {
let raw: unknown;
try {
raw = JSON.parse(source);
} catch {
return { ok: false, error: "地图数据尚未完整或不是有效 JSON" };
}
const normalized = normalizeLanguageType(language, raw);
if (!normalized || typeof normalized !== "object" || Array.isArray(normalized)) {
return { ok: false, error: "地图数据必须是 JSON 对象" };
}
if (language === "mindmap-json") {
const error = mindmapShapeError(normalized);
if (error) return { ok: false, error };
}
const schema = language === "map-json" ? mapSchema : mindmapSchema;
const parsed = schema.safeParse(normalized);
if (!parsed.success) return { ok: false, error: formatZodError(parsed.error) };
if (language === "mindmap-json") {
const error = validateMindmap(parsed.data as MindmapSpec);
if (error) return { ok: false, error };
}
return { ok: true, spec: parsed.data };
}
export type VisualizationSegment =
| { type: "markdown"; content: string }
| {
type: "visualization";
language: VisualizationLanguage;
source: string;
complete: boolean;
};
// `mindmap-json` is the contract. `mindmap` is retained as a compatibility
// alias because existing model turns have already emitted that fence name.
const FENCE_PATTERN = /(^|\n)```(map-json|mindmap-json|mindmap|json)[ \t]*\r?\n/gi;
function isMapJsonSource(source: string): boolean {
let raw: unknown;
try {
raw = JSON.parse(source);
} catch {
return false;
}
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return false;
const record = raw as Record<string, unknown>;
const hasMapField = ["center", "zoom", "markers", "lines", "polygons"].some(
(field) => Object.prototype.hasOwnProperty.call(record, field),
);
return hasMapField && parseVisualizationSpec("map-json", source).ok;
}
/** Split only our dedicated fences. With no match, the caller keeps the exact legacy renderer path. */
export function splitVisualizationContent(content: string): VisualizationSegment[] {
const segments: VisualizationSegment[] = [];
let cursor = 0;
FENCE_PATTERN.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = FENCE_PATTERN.exec(content)) !== null) {
const blockStart = match.index + match[1].length;
if (blockStart > cursor) {
segments.push({ type: "markdown", content: content.slice(cursor, blockStart) });
}
const sourceStart = FENCE_PATTERN.lastIndex;
const closeIndex = content.indexOf("```", sourceStart);
const complete = closeIndex >= 0;
const source = content.slice(sourceStart, complete ? closeIndex : content.length).trim();
const matchedLanguage = match[2].toLowerCase();
const isMindmapFence = matchedLanguage === "mindmap-json" || matchedLanguage === "mindmap";
// Dedicated mind-map JSON is shown as an ordinary streaming code block
// until the closing fence arrives. That lets people see the model writing
// and validate the raw structure before the expensive renderer mounts.
if (isMindmapFence && !complete) {
return [{ type: "markdown", content }];
}
if (matchedLanguage === "json" && (!complete || !isMapJsonSource(source))) {
if (!complete) break;
FENCE_PATTERN.lastIndex = closeIndex + 3;
continue;
}
segments.push({
type: "visualization",
language: isMindmapFence ? "mindmap-json" : "map-json",
source,
complete,
});
cursor = complete ? closeIndex + 3 : content.length;
FENCE_PATTERN.lastIndex = cursor;
if (!complete) break;
}
if (cursor < content.length) {
segments.push({ type: "markdown", content: content.slice(cursor) });
}
return segments.length > 0 ? segments : [{ type: "markdown", content }];
}
@@ -24,7 +24,10 @@ import {
applyCompactMenuOverrides,
type CompactMenuItem,
} from "@/core/page-layout/compact-menu-overrides";
import { type MenuItem } from "@/core/page-layout/sidebar-menu";
import {
MARKDOWN_DOCX_MENU_ITEM,
type MenuItem,
} from "@/core/page-layout/sidebar-menu";
import { useCompactMenuOverrides } from "@/strategy-components/api/compact-menu-overrides";
// 普通导航项:默认中性色,激活态与「新对话」一致——主色浅底 + 主色文字/图标
@@ -95,13 +98,6 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
path: "/page/workspace/skills",
match: (pathname) => pathname.startsWith("/page/workspace/skills"),
},
{
id: "workspace-memory",
label: "记忆",
icon: BrainIcon,
path: "/page/workspace/memory",
match: (pathname) => pathname.startsWith("/page/workspace/memory"),
},
{
id: "workspace-ai-writing",
label: "AI写作",
@@ -149,7 +145,13 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
label: "知识库管理",
icon: BookOpen,
path: "/page/workspace/knowledge",
match: (pathname) => pathname.startsWith("/page/workspace/knowledge"),
match: (pathname) =>
pathname.startsWith("/page/workspace/knowledge") &&
!pathname.startsWith(MARKDOWN_DOCX_MENU_ITEM.path),
},
{
...MARKDOWN_DOCX_MENU_ITEM,
match: (pathname) => pathname.startsWith(MARKDOWN_DOCX_MENU_ITEM.path),
},
];
@@ -160,7 +162,8 @@ export function useWorkspaceCompactNavItems(): {
settingsItems: EffectiveWorkspaceCompactNavItem[];
toolboxItems: EffectiveWorkspaceCompactNavItem[];
} {
const { data: overrides = [] } = useCompactMenuOverrides();
const { data: overrideConfig } = useCompactMenuOverrides();
const overrides = overrideConfig?.overrides ?? [];
return useMemo(() => {
const applied = applyCompactMenuOverrides(WORKSPACE_COMPACT_NAV_ITEMS, overrides);
return {
@@ -223,7 +223,7 @@ export function WorkspaceNavMenu() {
{isAdmin ? (
<DropdownMenuItem
onClick={() =>
navigate("/page/strategy/admin/user-leaderboard")
navigate("/page/workspace/admin/user-leaderboard")
}
>
<TrophyIcon />
@@ -273,16 +273,15 @@ export function WorkspaceNavMenu() {
模型配置
</DropdownMenuItem>
)}
{/* 与全量模式左下角「设置和更多」下拉里的菜单管理同入口(管理员):
简洁模式没有全局侧边栏,这里放一份保证两种模式都能进菜单管理。 */}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/strategy/admin/menu-management")}
>
<ListTreeIcon />
菜单管理
</DropdownMenuItem>
)}
{/* 与全量模式左下角「设置和更多」下拉里的菜单管理同入口:
简洁模式没有全局侧边栏,走工作区地址带上 WorkspaceSidebar;
/page/strategy/admin/menu-management 保留给全量模式侧边栏。 */}
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/menu-management")}
>
<ListTreeIcon />
{isAdmin ? "默认菜单管理" : "我的菜单"}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/tags")}
>
@@ -328,7 +327,7 @@ export function WorkspaceNavMenu() {
{isAdmin && (
<DropdownMenuItem
onClick={() =>
navigate("/page/strategy/admin/fixed-questions")
navigate("/page/workspace/admin/fixed-questions")
}
>
<MessageSquareTextIcon />
@@ -18,15 +18,18 @@ import { WorkspaceHeader } from "./workspace-header";
import { WorkspaceNavChatList } from "./workspace-nav-chat-list";
import { WorkspaceNavMenu } from "./workspace-nav-menu";
export function WorkspaceSidebar({
compactChrome = false,
forceCollapsed = false,
className,
...props
}: React.ComponentProps<typeof Sidebar> & {
compactChrome?: boolean;
forceCollapsed?: boolean;
}) {
export function WorkspaceSidebar({
compactChrome = false,
forceCollapsed = false,
wxCommonAgents = false,
className,
...props
}: React.ComponentProps<typeof Sidebar> & {
compactChrome?: boolean;
forceCollapsed?: boolean;
/** Enables wx's shared Agent Gallery category selection. */
wxCommonAgents?: boolean;
}) {
const { open: isSidebarOpen } = useSidebar();
const { theme } = useTheme();
const pathname = usePathname();
@@ -35,6 +38,7 @@ export function WorkspaceSidebar({
// 仅在通用问答(/page/workspace/chats*)下启用三块式布局;点击常用智能体后是
// 「原地嵌入」(停留在 /chats/new?agent=...,仅右侧问答区换成智能体对话),左侧栏不变。
const isGeneralQa =
wxCommonAgents ||
pathname.startsWith("/page/workspace/chats") ||
pathname.startsWith("/page/strategy/chats");
@@ -84,7 +88,7 @@ export function WorkspaceSidebar({
{isGeneralQa ? (
// 全量版通用问答(/page/workspace/chats*):新建对话侧栏独立成
// GeneralQaSidebar(按 0616 样式参考图重画),与其余 workspace 页解耦。
<GeneralQaSidebar />
<GeneralQaSidebar wxCommonAgents={wxCommonAgents} />
) : (
<>
<WorkspaceNavChatList showCollapseTrigger compact />