sync update from LAN repo (source ji @ 85078a31)
This commit is contained in:
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) => ({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
"'": "'",
|
||||
'"': """,
|
||||
})[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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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 />
|
||||
|
||||
Reference in new issue
Block a user