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

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

No files matched your search

@@ -33,12 +33,12 @@ import {
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
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,23 +1010,35 @@ 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">
<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 ?? "检索结果"}
</DialogTitle>
</SheetTitle>
{(sourceLabel || date) && (
<DialogDescription className="flex items-center gap-2 text-xs">
<SheetDescription className="flex items-center gap-2 text-xs">
{sourceLabel && <span>{sourceLabel}</span>}
{sourceLabel && date && <span>·</span>}
{date && <span>{date}</span>}
</DialogDescription>
</SheetDescription>
)}
</DialogHeader>
{content && (
<div className="text-foreground max-h-[60vh] overflow-y-auto whitespace-pre-wrap text-sm leading-relaxed">
{!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 && (
@@ -1034,8 +1046,10 @@ function SearchResultCard({ item }: { item: SearchResultItem }) {
{item.url}
</div>
)}
</DialogContent>
</Dialog>
</div>
</div>
</SheetContent>
</Sheet>
</>
);
}
@@ -1,7 +1,8 @@
import type { Message } from '@langchain/langgraph-sdk';
import {
ArrowLeftIcon,
BookMarkedIcon,
ChevronDownIcon,
ChevronRightIcon,
ClockIcon,
ExternalLinkIcon,
FileIcon,
@@ -37,17 +38,12 @@ 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';
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import { Textarea } from '@/components/ui/textarea';
import {
UploadedFilePreviewDialog,
@@ -58,6 +54,7 @@ import { useChatAskTimes } from '@/core/chat-timestamps';
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,
@@ -77,12 +74,15 @@ import {
isMultiRoundRagMessage,
} from '@/core/threads/use-multi-round-rag';
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 { 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';
@@ -168,6 +168,8 @@ export function MessageListItem({
onEditSave,
onMessageDeleted,
turnTime,
answerFeedbackEnabled = false,
answerFeedbackContext,
}: {
className?: string;
fitRowToContent?: boolean;
@@ -194,6 +196,10 @@ export function MessageListItem({
* wall clock at render time and appear to "drift" to now on every reload.
*/
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';
@@ -218,6 +224,9 @@ export function MessageListItem({
!!message.id &&
!message.id.startsWith('opt-') &&
!!messageMarkdown;
const canEvaluateAnswer =
answerFeedbackEnabled &&
isEligibleAnswerFeedbackMessage(message, Boolean(isLoading));
const confirmAssistantEdit = useCallback(async () => {
const value = assistantDraft.trim();
if (!value || !message.id || !onEditSave || assistantSaving) {
@@ -327,6 +336,13 @@ export function MessageListItem({
>
<div className="flex gap-1">
<CopyButton clipboardData={messageMarkdown} />
{canEvaluateAnswer && message.id && answerFeedbackContext && (
<AnswerFeedbackControl
threadId={threadId}
messageId={message.id}
context={answerFeedbackContext}
/>
)}
{messageMarkdown && (
<DownloadWordButton
markdown={messageMarkdown}
@@ -692,13 +708,16 @@ function MessageContent_({
? stripArtifactTag(stripUploadedFilesTag(rawContent))
: '';
}
const sanitizedContent = stripKnowledgeRetrievalDiagnostics(rawContent ?? '');
if (isMultiRoundRag) {
return stripIncompleteCitationMarker(rawContent ?? '');
return stripIncompleteCitationMarker(sanitizedContent);
}
const withCitations = applyInlineCitations(rawContent ?? '', displaySources);
const withCitations = applyInlineCitations(sanitizedContent, displaySources);
return stripIncompleteCitationMarker(withCitations);
}, [rawContent, isHuman, isMultiRoundRag, displaySources]);
const markdownRehypePlugins = useMemo(
const markdownRehypePlugins = useMemo<
NonNullable<MarkdownContentProps['rehypePlugins']>
>(
() => [...rehypePlugins, [rehypeKatex, { output: 'html' as const }]],
[rehypePlugins],
);
@@ -808,10 +827,7 @@ function MessageContent_({
)}
</div>
{isSingleRoundRag && displaySources.length > 0 && (
<FrontendRagSourcesPanel
sources={displaySources}
onSelect={setSelectedCitation}
/>
<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);
@@ -835,109 +851,173 @@ function MessageContent_({
}
/**
* 单轮 RAG 专属的「引用来源」折叠面板。
*
* 多轮 RAG 的检索卡片已经画在思考过程里,正文下面不再重复一份来源列表。
* 单轮 RAG 的参考文献入口。先打开右侧抽屉查看列表,
* 选中条目后在同一抽屉中阅读详情,避免跳出居中弹框打断问答。
*/
function FrontendRagSourcesPanel({
sources,
onSelect,
}: {
sources: RuntimeDisplaySource[];
onSelect: (source: RuntimeDisplaySource) => void;
}) {
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 (
<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"
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="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',
)}
/>
<span>查看</span>
<ChevronRightIcon className="size-3.5" />
</button>
</CollapsibleTrigger>
<CollapsibleContent className="mt-2 grid gap-2">
<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={() => onSelect(source)}
className="bg-card hover:bg-accent block w-full cursor-pointer rounded-lg border p-3 text-left transition-colors"
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-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">
<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>
<span
className="text-foreground line-clamp-1 min-w-0 flex-1 text-sm font-medium"
title={source.title}
>
<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-xs">
<span className="bg-muted text-muted-foreground shrink-0 rounded px-1.5 py-0.5 text-[11px]">
{source.type}
</span>
)}
</div>
{source.snippet && (
<p className="text-muted-foreground mt-1.5 line-clamp-2 text-xs leading-relaxed">
{source.snippet}
{(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>
))}
</CollapsibleContent>
</Collapsible>
</div>
</div>
)}
</div>
</SheetContent>
</Sheet>
</>
);
}
export function SingleRoundRagCitationDialog({
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">
<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 || "引用资料"}
</DialogTitle>
<DialogDescription className="space-y-1 text-xs">
</SheetTitle>
<SheetDescription className="space-y-1 text-xs">
<span className="block">来源:{source?.source || source?.type || "未知来源"}</span>
{source?.time && <span className="block">时间:{source.time}</span>}
</DialogDescription>
</DialogHeader>
</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 hover:bg-accent inline-flex w-fit items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors"
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>
)}
<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 || "暂无引用内容"}
<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>
</DialogContent>
</Dialog>
);
}
@@ -1163,6 +1243,8 @@ function RichFileCard({
<UploadedFilePreviewDialog
filename={file.filename}
fileUrl={fileUrl}
threadId={threadId}
virtualPath={file.path}
open={previewOpen}
onOpenChange={setPreviewOpen}
/>
@@ -44,12 +44,14 @@ 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 { 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 { isEligibleAnswerFeedbackMessage } from "./answer-feedback-eligibility";
import { MarkdownContent } from "./markdown-content";
import { DocumentRewriteTimeline } from "./document-rewrite-timeline";
import { MessageGroup } from "./message-group";
@@ -112,6 +114,50 @@ function ScrollToBottomOnUserMessage({
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,
@@ -122,6 +168,7 @@ export function MessageList({
tokenUsageEnabled = false,
extraRehypePlugins,
scrollButtonClassName,
onAtBottomChange,
onClarificationSubmit,
onEditResubmit,
onEditSave,
@@ -141,6 +188,7 @@ export function MessageList({
onOpenArtifact,
onDownloadArtifact,
afterDocumentRewriteItems,
answerFeedbackEnabled = false,
}: {
className?: string;
contentClassName?: string;
@@ -151,6 +199,8 @@ export function MessageList({
tokenUsageEnabled?: boolean;
/** 额外定位「回到最新」悬浮按钮——不同页面输入框高度不同,需上移让位。 */
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;
@@ -196,6 +246,12 @@ export function MessageList({
onDownloadArtifact?: (filepath: string) => void | Promise<void>;
/** 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(
@@ -256,6 +312,39 @@ export function MessageList({
},
[allMessages, externallyHiddenMessageIds, hiddenMessageIds],
);
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();
@@ -321,7 +410,8 @@ export function MessageList({
const referenceModeEntryEnabled =
citationDisplay?.reference_mode_enabled ?? false;
const knowledgeReferencesEnabled =
threadContext?.knowledgeReferencesEnabled === true;
threadContext?.knowledgeReferencesEnabled === true ||
!!threadContext?.knowledgeReferences;
const ragModeEnabled =
(referenceModeEntryEnabled && settings.display.ragModeEnabled) ||
isThreadRagModeLocked(threadId) ||
@@ -524,6 +614,7 @@ export function MessageList({
className={cn("flex size-full flex-col justify-center", className)}
>
<ScrollToBottomOnUserMessage threadId={threadId} messages={messages} />
<ConversationBottomObserver onAtBottomChange={onAtBottomChange} />
<ConversationContent
className={cn(
"mx-auto w-full max-w-(--container-width-lg)",
@@ -585,6 +676,12 @@ export function MessageList({
allowDelete ? handleMessageDeleted : undefined
}
turnTime={msg.id ? turnTimes.get(msg.id) : undefined}
answerFeedbackEnabled={answerFeedbackEnabled}
answerFeedbackContext={
msg.id && ratableAnswerMessageIds.has(msg.id)
? answerFeedbackContexts?.[msg.id]
: undefined
}
/>
);
});
@@ -957,6 +1054,7 @@ export type SetupWritingApprovalPayload = {
};
export type SetupReportStructureApprovalPayload = {
kind?: "report_structure" | "gongwen_template";
title?: string;
name?: string;
content?: string;
@@ -1376,6 +1474,7 @@ function SetupReportStructureApprovalCard({
}
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,7 +426,28 @@ export function UploadedFilePreviewDialog({
loadingView
) : textError ? (
<BinaryFallback fileUrl={fileUrl} label={t.common.download} />
) : renderAsMarkdown ? (
) : (
<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 ?? ""}
@@ -254,6 +459,8 @@ export function UploadedFilePreviewDialog({
<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>
);
}
@@ -48,6 +48,7 @@ import {
SidebarMenuAction,
SidebarMenuButton,
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,
@@ -83,6 +85,11 @@ import { env } from "@/env";
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,
@@ -98,6 +105,7 @@ export function RecentChatList({
const { t } = useI18n();
const router = useRouter();
const pathname = usePathname();
const { isMobile, setOpenMobile } = useSidebar();
const [internalCollapsed, setInternalCollapsed] = useState(
() => localStorage.getItem("sidebar:recent-chats-collapsed") === "true",
);
@@ -116,6 +124,7 @@ export function RecentChatList({
agent_id?: string;
}>();
const { data: threads = [] } = useThreads();
const { data: recentStrategyProjects = [] } = useRecentStrategyProjects();
const { mutate: deleteThread } = useDeleteThread();
const { mutate: renameThread } = useRenameThread();
const { toggle: togglePin } = useToggleThreadPin();
@@ -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(
@@ -203,12 +212,16 @@ export function RecentChatList({
const visibleThreads = sortThreadsPinnedFirst(
threads.filter((thread) => !isHiddenFromRecentChats(thread)),
);
const projectThreadNames = useProjectThreadNames(visibleThreads);
const recentItems = mergeRecentWorkspaceItems(
visibleThreads,
recentStrategyProjects,
);
if (visibleThreads.length === 0) {
if (recentItems.length === 0) {
return null;
}
const shownThreads =
limit != null ? visibleThreads.slice(0, limit) : visibleThreads;
const shownItems = limit != null ? recentItems.slice(0, limit) : recentItems;
return (
<>
<SidebarGroup
@@ -230,7 +243,7 @@ export function RecentChatList({
</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}
{recentItems.length}
</span>
)}
</span>
@@ -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}
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}
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">
<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>
{projectType && (
{projectInfo && (
<MyProjectThreadBadge
className="mr-3"
type={projectType}
className="mr-3 max-w-[calc(100%_-_0.75rem)]"
projectName={projectInfo.projectName}
type={projectInfo.type}
/>
)}
</span>
</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,
@@ -75,6 +77,7 @@ import {
useDeleteBuiltinSkill,
useDeleteCustomSkill,
useDuplicateSkill,
useEnableSkill,
useGenerateMissingSkillNameZh,
useGenerateSkillNameZh,
useInstallSkillUpload,
@@ -87,11 +90,23 @@ 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 { formatDateTime } from "@/core/utils/datetime";
@@ -105,18 +120,25 @@ import { SkillStateSheet } from "./skill-state-sheet";
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} />;
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">
{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
@@ -549,6 +625,7 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
skill={skill}
currentUserId={currentUserId}
isAdmin={isAdmin}
availabilityControl="owner"
layout={viewMode === "card" ? "card" : "row"}
onPublisherClick={setPublisher}
/>
@@ -607,6 +684,7 @@ export function SkillRow({
skill,
currentUserId,
isAdmin,
availabilityControl,
layout = "row",
onPublisherClick,
knowledgeBindings = [],
@@ -614,6 +692,8 @@ export function SkillRow({
skill: Skill;
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
@@ -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
@@ -653,6 +735,27 @@ export function SkillRow({
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 {
@@ -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"
@@ -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)}
@@ -1157,6 +1318,37 @@ export function SkillRow({
</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>
</Dialog>
<TagPickerDialog
open={tagPickerOpen}
onOpenChange={setTagPickerOpen}
@@ -1372,6 +1564,9 @@ function InstallSkillDialog({
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
@@ -1386,6 +1581,8 @@ function InstallSkillDialog({
? 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 (
@@ -1572,7 +1769,7 @@ function InstallSkillDialog({
<div
className={cn(
"mt-1 flex items-start gap-2 text-xs",
ownCustomCanOverwrite ? "text-amber-600" : "text-destructive",
ownCustomCanOverwrite || unclaimedCustomConflict ? "text-amber-600" : "text-destructive",
)}
>
<AlertCircle className="mt-0.5 size-3.5 shrink-0" />
@@ -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,9 +229,10 @@ export function SourceCard({
</Button>
)}
</div>
</DialogHeader>
</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 ? (
<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" />
@@ -237,15 +248,20 @@ export function SourceCard({
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>
) : null}
)}
{source.url && !previewTarget && (
<div className="text-muted-foreground border-t pt-3 text-xs break-all">
<div className="text-muted-foreground mt-4 border-t pt-3 text-xs break-all">
{source.url}
</div>
)}
</DialogContent>
</Dialog>}
</div>
</SheetContent>
</Sheet>}
</>
);
}
@@ -0,0 +1,214 @@
import { useEffect, useMemo, useRef, useState } from "react";
import * as maplibregl from "maplibre-gl";
import type { StyleSpecification } from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
import { AlertTriangle, MapPinned, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { getMapTileAttribution, getMapTileUrl } from "@/core/utils/map-tile-config";
import type { MapSpec } from "./visualization-spec";
import { VisualizationCard } from "./visualization-card";
function createStyle(): StyleSpecification {
return {
version: 8,
sources: {
"google-raster": {
type: "raster",
tiles: [getMapTileUrl()],
tileSize: 256,
attribution: getMapTileAttribution(),
},
},
layers: [{ id: "google-raster", type: "raster", source: "google-raster" }],
};
}
function allCoordinates(spec: MapSpec): Array<[number, number]> {
return [
...spec.markers.map((marker) => marker.coordinate),
...spec.lines.flatMap((line) => line.coordinates),
...spec.polygons.flatMap((polygon) => polygon.coordinates),
];
}
function defaultCenter(spec: MapSpec): [number, number] {
if (spec.center) return spec.center;
const coordinates = allCoordinates(spec);
if (coordinates.length === 0) return [104.1954, 35.8617];
const total = coordinates.reduce(([lng, lat], coordinate) => [lng + coordinate[0], lat + coordinate[1]], [0, 0]);
return [total[0] / coordinates.length, total[1] / coordinates.length];
}
export default function MapRenderer({ spec }: { spec: MapSpec }) {
const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<maplibregl.Map | null>(null);
const [tileFailed, setTileFailed] = useState(false);
const [reloadToken, setReloadToken] = useState(0);
const coordinates = useMemo(() => allCoordinates(spec), [spec]);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
setTileFailed(false);
const map = new maplibregl.Map({
container,
style: createStyle(),
center: defaultCenter(spec),
zoom: spec.zoom ?? (coordinates.length > 1 ? 4 : 8),
minZoom: 1,
maxZoom: 20,
attributionControl: { compact: true },
});
mapRef.current = map;
map.addControl(new maplibregl.NavigationControl({ showCompass: false }), "top-right");
const markers: maplibregl.Marker[] = [];
for (const marker of spec.markers) {
const element = document.createElement("button");
element.type = "button";
element.className = "size-5 rounded-full border-2 border-white bg-primary shadow-md focus:outline-none focus:ring-2 focus:ring-primary/50";
element.title = marker.name;
element.setAttribute("aria-label", marker.name);
const popup = new maplibregl.Popup({ offset: 14 }).setHTML(
`<div style="max-width:240px"><strong>${escapeHtml(marker.name)}</strong>${marker.description ? `<p style="margin:6px 0 0">${escapeHtml(marker.description)}</p>` : ""}</div>`,
);
markers.push(new maplibregl.Marker({ element }).setLngLat(marker.coordinate).setPopup(popup).addTo(map));
}
let baseTileLoaded = false;
const onSourceData = (event: maplibregl.MapSourceDataEvent) => {
if (event.sourceId !== "google-raster" || event.sourceDataType !== "content") return;
baseTileLoaded = true;
setTileFailed(false);
};
const onError = (event: maplibregl.ErrorEvent) => {
const message = event.error?.message ?? "";
if (!baseTileLoaded && /tile|fetch|network|request|http/i.test(message)) setTileFailed(true);
};
map.on("sourcedata", onSourceData);
map.on("error", onError);
map.on("load", () => {
if (spec.lines.length > 0) {
map.addSource("visual-lines", {
type: "geojson",
data: {
type: "FeatureCollection",
features: spec.lines.map((line) => ({
type: "Feature",
properties: { name: line.name ?? "" },
geometry: { type: "LineString", coordinates: line.coordinates },
})),
},
});
map.addLayer({
id: "visual-lines",
type: "line",
source: "visual-lines",
paint: { "line-color": "#2563eb", "line-width": 3, "line-opacity": 0.85 },
});
}
if (spec.polygons.length > 0) {
map.addSource("visual-polygons", {
type: "geojson",
data: {
type: "FeatureCollection",
features: spec.polygons.map((polygon) => ({
type: "Feature",
properties: { name: polygon.name ?? "" },
geometry: { type: "Polygon", coordinates: [[...polygon.coordinates, polygon.coordinates[0]]] },
})),
},
});
map.addLayer({
id: "visual-polygons-fill",
type: "fill",
source: "visual-polygons",
paint: { "fill-color": "#2563eb", "fill-opacity": 0.18 },
});
map.addLayer({
id: "visual-polygons-border",
type: "line",
source: "visual-polygons",
paint: { "line-color": "#2563eb", "line-width": 2 },
});
}
if (!spec.center && coordinates.length > 1) {
const bounds = coordinates.reduce(
(current, coordinate) => current.extend(coordinate),
new maplibregl.LngLatBounds(coordinates[0], coordinates[0]),
);
map.fitBounds(bounds, { padding: 52, maxZoom: 12, duration: 0 });
}
});
return () => {
map.off("sourcedata", onSourceData);
map.off("error", onError);
markers.forEach((marker) => marker.remove());
mapRef.current = null;
map.remove();
};
}, [coordinates, reloadToken, spec]);
return (
<VisualizationCard>
<div className="flex min-h-12 items-center gap-2 border-b px-3 py-2">
<MapPinned aria-hidden="true" className="size-4 shrink-0 text-primary" />
<span className="truncate text-sm font-medium">{spec.title}</span>
<span className="ml-auto text-xs text-muted-foreground">{spec.markers.length} 个地点</span>
</div>
<div className="relative">
<div ref={containerRef} className="h-[420px] w-full" role="img" aria-label={`${spec.title},包含 ${spec.markers.length} 个地点`} />
{tileFailed ? (
<div className="absolute inset-x-3 top-3 z-10 flex items-center gap-2 rounded-lg border border-amber-300 bg-amber-50/95 p-3 text-xs text-amber-900 shadow-sm" role="status">
<AlertTriangle aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
<span className="flex-1">地图底图当前不可访问;请检查网络。地点和线路数据不会丢失。</span>
<Button
aria-label="重新加载地图"
className="shrink-0 text-amber-950 hover:bg-amber-100"
onClick={() => {
setTileFailed(false);
setReloadToken((value) => value + 1);
}}
size="sm"
variant="ghost"
>
<RefreshCw aria-hidden="true" />
重试
</Button>
</div>
) : null}
</div>
{spec.markers.length > 0 ? (
<details className="border-t px-3 py-2 text-xs text-muted-foreground">
<summary className="cursor-pointer select-none">查看地点列表</summary>
<ul className="mt-2 space-y-1">
{spec.markers.map((marker, index) => (
<li key={`${marker.name}-${index}`}>
<span className="font-medium text-foreground">{marker.name}</span>
<span>({marker.coordinate[0]},{marker.coordinate[1]})</span>
{marker.description ? <span>:{marker.description}</span> : null}
</li>
))}
</ul>
</details>
) : null}
</VisualizationCard>
);
}
function escapeHtml(value: string): string {
return value.replace(/[&<>'"]/g, (character) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
"'": "&#39;",
'"': "&quot;",
})[character] ?? character);
}
@@ -0,0 +1,682 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import {
Background,
BackgroundVariant,
Handle,
Panel,
Position,
ReactFlow,
ReactFlowProvider,
useReactFlow,
type Edge,
type Node,
type NodeProps,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import {
Download,
FileJson,
ImageDown,
LocateFixed,
Maximize2,
Minus,
Plus,
X,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { renderSvgElementToPngBlob, triggerBlobDownload } from "@/core/utils/mermaid";
import { cn } from "@/lib/utils";
import { VisualizationCard } from "./visualization-card";
import type { MindmapNodeKind, MindmapSpec } from "./visualization-spec";
const NODE_WIDTH = 184;
const NODE_HEIGHT = 68;
const LAYER_GAP = 244;
const ROW_GAP = 88;
const PREVIEW_DEPTH = 2;
const KIND_META: Record<MindmapNodeKind, { label: string; className: string }> = {
topic: { label: "主题", className: "border-primary/50 bg-primary/10" },
dimension: { label: "维度", className: "border-sky-500/45 bg-sky-500/10" },
finding: { label: "发现", className: "border-violet-500/45 bg-violet-500/10" },
evidence: { label: "依据", className: "border-emerald-500/45 bg-emerald-500/10" },
risk: { label: "风险", className: "border-amber-500/50 bg-amber-500/10" },
action: { label: "建议", className: "border-rose-500/45 bg-rose-500/10" },
};
type MindmapNode = MindmapSpec["nodes"][number];
type LayoutNode = MindmapNode & {
depth: number;
x: number;
y: number;
hiddenCount: number;
hasChildren: boolean;
collapsed: boolean;
};
type MindmapLayout = {
nodes: LayoutNode[];
edges: Array<{ id: string; from: string; to: string }>;
width: number;
height: number;
};
function childrenByParent(spec: MindmapSpec) {
const children = new Map<string, MindmapNode[]>();
for (const node of spec.nodes) {
if (node.parentId === null) continue;
const list = children.get(node.parentId) ?? [];
list.push(node);
children.set(node.parentId, list);
}
return children;
}
function getRoot(spec: MindmapSpec): MindmapNode {
return spec.nodes.find((node) => node.parentId === null)!;
}
function descendantsCount(id: string, children: Map<string, MindmapNode[]>): number {
let count = 0;
for (const child of children.get(id) ?? []) {
count += 1 + descendantsCount(child.id, children);
}
return count;
}
/**
* A rooted analysis tree uses a left-to-right layout in messages. It reads more
* naturally than a radial graph inside a narrow chat column and stays stable
* when the same persisted message is replayed.
*/
function layoutMindmap(
spec: MindmapSpec,
maxDepth?: number,
collapsedIds: ReadonlySet<string> = new Set(),
): MindmapLayout {
const children = childrenByParent(spec);
const root = getRoot(spec);
const visible = new Map<string, { node: MindmapNode; depth: number }>();
const collect = (node: MindmapNode, depth: number) => {
visible.set(node.id, { node, depth });
if ((maxDepth !== undefined && depth >= maxDepth) || collapsedIds.has(node.id)) return;
for (const child of children.get(node.id) ?? []) collect(child, depth + 1);
};
collect(root, 0);
const unitHeight = (id: string): number => {
const childIds = (children.get(id) ?? []).filter((child) => visible.has(child.id));
if (childIds.length === 0) return 1;
return childIds.reduce((sum, child) => sum + unitHeight(child.id), 0);
};
const positions = new Map<string, { x: number; y: number }>();
const assign = (id: string, depth: number, top: number): number => {
const ownUnits = unitHeight(id);
positions.set(id, {
x: depth * LAYER_GAP,
y: top + ((ownUnits - 1) * ROW_GAP) / 2,
});
let nextTop = top;
for (const child of children.get(id) ?? []) {
if (!visible.has(child.id)) continue;
nextTop = assign(child.id, depth + 1, nextTop) + ROW_GAP;
}
return top + (ownUnits - 1) * ROW_GAP;
};
assign(root.id, 0, 0);
const nodes = [...visible.values()].map(({ node, depth }) => ({
...node,
depth,
x: positions.get(node.id)?.x ?? 0,
y: positions.get(node.id)?.y ?? 0,
hiddenCount:
(maxDepth !== undefined && depth === maxDepth) || collapsedIds.has(node.id)
? descendantsCount(node.id, children)
: 0,
hasChildren: (children.get(node.id) ?? []).length > 0,
collapsed: collapsedIds.has(node.id),
}));
const nodeIds = new Set(nodes.map((node) => node.id));
const edges = nodes
.filter((node) => node.parentId && nodeIds.has(node.parentId))
.map((node) => ({ id: `${node.parentId}->${node.id}`, from: node.parentId!, to: node.id }));
const maxX = Math.max(...nodes.map((node) => node.x), 0);
const maxY = Math.max(...nodes.map((node) => node.y), 0);
return {
nodes,
edges,
width: maxX + NODE_WIDTH + 64,
height: maxY + NODE_HEIGHT + 64,
};
}
type FlowNodeData = {
node: LayoutNode;
selected: boolean;
onToggle?: (id: string) => void;
};
function MindmapFlowNode({ data }: NodeProps) {
const { node, selected, onToggle } = data as FlowNodeData;
const meta = KIND_META[node.kind];
return (
<div
className={cn(
"relative w-[184px] rounded-xl border bg-background px-3 py-2 shadow-sm transition-shadow",
"hover:shadow-md focus-within:ring-2 focus-within:ring-primary/50",
meta.className,
selected && "ring-2 ring-primary ring-offset-2 ring-offset-background",
)}
title={node.label}
>
{node.parentId ? <Handle type="target" position={Position.Left} className="!size-1.5 !border-0 !bg-border" /> : null}
<div className="mb-1 flex items-center gap-1.5 text-[11px] leading-none text-muted-foreground">
<span className="font-medium">{meta.label}</span>
{node.hiddenCount > 0 ? (
<span className="rounded-full bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
+{node.hiddenCount}
</span>
) : null}
</div>
<div className="line-clamp-2 text-sm font-medium leading-5 text-foreground">{node.label}</div>
{node.hasChildren ? (
<>
<Handle type="source" position={Position.Right} className="!size-6 !border-0 !bg-transparent" />
<button
type="button"
className="absolute -right-3 top-1/2 z-20 flex size-6 -translate-y-1/2 items-center justify-center rounded-full border border-slate-400 bg-background text-slate-600 shadow-sm transition-colors hover:border-primary hover:bg-primary hover:text-primary-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
aria-label={`${node.collapsed ? "展开" : "收起"}${node.label}`}
aria-expanded={!node.collapsed}
title={node.collapsed ? "展开子节点" : "收起子节点"}
onClick={(event) => {
event.stopPropagation();
onToggle?.(node.id);
}}
>
{node.collapsed ? <Plus aria-hidden="true" className="size-3.5" /> : <Minus aria-hidden="true" className="size-3.5" />}
</button>
</>
) : null}
</div>
);
}
const NODE_TYPES = { mindmap: MindmapFlowNode };
function MindmapCanvasControls({
compact,
onClose,
}: {
compact: boolean;
onClose?: () => void;
}) {
const rf = useReactFlow();
const buttonClassName = compact ? "size-7" : "size-8";
const padding = compact ? 0.16 : 0.12;
return (
<Panel position="top-right" className="flex items-center gap-1 rounded-lg border bg-background/95 p-1 shadow-sm backdrop-blur">
<Button
type="button"
variant="ghost"
size="icon"
className={buttonClassName}
title="放大"
aria-label="放大思维导图"
onClick={() => void rf.zoomIn({ duration: 150 })}
>
<Plus aria-hidden="true" className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className={buttonClassName}
title="缩小"
aria-label="缩小思维导图"
onClick={() => void rf.zoomOut({ duration: 150 })}
>
<Minus aria-hidden="true" className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className={buttonClassName}
title="重置位置与缩放"
aria-label="重置思维导图位置与缩放"
onClick={() => void rf.fitView({ duration: 220, padding })}
>
<LocateFixed aria-hidden="true" className="size-4" />
</Button>
{onClose ? (
<>
<span aria-hidden="true" className="mx-0.5 h-4 w-px bg-border" />
<Button
type="button"
variant="ghost"
size="icon"
className={buttonClassName}
title="关闭"
aria-label="关闭思维导图"
onClick={onClose}
>
<X aria-hidden="true" className="size-4" />
</Button>
</>
) : null}
</Panel>
);
}
function overviewNodeColor(kind: MindmapNodeKind): string {
return svgColors(kind).fill;
}
function MindmapOverview({ layout }: { layout: MindmapLayout }) {
const nodesById = new Map(layout.nodes.map((node) => [node.id, node]));
return (
<Panel position="bottom-right" className="overflow-hidden rounded-lg border bg-background/95 p-1 shadow-md backdrop-blur">
<div className="mb-1 px-1.5 pt-0.5 text-[10px] font-medium text-muted-foreground">全图预览</div>
<svg
className="block h-28 w-44 rounded bg-muted/30"
viewBox={`0 0 ${layout.width} ${layout.height}`}
preserveAspectRatio="xMidYMid meet"
role="img"
aria-label="思维导图全图预览"
>
{layout.edges.map((edge) => {
const from = nodesById.get(edge.from);
const to = nodesById.get(edge.to);
if (!from || !to) return null;
return (
<path
key={edge.id}
d={`M ${from.x + NODE_WIDTH} ${from.y + NODE_HEIGHT / 2} L ${to.x} ${to.y + NODE_HEIGHT / 2}`}
fill="none"
stroke="#64748b"
strokeWidth="4"
strokeOpacity="0.8"
/>
);
})}
{layout.nodes.map((node) => (
<rect
key={node.id}
x={node.x}
y={node.y}
width={NODE_WIDTH}
height={NODE_HEIGHT}
rx="10"
fill={overviewNodeColor(node.kind)}
stroke="#94a3b8"
strokeWidth="2"
/>
))}
</svg>
</Panel>
);
}
function MindmapCanvas({
spec,
preview,
selectedId,
onSelect,
onClose,
}: {
spec: MindmapSpec;
preview: boolean;
selectedId?: string | null;
onSelect?: (id: string) => void;
onClose?: () => void;
}) {
const rf = useReactFlow();
const [collapsedIds, setCollapsedIds] = useState<Set<string>>(() => new Set());
const toggleNode = useCallback((id: string) => {
setCollapsedIds((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const layout = useMemo(
() => layoutMindmap(spec, preview ? PREVIEW_DEPTH : undefined, collapsedIds),
[collapsedIds, preview, spec],
);
const nodes = useMemo<Node[]>(
() =>
layout.nodes.map((node) => ({
id: node.id,
type: "mindmap",
position: { x: node.x, y: node.y },
width: NODE_WIDTH,
height: NODE_HEIGHT,
data: { node, selected: node.id === selectedId, onToggle: toggleNode } satisfies FlowNodeData,
})),
[layout.nodes, selectedId, toggleNode],
);
const edges = useMemo<Edge[]>(
() =>
layout.edges.map((edge) => ({
id: edge.id,
source: edge.from,
target: edge.to,
type: "smoothstep",
style: { stroke: "#64748b", strokeWidth: 2, strokeOpacity: 0.8 },
})),
[layout.edges],
);
const handleNodeClick = useCallback((id: string) => {
const node = layout.nodes.find((item) => item.id === id);
if (node?.hasChildren) {
toggleNode(id);
return;
}
onSelect?.(id);
}, [layout.nodes, onSelect, toggleNode]);
useEffect(() => {
const timer = window.setTimeout(() => {
void rf.fitView({ padding: preview ? 0.16 : 0.12, duration: 180 });
}, 0);
return () => window.clearTimeout(timer);
}, [collapsedIds, preview, rf, spec]);
return (
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={NODE_TYPES}
fitView
fitViewOptions={{ padding: preview ? 0.16 : 0.12 }}
minZoom={0.12}
maxZoom={2}
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable={Boolean(onSelect)}
panOnDrag
panOnScroll={false}
zoomOnScroll
zoomOnDoubleClick
zoomOnPinch
preventScrolling
onNodeClick={(_, node) => handleNodeClick(node.id)}
aria-label={`${spec.title}思维导图`}
proOptions={{ hideAttribution: true }}
>
<Background variant={BackgroundVariant.Dots} gap={18} size={1} color="var(--border)" />
<MindmapCanvasControls compact={preview} onClose={onClose} />
{!preview ? <MindmapOverview layout={layout} /> : null}
</ReactFlow>
);
}
function svgEscape(value: string): string {
return value.replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&apos;" })[char]!);
}
function splitSvgLabel(label: string): string[] {
const chunks = label.match(/.{1,16}/gu) ?? [label];
return chunks.slice(0, 2);
}
function buildMindmapSvg(spec: MindmapSpec): string {
const layout = layoutMindmap(spec);
const offset = 32;
const lines = layout.edges
.map((edge) => {
const from = layout.nodes.find((node) => node.id === edge.from)!;
const to = layout.nodes.find((node) => node.id === edge.to)!;
const x1 = from.x + NODE_WIDTH + offset;
const y1 = from.y + NODE_HEIGHT / 2 + offset;
const x2 = to.x + offset;
const y2 = to.y + NODE_HEIGHT / 2 + offset;
const control = Math.max(42, (x2 - x1) / 2);
return `<path d="M ${x1} ${y1} C ${x1 + control} ${y1}, ${x2 - control} ${y2}, ${x2} ${y2}" fill="none" stroke="#94a3b8" stroke-width="1.5"/>`;
})
.join("");
const nodes = layout.nodes
.map((node) => {
const meta = KIND_META[node.kind];
const { fill, stroke } = svgColors(node.kind);
const lines = splitSvgLabel(node.label)
.map((line, index) => `<tspan x="${node.x + offset + 14}" dy="${index === 0 ? 0 : 18}">${svgEscape(line)}</tspan>`)
.join("");
return [
`<g>`,
`<rect x="${node.x + offset}" y="${node.y + offset}" width="${NODE_WIDTH}" height="${NODE_HEIGHT}" rx="12" fill="${fill}" stroke="${stroke}" stroke-width="1.5"/>`,
`<text x="${node.x + offset + 14}" y="${node.y + offset + 18}" font-family="Microsoft YaHei, Arial, sans-serif" font-size="10" fill="#64748b">${meta.label}</text>`,
`<text x="${node.x + offset + 14}" y="${node.y + offset + 42}" font-family="Microsoft YaHei, Arial, sans-serif" font-size="13" font-weight="600" fill="#0f172a">${lines}</text>`,
`</g>`,
].join("");
})
.join("");
return `<svg xmlns="http://www.w3.org/2000/svg" width="${layout.width + offset}" height="${layout.height + offset}" viewBox="0 0 ${layout.width + offset} ${layout.height + offset}" role="img" aria-label="${svgEscape(spec.title)}"><rect width="100%" height="100%" fill="#ffffff"/><text x="32" y="26" font-family="Microsoft YaHei, Arial, sans-serif" font-size="16" font-weight="700" fill="#0f172a">${svgEscape(spec.title)}</text>${lines}${nodes}</svg>`;
}
function svgColors(kind: MindmapNodeKind): { fill: string; stroke: string } {
switch (kind) {
case "topic": return { fill: "#eff6ff", stroke: "#60a5fa" };
case "dimension": return { fill: "#f0f9ff", stroke: "#38bdf8" };
case "evidence": return { fill: "#ecfdf5", stroke: "#34d399" };
case "risk": return { fill: "#fffbeb", stroke: "#fbbf24" };
case "action": return { fill: "#fff1f2", stroke: "#fb7185" };
default: return { fill: "#f5f3ff", stroke: "#a78bfa" };
}
}
function safeDownloadName(title: string, extension: string): string {
const clean = title.replace(/[\\/:*?"<>|]/g, "-").trim().slice(0, 80) || "分析思维导图";
return `${clean}.${extension}`;
}
function MindmapDownloads({ spec }: { spec: MindmapSpec }) {
const [downloading, setDownloading] = useState<"png" | "svg" | "json" | null>(null);
const [open, setOpen] = useState(false);
const download = useCallback(async (format: "png" | "svg" | "json") => {
setDownloading(format);
try {
if (format === "json") {
triggerBlobDownload(
new Blob([JSON.stringify(spec, null, 2)], { type: "application/json;charset=utf-8" }),
safeDownloadName(spec.title, "mindmap.json"),
);
setOpen(false);
return;
}
const svgMarkup = buildMindmapSvg(spec);
if (format === "svg") {
triggerBlobDownload(
new Blob([svgMarkup], { type: "image/svg+xml;charset=utf-8" }),
safeDownloadName(spec.title, "svg"),
);
setOpen(false);
return;
}
const host = document.createElement("div");
host.style.cssText = "position:fixed;left:-10000px;top:-10000px;pointer-events:none;opacity:0";
host.innerHTML = svgMarkup;
document.body.appendChild(host);
try {
const svg = host.querySelector("svg");
if (!(svg instanceof SVGSVGElement)) throw new Error("无法生成思维导图图片");
const blob = await renderSvgElementToPngBlob(svg);
triggerBlobDownload(blob, safeDownloadName(spec.title, "png"));
setOpen(false);
} finally {
host.remove();
}
} catch (error) {
console.error("Failed to download mindmap", error);
toast.error(error instanceof Error ? error.message : "下载思维导图失败");
} finally {
setDownloading(null);
}
}, [spec]);
return (
<>
<Button
type="button"
variant="outline"
size="icon"
className="size-9"
title="下载思维导图"
aria-label="下载思维导图"
onClick={() => setOpen(true)}
>
<Download aria-hidden="true" className="size-4" />
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-sm p-5">
<DialogHeader className="text-left">
<DialogTitle>下载思维导图</DialogTitle>
<DialogDescription>选择需要导出的文件格式。</DialogDescription>
</DialogHeader>
<div className="grid gap-2">
<Button
type="button"
variant="outline"
className="h-auto min-h-11 justify-start gap-3 px-3 py-2.5 text-left"
onClick={() => void download("png")}
disabled={downloading !== null}
>
<ImageDown aria-hidden="true" className="size-4 shrink-0" />
<span>
<span className="block text-sm font-medium">PNG 图片</span>
<span className="block text-xs font-normal text-muted-foreground">
{downloading === "png" ? "正在生成…" : "便于在文档或图片中使用"}
</span>
</span>
</Button>
<Button
type="button"
variant="outline"
className="h-auto min-h-11 justify-start gap-3 px-3 py-2.5 text-left"
onClick={() => void download("svg")}
disabled={downloading !== null}
>
<Download aria-hidden="true" className="size-4 shrink-0" />
<span>
<span className="block text-sm font-medium">SVG 矢量图</span>
<span className="block text-xs font-normal text-muted-foreground">适合无损缩放和二次编辑</span>
</span>
</Button>
<Button
type="button"
variant="outline"
className="h-auto min-h-11 justify-start gap-3 px-3 py-2.5 text-left"
onClick={() => void download("json")}
disabled={downloading !== null}
>
<FileJson aria-hidden="true" className="size-4 shrink-0" />
<span>
<span className="block text-sm font-medium">JSON 数据</span>
<span className="block text-xs font-normal text-muted-foreground">保留完整节点结构</span>
</span>
</Button>
</div>
</DialogContent>
</Dialog>
</>
);
}
function MindmapDialog({
open,
onOpenChange,
spec,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
spec: MindmapSpec;
}) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
showCloseButton={false}
className="flex h-[calc(100dvh-2rem)] !w-[calc(100vw-2rem)] !max-w-[calc(100vw-2rem)] flex-col gap-0 overflow-hidden rounded-xl p-0 sm:!max-w-[calc(100vw-2rem)]"
>
<DialogTitle className="sr-only">{spec.title}</DialogTitle>
<DialogDescription className="sr-only">
思维导图画布。支持鼠标滚轮缩放、拖动画布,并可使用右上角按钮调整视图。
</DialogDescription>
<div className="relative min-h-0 flex-1 bg-muted/20">
<div className="absolute inset-0">
<ReactFlowProvider>
<MindmapCanvas spec={spec} preview={false} onClose={() => onOpenChange(false)} />
</ReactFlowProvider>
</div>
</div>
</DialogContent>
</Dialog>
);
}
export default function MindmapRenderer({ spec }: { spec: MindmapSpec }) {
const [open, setOpen] = useState(false);
const root = getRoot(spec);
const ariaSummary = spec.nodes.map((node) => `${KIND_META[node.kind].label}:${node.label}`).join(";");
return (
<>
<VisualizationCard className="my-4" >
<section aria-label={`${spec.title}思维导图`}>
<div className="flex flex-wrap items-start justify-between gap-3 border-b px-4 py-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<h2 className="truncate text-sm font-semibold">{spec.title}</h2>
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[11px] font-medium text-primary">思维导图</span>
</div>
<p className="mt-1 line-clamp-2 text-xs leading-5 text-muted-foreground">
{spec.summary || `围绕“${root.label}”整理的 ${spec.nodes.length} 个分析节点。`}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<MindmapDownloads spec={spec} />
<Button type="button" size="sm" className="h-8 gap-1.5" onClick={() => setOpen(true)}>
<Maximize2 aria-hidden="true" className="size-3.5" />
展开查看
</Button>
</div>
</div>
<p className="sr-only">{ariaSummary}</p>
<div className="h-[20rem] bg-muted/15">
<ReactFlowProvider>
<MindmapCanvas
spec={spec}
preview
onSelect={() => {
setOpen(true);
}}
/>
</ReactFlowProvider>
</div>
<div className="flex items-center justify-between gap-3 border-t px-4 py-2 text-xs text-muted-foreground">
<span>预览显示前 {PREVIEW_DEPTH + 1} 层;完整导图可缩放查看。</span>
<span>{spec.nodes.length} 个节点</span>
</div>
</section>
</VisualizationCard>
<MindmapDialog open={open} onOpenChange={setOpen} spec={spec} />
</>
);
}
@@ -0,0 +1,129 @@
import { Component, lazy, Suspense, useMemo, type ErrorInfo, type ReactNode } from "react";
import { AlertTriangle, Loader2 } from "lucide-react";
import {
parseVisualizationSpec,
type MapSpec,
type MindmapSpec,
type VisualizationLanguage,
} from "./visualization-spec";
const MapRenderer = lazy(() => import("./map-renderer"));
const MindmapRenderer = lazy(() => import("./mindmap-renderer"));
type VisualizationBlockProps = {
language: VisualizationLanguage;
source: string;
complete: boolean;
isLoading: boolean;
};
function LoadingCard({ label = "正在生成可视化内容…" }: { label?: string }) {
return (
<div
className="my-4 flex min-h-32 w-full max-w-none items-center justify-center rounded-xl border bg-muted/20 text-muted-foreground"
role="status"
>
<Loader2 aria-hidden="true" className="mr-2 size-4 animate-spin" />
<span className="text-sm">{label}</span>
</div>
);
}
function ErrorCard({
message,
source,
title = "可视化内容暂时无法显示",
}: {
message: string;
source?: string;
title?: string;
}) {
return (
<div
className="my-4 w-full max-w-none rounded-xl border border-destructive/30 bg-destructive/5 p-4"
role="alert"
>
<div className="flex items-start gap-2 text-sm text-destructive">
<AlertTriangle aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
<div>
<div className="font-medium">{title}</div>
<div className="mt-1 text-xs opacity-80">{message}</div>
</div>
</div>
{source ? (
<details className="mt-3 text-xs text-muted-foreground">
<summary className="cursor-pointer select-none">查看原始数据</summary>
<pre className="mt-2 max-h-48 overflow-auto whitespace-pre-wrap rounded-lg bg-muted/50 p-3">
{source}
</pre>
</details>
) : null}
</div>
);
}
class VisualizationErrorBoundary extends Component<
{ children: ReactNode; source: string },
{ error: string | null }
> {
state = { error: null as string | null };
static getDerivedStateFromError(error: unknown) {
return { error: error instanceof Error ? error.message : "可视化渲染失败" };
}
componentDidCatch(error: unknown, info: ErrorInfo) {
console.error("Visualization renderer failed", error, info.componentStack);
}
render() {
if (this.state.error) {
return <ErrorCard message={this.state.error} source={this.props.source} />;
}
return this.props.children;
}
}
export function VisualizationBlock({
language,
source,
complete,
isLoading,
}: VisualizationBlockProps) {
const result = useMemo(
() => parseVisualizationSpec(language, source),
[language, source],
);
if (!result.ok) {
if (isLoading && !complete) {
return <LoadingCard label={language === "mindmap-json" ? "正在整理思维导图…" : undefined} />;
}
return (
<ErrorCard
title={language === "mindmap-json" ? "思维导图暂时无法显示" : "地图内容暂时无法显示"}
message={result.error}
source={source}
/>
);
}
return (
<VisualizationErrorBoundary key={`${language}-${source.length}`} source={source}>
<Suspense
fallback={
<LoadingCard
label={language === "mindmap-json" ? "正在加载思维导图组件…" : "正在加载本地地图组件…"}
/>
}
>
{language === "mindmap-json" ? (
<MindmapRenderer spec={result.spec as MindmapSpec} />
) : (
<MapRenderer spec={result.spec as MapSpec} />
)}
</Suspense>
</VisualizationErrorBoundary>
);
}
@@ -0,0 +1,22 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
export function VisualizationCard({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<section
className={cn(
"my-4 w-full max-w-none overflow-hidden rounded-xl border bg-background shadow-sm",
className,
)}
>
{children}
</section>
);
}
@@ -0,0 +1,183 @@
import assert from "node:assert/strict";
import test from "node:test";
// @ts-expect-error Node's type-stripping test runner requires the explicit .ts suffix.
import { parseVisualizationSpec, splitVisualizationContent } from "./visualization-spec.ts";
test("ordinary markdown remains one unchanged segment", () => {
const content = "普通回答\n\n```ts\nconst value = 1;\n```";
assert.deepEqual(splitVisualizationContent(content), [{ type: "markdown", content }]);
});
test("splits a dedicated map block while keeping surrounding markdown", () => {
const content = [
"下面是地图:\n",
"```map-json",
'{"title":"地点","markers":[{"name":"北京","coordinate":[116.4,39.9]}]}',
"```",
"\n地图结束。",
].join("\n");
const segments = splitVisualizationContent(content);
assert.equal(segments.length, 3);
assert.equal(segments[0]?.type, "markdown");
assert.equal(segments[1]?.type, "visualization");
assert.equal(segments[2]?.type, "markdown");
});
test("accepts the map contract", () => {
const map = parseVisualizationSpec(
"map-json",
JSON.stringify({ markers: [{ name: "北京", coordinate: [116.4, 39.9] }] }),
);
assert.equal(map.ok, true);
});
test("accepts a dedicated mindmap contract", () => {
const result = parseVisualizationSpec(
"mindmap-json",
JSON.stringify({
title: "形势分析",
rootId: "root",
nodes: [
{ id: "root", parentId: null, label: "形势分析", kind: "topic" },
{ id: "risk", parentId: "root", label: "主要风险", kind: "risk" },
{ id: "action", parentId: "root", label: "处置建议", kind: "action" },
],
}),
);
assert.equal(result.ok, true);
assert.equal(result.ok && result.spec.type, "mindmap");
});
test("renders a completed legacy mindmap fence as a mindmap", () => {
const content = [
"```mindmap",
'{"rootId":"root","nodes":[{"id":"root","parentId":null,"label":"主题"}]}',
"```",
].join("\n");
const visual = splitVisualizationContent(content).find((segment) => segment.type === "visualization");
assert.ok(visual && visual.type === "visualization");
assert.equal(visual.language, "mindmap-json");
assert.equal(visual.complete, true);
});
test("rejects mindmaps with a cycle or missing parent", () => {
const cycle = parseVisualizationSpec(
"mindmap-json",
JSON.stringify({
nodes: [
{ id: "root", parentId: null, label: "主题" },
{ id: "a", parentId: "b", label: "A" },
{ id: "b", parentId: "a", label: "B" },
],
}),
);
assert.equal(cycle.ok, false);
const missingParent = parseVisualizationSpec(
"mindmap-json",
JSON.stringify({
nodes: [
{ id: "root", parentId: null, label: "主题" },
{ id: "a", parentId: "missing", label: "A" },
],
}),
);
assert.equal(missingParent.ok, false);
});
test("rejects dense mindmaps before they can overload the canvas", () => {
const tooManyChildren = parseVisualizationSpec(
"mindmap-json",
JSON.stringify({
nodes: [
{ id: "root", parentId: null, label: "主题" },
{ id: "a", parentId: "root", label: "维度 A" },
{ id: "b", parentId: "root", label: "维度 B" },
{ id: "c", parentId: "root", label: "维度 C" },
{ id: "d", parentId: "root", label: "维度 D" },
{ id: "e", parentId: "root", label: "维度 E" },
{ id: "f", parentId: "root", label: "维度 F" },
{ id: "g", parentId: "root", label: "维度 G" },
{ id: "h", parentId: "root", label: "维度 H" },
],
}),
);
assert.equal(tooManyChildren.ok, false);
const tooManyNodes = parseVisualizationSpec(
"mindmap-json",
JSON.stringify({
nodes: [
{ id: "root", parentId: null, label: "主题" },
...Array.from({ length: 36 }, (_, index) => ({
id: `node-${index}`,
parentId: "root",
label: `节点 ${index + 1}`,
})),
],
}),
);
assert.equal(tooManyNodes.ok, false);
assert.equal(
tooManyNodes.ok ? "" : tooManyNodes.error,
"思维导图节点过多(37 个);请将同类信息合并后控制在 36 个以内",
);
});
test("renders a valid map contract from a generic json fence", () => {
const content = [
"下面是地图:",
"```json",
'{"title":"城市分布","markers":[{"name":"北京","coordinate":[116.4,39.9]}]}',
"```",
].join("\n");
const segments = splitVisualizationContent(content);
const visual = segments.find((segment) => segment.type === "visualization");
assert.ok(visual && visual.type === "visualization");
assert.equal(visual.language, "map-json");
});
test("keeps non-map json fences on the ordinary Markdown path", () => {
const content = '```json\n{"name":"普通数据","items":[1,2,3]}\n```';
assert.deepEqual(splitVisualizationContent(content), [{ type: "markdown", content }]);
});
test("keeps generic mindmap-shaped JSON on the ordinary Markdown path", () => {
const content = [
"```json",
'{"type":"mindmap","nodes":[{"id":"root","parentId":null,"label":"主题"}]}',
"```",
].join("\n");
assert.deepEqual(splitVisualizationContent(content), [{ type: "markdown", content }]);
});
test("keeps unfinished generic json fences on the ordinary Markdown path", () => {
const content = '```json\n{"markers":[';
assert.deepEqual(splitVisualizationContent(content), [{ type: "markdown", content }]);
});
test("rejects invalid map coordinates", () => {
const result = parseVisualizationSpec(
"map-json",
JSON.stringify({ markers: [{ name: "错误地点", coordinate: [200, 100] }] }),
);
assert.equal(result.ok, false);
});
test("flow and chart fences stay on the ordinary Markdown path", () => {
const content = "```flow-json\n{}\n```\n```chart-json\n{}\n```";
assert.deepEqual(splitVisualizationContent(content), [{ type: "markdown", content }]);
});
test("keeps an unfinished map fence for streaming fallback", () => {
const segments = splitVisualizationContent("说明\n```map-json\n{\"title\":\"地点\"");
const visual = segments.find((segment) => segment.type === "visualization");
assert.ok(visual && visual.type === "visualization");
assert.equal(visual.complete, false);
});
test("shows unfinished dedicated mindmap JSON before mounting the renderer", () => {
const content = "说明\n```mindmap-json\n{\"title\":\"分析导图\"";
assert.deepEqual(splitVisualizationContent(content), [{ type: "markdown", content }]);
});
@@ -0,0 +1,271 @@
import { z } from "zod";
export const VISUALIZATION_LANGUAGES = ["map-json", "mindmap-json"] as const;
export type VisualizationLanguage = (typeof VISUALIZATION_LANGUAGES)[number];
const safeText = z.string().trim().min(1).max(500);
const optionalText = z.string().trim().max(2_000).optional();
const longitude = z.number().finite().min(-180).max(180);
const latitude = z.number().finite().min(-90).max(90);
const coordinateSchema = z.tuple([longitude, latitude]);
const mapSchema = z.object({
type: z.literal("map").default("map"),
title: z.string().trim().max(200).default("地图"),
center: coordinateSchema.optional(),
zoom: z.number().finite().min(1).max(20).optional(),
markers: z
.array(
z.object({
name: safeText,
coordinate: coordinateSchema,
description: optionalText,
}),
)
.max(500)
.default([]),
lines: z
.array(
z.object({
name: z.string().trim().max(120).optional(),
coordinates: z.array(coordinateSchema).min(2).max(2_000),
}),
)
.max(100)
.default([]),
polygons: z
.array(
z.object({
name: z.string().trim().max(120).optional(),
coordinates: z.array(coordinateSchema).min(3).max(2_000),
}),
)
.max(100)
.default([]),
});
export type MapSpec = z.infer<typeof mapSchema>;
export const MINDMAP_NODE_KINDS = [
"topic",
"dimension",
"finding",
"evidence",
"risk",
"action",
] as const;
export type MindmapNodeKind = (typeof MINDMAP_NODE_KINDS)[number];
const mindmapNodeSchema = z.object({
id: z.string().trim().min(1).max(80),
parentId: z.string().trim().min(1).max(80).nullable(),
label: z.string().trim().min(1).max(120),
kind: z.enum(MINDMAP_NODE_KINDS).default("finding"),
detail: z.string().trim().max(1_200).optional(),
sourceRefs: z.array(z.string().trim().min(1).max(160)).max(12).optional(),
});
const mindmapSchema = z.object({
type: z.literal("mindmap").default("mindmap"),
title: z.string().trim().max(200).default("分析思维导图"),
summary: z.string().trim().max(800).optional(),
rootId: z.string().trim().min(1).max(80).optional(),
// A mind map is an overview, not a transcription of every paragraph in a
// report. Keeping this small is also important because every node becomes a
// rendered React Flow element in the message list and full-screen canvas.
nodes: z.array(mindmapNodeSchema).min(1).max(36),
});
export type MindmapSpec = z.infer<typeof mindmapSchema>;
type MindmapNode = MindmapSpec["nodes"][number];
export type VisualizationSpec = MapSpec | MindmapSpec;
export type VisualizationParseResult =
| { ok: true; spec: VisualizationSpec }
| { ok: false; error: string };
function normalizeLanguageType(
language: VisualizationLanguage,
value: unknown,
): unknown {
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
const record = { ...(value as Record<string, unknown>) };
if (language === "map-json") record.type = "map";
if (language === "mindmap-json") record.type = "mindmap";
return record;
}
function formatZodError(error: z.ZodError): string {
const issue = error.issues[0];
if (!issue) return "可视化数据格式不正确";
const path = issue.path.length > 0 ? `${issue.path.join(".")}:` : "";
return `${path}${issue.message}`;
}
function validateMindmap(spec: MindmapSpec): string | null {
const nodesById = new Map<string, MindmapSpec["nodes"][number]>();
for (const node of spec.nodes) {
if (nodesById.has(node.id)) return `节点 id 重复:${node.id}`;
nodesById.set(node.id, node);
}
const roots = spec.nodes.filter((node) => node.parentId === null);
if (roots.length !== 1) return "思维导图必须且只能有一个根节点";
const root = roots[0]!;
if (spec.rootId && spec.rootId !== root.id) return "rootId 必须指向唯一的根节点";
for (const node of spec.nodes) {
if (node.parentId === null) continue;
if (node.parentId === node.id) return `节点不能以自身为父节点:${node.id}`;
if (!nodesById.has(node.parentId)) return `节点 ${node.id} 的父节点不存在:${node.parentId}`;
}
const children = new Map<string, MindmapNode[]>();
for (const node of spec.nodes) {
if (node.parentId === null) continue;
const siblings = children.get(node.parentId) ?? [];
siblings.push(node);
children.set(node.parentId, siblings);
}
for (const [parentId, siblings] of children) {
const limit = parentId === root.id ? 6 : 3;
if (siblings.length > limit) {
const parentLabel = nodesById.get(parentId)?.label ?? parentId;
return parentId === root.id
? `中心主题最多保留 6 个一级维度,当前“${parentLabel}”下有 ${siblings.length} 个`
: `节点“${parentLabel}”最多保留 3 个子节点,当前有 ${siblings.length} 个;请合并同类信息`;
}
}
for (const node of spec.nodes) {
let depth = 0;
let cursor: MindmapSpec["nodes"][number] | undefined = node;
const visited = new Set<string>();
while (cursor && cursor.parentId !== null) {
if (visited.has(cursor.id)) return "思维导图不能包含循环父子关系";
visited.add(cursor.id);
cursor = nodesById.get(cursor.parentId);
depth += 1;
if (depth > 3) return "思维导图最多支持 4 层层级";
}
}
return null;
}
function mindmapShapeError(value: unknown): string | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const nodes = (value as Record<string, unknown>).nodes;
if (Array.isArray(nodes) && nodes.length > 36) {
return `思维导图节点过多(${nodes.length} 个);请将同类信息合并后控制在 36 个以内`;
}
return null;
}
export function parseVisualizationSpec(
language: VisualizationLanguage,
source: string,
): VisualizationParseResult {
let raw: unknown;
try {
raw = JSON.parse(source);
} catch {
return { ok: false, error: "地图数据尚未完整或不是有效 JSON" };
}
const normalized = normalizeLanguageType(language, raw);
if (!normalized || typeof normalized !== "object" || Array.isArray(normalized)) {
return { ok: false, error: "地图数据必须是 JSON 对象" };
}
if (language === "mindmap-json") {
const error = mindmapShapeError(normalized);
if (error) return { ok: false, error };
}
const schema = language === "map-json" ? mapSchema : mindmapSchema;
const parsed = schema.safeParse(normalized);
if (!parsed.success) return { ok: false, error: formatZodError(parsed.error) };
if (language === "mindmap-json") {
const error = validateMindmap(parsed.data as MindmapSpec);
if (error) return { ok: false, error };
}
return { ok: true, spec: parsed.data };
}
export type VisualizationSegment =
| { type: "markdown"; content: string }
| {
type: "visualization";
language: VisualizationLanguage;
source: string;
complete: boolean;
};
// `mindmap-json` is the contract. `mindmap` is retained as a compatibility
// alias because existing model turns have already emitted that fence name.
const FENCE_PATTERN = /(^|\n)```(map-json|mindmap-json|mindmap|json)[ \t]*\r?\n/gi;
function isMapJsonSource(source: string): boolean {
let raw: unknown;
try {
raw = JSON.parse(source);
} catch {
return false;
}
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return false;
const record = raw as Record<string, unknown>;
const hasMapField = ["center", "zoom", "markers", "lines", "polygons"].some(
(field) => Object.prototype.hasOwnProperty.call(record, field),
);
return hasMapField && parseVisualizationSpec("map-json", source).ok;
}
/** Split only our dedicated fences. With no match, the caller keeps the exact legacy renderer path. */
export function splitVisualizationContent(content: string): VisualizationSegment[] {
const segments: VisualizationSegment[] = [];
let cursor = 0;
FENCE_PATTERN.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = FENCE_PATTERN.exec(content)) !== null) {
const blockStart = match.index + match[1].length;
if (blockStart > cursor) {
segments.push({ type: "markdown", content: content.slice(cursor, blockStart) });
}
const sourceStart = FENCE_PATTERN.lastIndex;
const closeIndex = content.indexOf("```", sourceStart);
const complete = closeIndex >= 0;
const source = content.slice(sourceStart, complete ? closeIndex : content.length).trim();
const matchedLanguage = match[2].toLowerCase();
const isMindmapFence = matchedLanguage === "mindmap-json" || matchedLanguage === "mindmap";
// Dedicated mind-map JSON is shown as an ordinary streaming code block
// until the closing fence arrives. That lets people see the model writing
// and validate the raw structure before the expensive renderer mounts.
if (isMindmapFence && !complete) {
return [{ type: "markdown", content }];
}
if (matchedLanguage === "json" && (!complete || !isMapJsonSource(source))) {
if (!complete) break;
FENCE_PATTERN.lastIndex = closeIndex + 3;
continue;
}
segments.push({
type: "visualization",
language: isMindmapFence ? "mindmap-json" : "map-json",
source,
complete,
});
cursor = complete ? closeIndex + 3 : content.length;
FENCE_PATTERN.lastIndex = cursor;
if (!complete) break;
}
if (cursor < content.length) {
segments.push({ type: "markdown", content: content.slice(cursor) });
}
return segments.length > 0 ? segments : [{ type: "markdown", content }];
}
@@ -24,7 +24,10 @@ import {
applyCompactMenuOverrides,
type CompactMenuItem,
} from "@/core/page-layout/compact-menu-overrides";
import { type MenuItem } from "@/core/page-layout/sidebar-menu";
import {
MARKDOWN_DOCX_MENU_ITEM,
type MenuItem,
} from "@/core/page-layout/sidebar-menu";
import { useCompactMenuOverrides } from "@/strategy-components/api/compact-menu-overrides";
// 普通导航项:默认中性色,激活态与「新对话」一致——主色浅底 + 主色文字/图标
@@ -95,13 +98,6 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
path: "/page/workspace/skills",
match: (pathname) => pathname.startsWith("/page/workspace/skills"),
},
{
id: "workspace-memory",
label: "记忆",
icon: BrainIcon,
path: "/page/workspace/memory",
match: (pathname) => pathname.startsWith("/page/workspace/memory"),
},
{
id: "workspace-ai-writing",
label: "AI写作",
@@ -149,7 +145,13 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
label: "知识库管理",
icon: BookOpen,
path: "/page/workspace/knowledge",
match: (pathname) => pathname.startsWith("/page/workspace/knowledge"),
match: (pathname) =>
pathname.startsWith("/page/workspace/knowledge") &&
!pathname.startsWith(MARKDOWN_DOCX_MENU_ITEM.path),
},
{
...MARKDOWN_DOCX_MENU_ITEM,
match: (pathname) => pathname.startsWith(MARKDOWN_DOCX_MENU_ITEM.path),
},
];
@@ -160,7 +162,8 @@ export function useWorkspaceCompactNavItems(): {
settingsItems: EffectiveWorkspaceCompactNavItem[];
toolboxItems: EffectiveWorkspaceCompactNavItem[];
} {
const { data: overrides = [] } = useCompactMenuOverrides();
const { data: overrideConfig } = useCompactMenuOverrides();
const overrides = overrideConfig?.overrides ?? [];
return useMemo(() => {
const applied = applyCompactMenuOverrides(WORKSPACE_COMPACT_NAV_ITEMS, overrides);
return {
@@ -223,7 +223,7 @@ export function WorkspaceNavMenu() {
{isAdmin ? (
<DropdownMenuItem
onClick={() =>
navigate("/page/strategy/admin/user-leaderboard")
navigate("/page/workspace/admin/user-leaderboard")
}
>
<TrophyIcon />
@@ -273,16 +273,15 @@ export function WorkspaceNavMenu() {
模型配置
</DropdownMenuItem>
)}
{/* 与全量模式左下角「设置和更多」下拉里的菜单管理同入口(管理员):
简洁模式没有全局侧边栏,这里放一份保证两种模式都能进菜单管理。 */}
{isAdmin && (
{/* 与全量模式左下角「设置和更多」下拉里的菜单管理同入口:
简洁模式没有全局侧边栏,走工作区地址带上 WorkspaceSidebar;
/page/strategy/admin/menu-management 保留给全量模式侧边栏。 */}
<DropdownMenuItem
onClick={() => navigate("/page/strategy/admin/menu-management")}
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 />
@@ -21,11 +21,14 @@ import { WorkspaceNavMenu } from "./workspace-nav-menu";
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();
@@ -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 />
@@ -0,0 +1,79 @@
import assert from "node:assert/strict";
import test from "node:test";
import { mergeConversationAgentOptions } from "./conversation-options";
import type { Agent } from "./types";
function agent(
id: string,
options: Partial<Agent> = {},
): Agent {
return {
id,
name: options.name ?? id,
description: "",
user_id: options.user_id ?? null,
published: options.published ?? true,
is_private: options.is_private ?? false,
builtin: options.builtin ?? options.user_id == null,
...options,
};
}
test("keeps curated built-ins and appends every visible custom agent", () => {
const featured = [
agent("featured-builtin", { builtin: true }),
agent("featured-custom", { builtin: false, user_id: "owner-1" }),
];
const visible = [
agent("internal-worker", { builtin: true }),
agent("featured-custom", { builtin: false, user_id: "owner-1" }),
agent("owned-private", {
builtin: false,
user_id: "owner-1",
published: false,
}),
agent("published-custom", {
builtin: false,
user_id: "owner-2",
}),
];
assert.deepEqual(
mergeConversationAgentOptions(featured, visible).map(({ id }) => id),
[
"featured-builtin",
"featured-custom",
"owned-private",
"published-custom",
],
);
});
test("deduplicates ids and ignores malformed unavailable rows", () => {
const duplicate = agent("agent-1", {
name: "精选名称",
builtin: false,
user_id: "owner-1",
});
const result = mergeConversationAgentOptions(
[duplicate],
[
agent("agent-1", {
name: "列表名称",
builtin: false,
user_id: "owner-1",
}),
agent("", { builtin: false, user_id: "owner-1" }),
agent("missing-name", {
name: " ",
builtin: false,
user_id: "owner-1",
}),
],
);
assert.equal(result.length, 1);
assert.equal(result[0]?.name, "精选名称");
});
@@ -0,0 +1,34 @@
import type { Agent } from "./types";
/**
* Build the agent list used by ordinary conversation entry points.
*
* The homepage selector is intentionally admin-curated and therefore may
* contain only one or two featured agents. Project conversations need a
* wider catalogue: every custom agent visible to the current user should be
* selectable as well. Unfeatured built-ins are omitted because that bucket
* also contains internal workflow workers (AI writing, roundtable, strategy
* selection, and so on) that are not standalone conversation agents.
*/
export function mergeConversationAgentOptions(
featuredAgents: Agent[],
visibleAgents: Agent[],
): Agent[] {
const result: Agent[] = [];
const seen = new Set<string>();
const append = (agent: Agent) => {
const id = agent.id?.trim();
const name = agent.name?.trim();
if (!id || !name || seen.has(id)) return;
seen.add(id);
result.push(agent);
};
// Preserve the administrator's ordering for curated built-ins/custom
// agents, then append the rest of the user's visible custom catalogue.
featuredAgents.forEach(append);
visibleAgents.filter((agent) => !agent.builtin).forEach(append);
return result;
}
+18
View File
@@ -32,6 +32,7 @@ import type {
PaginatedAgents,
UpdateAgentRequest,
} from "./types";
import { mergeConversationAgentOptions } from "./conversation-options";
/** A cached agent listing is either a plain ``Agent[]`` (legacy ``useAgents``)
* or the paginated ``{ agents, total, ... }`` envelope. Mutators patch both. */
@@ -255,6 +256,23 @@ export function useAgentSelector() {
};
}
/**
* Agent catalogue for project/ordinary conversations.
*
* It combines the admin-curated homepage list with every custom agent the
* current user can access. This deliberately does not expose unfeatured
* built-in workflow workers as standalone chat agents.
*/
export function useConversationAgentOptions() {
const featured = useAgentSelector();
const visible = useAgents();
return {
agents: mergeConversationAgentOptions(featured.agents, visible.agents),
isLoading: featured.isLoading || visible.isLoading,
error: featured.error ?? visible.error,
};
}
function invalidateAgentQueries(queryClient: ReturnType<typeof useQueryClient>) {
void queryClient.invalidateQueries({ queryKey: ["agents"] });
}
@@ -0,0 +1,140 @@
import { useQuery } from "@tanstack/react-query";
import { apiFetch } from "@/core/api";
import { getStoredAuth } from "@/core/auth";
import { getBackendBaseURL } from "@/core/config";
export type ProjectMode = "qa" | "strategy" | "solution";
export type ProjectReportConfig = {
custom_outline: string;
structure_mode: "fixed" | "adaptive";
retrieval_directions: string[];
retrieval_skills: string[];
};
export type ProjectStrategyConfig = { lenses: string[] };
export const DEFAULT_STRATEGY_LENSES = [
"增长视角",
"风险视角",
"竞争视角",
"资源视角",
"长期价值视角",
] as const;
export type ProjectFields = {
name: string;
background: string;
knowledge_base_ids: string[];
assistant_knowledge_base_ids: string[];
agent_id: string | null;
report_config: ProjectReportConfig;
strategy_config: ProjectStrategyConfig;
};
export type AnalysisProject = ProjectFields & {
id: string;
mode: ProjectMode;
created_at: string;
updated_at: string;
solution_template: { id: string; name: string; outline: string } | null;
};
export type ProjectThread = {
thread_id: string;
title?: string;
status: string;
updated_at: string;
metadata?: Record<string, unknown>;
};
export const PROJECT_MODES = {
qa: { name: "问答模式", description: "围绕项目资料问答、解释与总结" },
strategy: {
name: "策略智能模式",
description: "选择分析视角,开展多视角推演、策略辩论与综合决策",
},
solution: {
name: "方案生成模式",
description: "选择报告结构,沿用研究管线生成方案",
},
} as const;
export const PROJECT_OUTLINE = [
"背景与依据",
"目标与约束",
"实施路径与重点任务",
"资源配置与时间计划",
"风险与应对措施",
"评估指标与参考依据",
];
export const projectKeys = () =>
["analysis-projects", getStoredAuth()?.user_id ?? "default"] as const;
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/analysis-projects${path}`,
{
...init,
headers: { "Content-Type": "application/json", ...init?.headers },
},
);
if (!response.ok) {
const error = (await response.json().catch(() => ({}))) as {
detail?: unknown;
};
throw new Error(
typeof error.detail === "string"
? error.detail
: `项目操作失败(${response.status})`,
);
}
return response.json() as Promise<T>;
}
export function useAnalysisProjects(
options: { enabled?: boolean } = {},
) {
return useQuery({
queryKey: projectKeys(),
queryFn: () => request<{ projects: AnalysisProject[] }>(""),
enabled: options.enabled ?? true,
});
}
export function useAnalysisProject(id: string) {
return useQuery({
queryKey: [...projectKeys(), id],
queryFn: () => request<AnalysisProject>(`/${encodeURIComponent(id)}`),
enabled: !!id,
});
}
export function useProjectThreads(id: string) {
return useQuery({
queryKey: [...projectKeys(), id, "threads"],
queryFn: () =>
request<{ threads: ProjectThread[] }>(
`/${encodeURIComponent(id)}/threads`,
),
enabled: !!id,
refetchInterval: 15000,
});
}
export function useProjectThreadAccess(projectId: string, threadId?: string) {
return useQuery({
queryKey: [...projectKeys(), projectId, "thread", threadId],
queryFn: () =>
request<ProjectThread>(
`/${encodeURIComponent(projectId)}/threads/${encodeURIComponent(threadId!)}`,
),
enabled: !!threadId,
retry: false,
});
}
export const createAnalysisProject = (
fields: ProjectFields & { mode: ProjectMode },
) =>
request<AnalysisProject>("", {
method: "POST",
body: JSON.stringify(fields),
});
export const updateAnalysisProject = (id: string, fields: ProjectFields) =>
request<AnalysisProject>(`/${encodeURIComponent(id)}`, {
method: "PUT",
body: JSON.stringify(fields),
});
export const createProjectThread = (id: string, agentId?: string | null) =>
request<ProjectThread>(`/${encodeURIComponent(id)}/threads`, {
method: "POST",
body: JSON.stringify(agentId === undefined ? {} : { agent_id: agentId }),
});
@@ -0,0 +1,141 @@
import type { Message } from "@langchain/langgraph-sdk";
export const STRATEGY_COLUMNS = [
"意图理解",
"多视角策略分析",
"完整策略方案",
] as const;
const STRATEGY_HEADING_INDEX: Record<string, number> = {
意图理解: 0,
意图理解与分析角度: 0,
多视角策略分析: 1,
推荐策略: 1,
完整策略方案: 2,
综合结果: 2,
结论: 2,
};
/** Parse only user-facing text. Never render tool payloads or reasoning fields. */
export function splitStrategySections(text: string): [string, string, string] {
const parts: string[][] = [[], [], []];
let section = 0;
let fence = false;
for (const line of text.replace(/\r\n/g, "\n").split("\n")) {
if (/^\s*```/.test(line)) fence = !fence;
const heading =
!fence &&
line.match(
/^\s*#{1,2}\s+(?:[一二三123][、..]\s*)?(意图理解与分析角度|意图理解|多视角策略分析|推荐策略|完整策略方案|综合结果|结论)\s*$/,
);
if (heading) {
section = STRATEGY_HEADING_INDEX[heading[1]!] ?? section;
continue;
}
parts[section]!.push(line);
}
return parts.map((lines) => lines.join("\n").trim()) as [
string,
string,
string,
];
}
export type StrategySubsection = { title: string; content: string };
export type StrategyClarification = {
question: string;
context?: string;
options: { id: string; label: string; description?: string }[];
allow_custom: boolean;
};
export function pendingStrategyClarification(
messages: Message[],
): StrategyClarification | null {
let pending: StrategyClarification | null = null;
for (const message of messages) {
const raw = message.additional_kwargs?.clarification;
if (raw && typeof raw === "object") {
const data = raw as Record<string, unknown>;
const question = typeof data.question === "string" ? data.question : "";
if (!question) continue;
pending = {
question,
context: typeof data.context === "string" ? data.context : undefined,
options: Array.isArray(data.options)
? data.options.flatMap((item) => {
if (!item || typeof item !== "object") return [];
const option = item as Record<string, unknown>;
if (typeof option.label !== "string") return [];
return [{
id: typeof option.id === "string" ? option.id : option.label,
label: option.label,
description:
typeof option.description === "string"
? option.description
: undefined,
}];
})
: [],
allow_custom: data.allow_custom !== false,
};
} else if (pending && message.type === "human") {
pending = null;
}
}
return pending;
}
export function splitStrategySubsections(text: string): StrategySubsection[] {
const sections: StrategySubsection[] = [];
let current: StrategySubsection | null = null;
let fence = false;
for (const line of text.replace(/\r\n/g, "\n").split("\n")) {
if (/^\s*```/.test(line)) fence = !fence;
const heading = !fence && line.match(/^\s*###\s+(.+?)\s*$/);
if (heading) {
current = { title: heading[1]!, content: "" };
sections.push(current);
} else if (current) {
current.content += `${current.content ? "\n" : ""}${line}`;
}
}
return sections.map((item) => ({ ...item, content: item.content.trim() }));
}
export function strategyRounds(messages: Message[]) {
const rounds: { id: string; question: string; answer: string }[] = [];
for (const message of messages) {
if (
message.additional_kwargs?.hide_from_ui ||
!["human", "ai"].includes(message.type)
)
continue;
const text =
typeof message.content === "string"
? message.content
: message.content
.filter(
(item): item is Extract<typeof item, { type: "text" }> =>
item.type === "text" &&
"text" in item &&
typeof item.text === "string",
)
.map((item) => item.text)
.join("\n");
if (message.type === "human") {
if (text.trim())
rounds.push({
id: message.id || `round-${rounds.length}`,
question: text,
answer: "",
});
} else if (text.trim()) {
if (!rounds.length)
rounds.push({
id: message.id || "round-0",
question: "策略分析",
answer: "",
});
rounds[rounds.length - 1]!.answer +=
`${rounds[rounds.length - 1]!.answer ? "\n\n" : ""}${text}`;
}
}
return rounds;
}
+73 -3
View File
@@ -1,5 +1,7 @@
import { apiFetch } from "../api";
import { getBackendBaseURL } from "../config";
import { downloadMarkdownAsDocx } from "../scheduled-tasks/markdown-docx";
import { prepareGeneratedHtml } from "../utils/legacy-html-preview";
import { getFileName } from "../utils/files";
import { urlOfArtifact } from "./utils";
@@ -60,12 +62,23 @@ export async function downloadArtifactFile({
const filename =
filenameFromContentDisposition(cd) ?? getFileName(filepath);
const blob = await res.blob();
const contentType = res.headers.get("Content-Type")?.split(";")[0]?.trim();
const downloadBlob =
const isHtml = contentType === "text/html" || /\.html?$/i.test(filename);
let downloadBlob: Blob;
if (isHtml) {
downloadBlob = new Blob(
[
prepareGeneratedHtml(await res.text()),
],
{ type: "text/html;charset=utf-8" },
);
} else {
const blob = await res.blob();
downloadBlob =
contentType && blob.type !== contentType
? new Blob([await blob.arrayBuffer()], { type: contentType })
: blob;
}
const objectUrl = URL.createObjectURL(downloadBlob);
const a = document.createElement("a");
a.href = objectUrl;
@@ -95,6 +108,54 @@ export async function downloadArtifactMarkdownAsWord({
await downloadMarkdownAsDocx(await res.text(), fileName, title);
}
async function fetchStrategyProjectMarkdown(projectId: string): Promise<Response> {
const res = await apiFetch(
`${getBackendBaseURL()}/api/strategy-project-artifacts/${encodeURIComponent(projectId)}/download`,
);
if (!res.ok) {
throw new Error(await errorMessageFromResponse(res));
}
return res;
}
export async function downloadStrategyProjectMarkdown({
projectId,
}: {
projectId: string;
}): Promise<void> {
const res = await fetchStrategyProjectMarkdown(projectId);
const filename =
filenameFromContentDisposition(res.headers.get("Content-Disposition")) ??
"策略优选方案.md";
const blob = await res.blob();
const objectUrl = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = objectUrl;
anchor.download = filename;
anchor.rel = "noopener";
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 30_000);
}
export async function downloadStrategyProjectMarkdownAsWord({
projectId,
}: {
projectId: string;
}): Promise<void> {
const res = await fetchStrategyProjectMarkdown(projectId);
const markdownName =
filenameFromContentDisposition(res.headers.get("Content-Disposition")) ??
"策略优选方案.md";
const title = markdownName.replace(/\.(md|markdown)$/i, "");
await downloadMarkdownAsDocx(
await res.text(),
title || "策略优选方案",
title || "策略优选方案",
);
}
export async function openArtifactInNewTab({
filepath,
threadId,
@@ -111,7 +172,16 @@ export async function openArtifactInNewTab({
if (!res.ok) {
throw new Error(await errorMessageFromResponse(res));
}
const blob = await res.blob();
const contentType = res.headers.get("Content-Type")?.split(";")[0]?.trim();
const isHtml = contentType === "text/html" || /\.html?$/i.test(filepath);
const blob = isHtml
? new Blob(
[
prepareGeneratedHtml(await res.text()),
],
{ type: "text/html;charset=utf-8" },
)
: await res.blob();
const objectUrl = URL.createObjectURL(blob);
const w = window.open(objectUrl, "_blank", "noopener,noreferrer");
if (w) w.opener = null;
+19 -2
View File
@@ -13,7 +13,11 @@ import {
isSandboxStreamPerfEnabled,
} from "./sandbox-stream-perf";
import { isArtifactApiPath } from "./streaming-write-preview";
import { isWriteFileInFlight, isWriteFileStreamingUrl } from "./write-file-in-flight";
import {
isWriteFileInFlight,
isWriteFileStreamingUrl,
latestCompletedArtifactMutationKey,
} from "./write-file-in-flight";
import { updateArtifactContent } from "./api";
function hasToolResult(
@@ -135,6 +139,12 @@ export function useArtifactContent({
: undefined;
const writeFileInFlight =
useWriteFileStreaming && isWriteFileInFlight(thread, filepath);
const completedArtifactMutationKey = useMemo(() => {
if (useWriteFileStreaming || !isArtifactApiPath(filepath)) {
return null;
}
return latestCompletedArtifactMutationKey(thread, filepath);
}, [filepath, thread, useWriteFileStreaming]);
const displayedWriteFileContent = useThrottledWriteFileDisplay({
filepath,
liveContent: liveWriteFileContent,
@@ -150,7 +160,14 @@ export function useArtifactContent({
}, [filepath, thread, useWriteFileStreaming]);
const { data, isLoading, error, refetch } = useQuery({
queryKey: ["artifact", filepath, threadId, isMock, publicShareCode],
queryKey: [
"artifact",
filepath,
threadId,
isMock,
publicShareCode,
completedArtifactMutationKey,
],
queryFn: () => {
return loadArtifactContent({ filepath, threadId, isMock, publicShareCode });
},
+21 -1
View File
@@ -9,7 +9,15 @@ export type ArtifactLibraryKind =
| "archive"
| "other";
export type ArtifactLibrarySource = "all" | "general_qa" | "agent";
export type ArtifactLibrarySource =
| "all"
| "general_qa"
| "agent"
| "strategy";
export type ArtifactStrategySourceKind =
| "strategy_selection"
| "analysis_project";
export interface ArtifactLibraryItem {
threadId: string;
@@ -18,6 +26,10 @@ export interface ArtifactLibraryItem {
agentName: string | null;
threadType: string | null;
sourceType: Exclude<ArtifactLibrarySource, "all">;
strategyProjectId: string | null;
strategyName: string | null;
strategySourceKind: ArtifactStrategySourceKind | null;
strategyStale: boolean;
path: string;
name: string;
sizeBytes: number;
@@ -82,6 +94,10 @@ export async function fetchArtifactLibrary({
agent_name?: string | null;
thread_type?: string | null;
source_type?: Exclude<ArtifactLibrarySource, "all">;
strategy_project_id?: string | null;
strategy_name?: string | null;
strategy_source_kind?: ArtifactStrategySourceKind | null;
strategy_stale?: boolean;
path: string;
name: string;
size_bytes: number;
@@ -102,6 +118,10 @@ export async function fetchArtifactLibrary({
agentName: item.agent_name ?? item.agent_id ?? null,
threadType: item.thread_type ?? null,
sourceType: item.source_type ?? (item.agent_id ? "agent" : "general_qa"),
strategyProjectId: item.strategy_project_id ?? null,
strategyName: item.strategy_name ?? null,
strategySourceKind: item.strategy_source_kind ?? null,
strategyStale: item.strategy_stale === true,
path: item.path,
name: item.name,
sizeBytes: item.size_bytes,
@@ -3,10 +3,142 @@ import test from "node:test";
import {
isWriteFileInFlight,
latestCompletedArtifactMutationKey,
latestInFlightWriteFilePreview,
latestPreparedHtmlArtifact,
resolveWriteFilePath,
} from "./write-file-in-flight";
test("latestPreparedHtmlArtifact opens the output created by template prepare", () => {
const preview = latestPreparedHtmlArtifact([
{
id: "m1",
type: "ai",
tool_calls: [
{
id: "tpl1",
name: "page_report_template",
args: { action: "prepare" },
},
],
},
{
type: "tool",
tool_call_id: "tpl1",
content: JSON.stringify({
ok: true,
working_copy: "/mnt/user-data/outputs/人工智能发展报告.html",
}),
},
]);
assert.deepEqual(preview, {
artifactPath: "/mnt/user-data/outputs/人工智能发展报告.html",
previewKey: "page-template:tpl1",
});
});
test("latestPreparedHtmlArtifact ignores workspace and failed prepares", () => {
assert.equal(
latestPreparedHtmlArtifact([
{
id: "m1",
type: "ai",
tool_calls: [
{
id: "tpl1",
name: "page_report_template",
args: { action: "prepare" },
},
],
},
{
type: "tool",
tool_call_id: "tpl1",
content: JSON.stringify({
ok: true,
working_copy: "/mnt/user-data/workspace/draft.html",
}),
},
]),
null,
);
});
test("latestPreparedHtmlArtifact yields to a later final HTML write", () => {
const preview = latestPreparedHtmlArtifact([
{
id: "m-template",
type: "ai",
tool_calls: [
{ id: "tpl1", name: "page_report_template", args: { action: "prepare" } },
],
},
{
type: "tool",
tool_call_id: "tpl1",
content: JSON.stringify({
ok: true,
working_copy: "/mnt/user-data/outputs/report-page.html",
}),
},
{
id: "m-final",
type: "ai",
tool_calls: [
{
id: "write1",
name: "write_file",
args: {
path: "/mnt/user-data/outputs/美国服务网络大屏.html",
content: "<!doctype html><html></html>",
},
},
],
},
]);
assert.equal(preview, null);
});
test("latestCompletedArtifactMutationKey revisions completed writes and replacements", () => {
const thread = {
messages: [
{
type: "ai",
tool_calls: [
{
id: "write1",
name: "write_file",
args: { path: "/mnt/user-data/outputs/report-page.html" },
},
],
},
{ type: "tool", tool_call_id: "write1" },
{
type: "ai",
tool_calls: [
{
id: "replace2",
name: "str_replace",
args: { path: "/mnt/user-data/outputs/report-page.html" },
},
],
},
{ type: "tool", tool_call_id: "replace2" },
],
};
assert.equal(
latestCompletedArtifactMutationKey(thread, "/mnt/user-data/outputs/report-page.html"),
"str_replace:replace2",
);
assert.equal(
latestCompletedArtifactMutationKey(thread, "/mnt/user-data/outputs/other.html"),
null,
);
});
test("latestInFlightWriteFilePreview opens a write_file that is not the last tool call", () => {
const preview = latestInFlightWriteFilePreview([
{
@@ -51,6 +51,100 @@ type WriteFileThreadLike = {
}>;
};
function parseToolJson(content: unknown): Record<string, unknown> | null {
let value = content;
if (Array.isArray(value)) {
const textBlock = value.find(
(item) =>
item &&
typeof item === "object" &&
"text" in item &&
typeof (item as { text?: unknown }).text === "string",
) as { text?: string } | undefined;
value = textBlock?.text;
}
if (typeof value === "string") {
try {
value = JSON.parse(value) as unknown;
} catch {
return null;
}
}
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function hasLaterOutputHtmlWrite(
messages: WriteFileThreadLike["messages"],
preparedMessageIndex: number,
): boolean {
for (
let messageIndex = preparedMessageIndex + 1;
messageIndex < messages.length;
messageIndex += 1
) {
const message = messages[messageIndex];
if (message?.type !== "ai" || !message.tool_calls) continue;
for (const toolCall of message.tool_calls) {
if (toolCall?.name !== "write_file") continue;
const args = toolCall.args as { path?: unknown } | undefined;
const path =
typeof args?.path === "string"
? normalizeStreamingArtifactPath(args.path)
: "";
if (/^\/mnt\/user-data\/outputs\/.+\.html?$/i.test(path)) {
return true;
}
}
}
return false;
}
/**
* A successful page-template prepare already creates a previewable HTML file
* under outputs. Open it before the first edit arrives so the user sees the
* sandbox as soon as page generation starts when the model edits it in place.
*
* A later write_file to outputs is a new final artifact, not a reason to keep
* the prepared template selected. Let the write preview take over instead;
* otherwise the template's cached content can reappear until a manual refresh.
*/
export function latestPreparedHtmlArtifact(
messages: WriteFileThreadLike["messages"],
): { artifactPath: string; previewKey: string } | null {
for (let messageIndex = messages.length - 1; messageIndex >= 0; messageIndex -= 1) {
const message = messages[messageIndex];
if (message?.type !== "ai" || !message.tool_calls) continue;
for (let toolIndex = message.tool_calls.length - 1; toolIndex >= 0; toolIndex -= 1) {
const toolCall = message.tool_calls[toolIndex];
if (toolCall?.name !== "page_report_template" || !toolCall.id) continue;
const args = toolCall.args as { action?: unknown } | undefined;
if (String(args?.action ?? "").toLowerCase() !== "prepare") continue;
const resultMessage = messages.find(
(candidate) =>
candidate.type === "tool" && candidate.tool_call_id === toolCall.id,
);
const result = parseToolJson(resultMessage?.content);
if (result?.ok !== true) continue;
const candidate =
typeof result.working_copy === "string"
? result.working_copy
: typeof result.final_output === "string"
? result.final_output
: "";
const artifactPath = candidate.trim();
if (!/^\/mnt\/user-data\/outputs\/.+\.html?$/i.test(artifactPath)) continue;
if (hasLaterOutputHtmlWrite(messages, messageIndex)) continue;
return {
artifactPath,
previewKey: `page-template:${toolCall.id}`,
};
}
}
return null;
}
function hasToolResult(thread: WriteFileThreadLike, toolCallId?: string) {
if (!toolCallId) {
return false;
@@ -60,6 +154,43 @@ function hasToolResult(thread: WriteFileThreadLike, toolCallId?: string) {
);
}
/**
* A completed write_file or str_replace changes the server-side artifact even
* when its path stays the same. The id becomes a React Query revision key so a
* previously prepared template cannot remain cached after the final HTML lands.
*/
export function latestCompletedArtifactMutationKey(
thread: WriteFileThreadLike,
filepath: string,
): string | null {
const targetPath = normalizeStreamingArtifactPath(filepath);
if (!targetPath) return null;
for (let messageIndex = thread.messages.length - 1; messageIndex >= 0; messageIndex -= 1) {
const message = thread.messages[messageIndex];
if (message?.type !== "ai" || !message.tool_calls) continue;
for (let toolIndex = message.tool_calls.length - 1; toolIndex >= 0; toolIndex -= 1) {
const toolCall = message.tool_calls[toolIndex];
if (
(toolCall?.name !== "write_file" && toolCall?.name !== "str_replace") ||
!toolCall.id ||
!hasToolResult(thread, toolCall.id)
) {
continue;
}
const args = toolCall.args as { path?: unknown } | undefined;
const path =
typeof args?.path === "string"
? normalizeStreamingArtifactPath(args.path)
: "";
if (path === targetPath) {
return `${toolCall.name}:${toolCall.id}`;
}
}
}
return null;
}
/** True only while THIS file's write_file call has not yet returned a tool result. */
export function isWriteFileInFlight(
thread: WriteFileThreadLike,
+6 -2
View File
@@ -1,6 +1,6 @@
"use client";
import { Fragment, useEffect, useRef, useState, type ReactNode } from "react";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
@@ -125,5 +125,9 @@ export function AuthSyncWatcher({
};
}, [disabled, location.pathname, navigate, queryClient]);
return children ? <Fragment key={revision}>{children}</Fragment> : null;
// A keyed Fragment is transformed by the development tagger, which injects
// data-lov-id. React.Fragment only accepts key and children, so use a
// display:contents element to preserve the remount behavior without adding
// a layout box.
return children ? <div className="contents" key={revision}>{children}</div> : null;
}
@@ -492,6 +492,8 @@ export const enUS: Translations = {
badgePublishedTitle: "Visible to all users",
badgeUnpublished: "Unpublished",
badgeUnpublishedTitle: "Not published yet — only visible to its owner",
badgeOffline: "Offline",
badgeOfflineTitle: "Retained in the system but unavailable to models",
badgeStale: "Stale",
badgeStaleTitle: "Idle for a long time — flagged by the curator",
badgeArchived: "Archived",
@@ -514,6 +516,13 @@ export const enUS: Translations = {
unpublishAction: "Unpublish",
publishSuccess: "Skill published",
unpublishSuccess: "Skill unpublished",
onlineAction: "Bring online",
offlineAction: "Take offline",
onlineSuccess: "Skill is online and available to models again",
offlineSuccess: "Skill is offline and unavailable to models",
offlineConfirmTitle: "Take this skill offline?",
offlineConfirmDescription:
"Take \"{name}\" offline? Its files and configuration will be retained, but models cannot use it and new configurations cannot select it. You can bring it online again later.",
pinAction: "Pin (prevent auto-archive)",
unpinAction: "Unpin",
pinSuccess: "Pinned",
@@ -607,6 +616,8 @@ export const enUS: Translations = {
"Skill '{name}' was uploaded by user '{owner}' and cannot be replaced. Rename the skill before uploading.",
conflictCustom:
"A custom skill named '{name}' already exists, but its uploader could not be identified. Rename it to avoid overwriting someone else's skill.",
conflictUnclaimed:
"A leftover copy of skill '{name}' exists on the server with no owning user — it is invisible in every list and cannot be managed. Click 'Replace' to remove the leftover and install your upload.",
conflictPublic:
"A built-in public skill named '{name}' already exists and cannot be overwritten.",
conflictPublicAdminHint:
@@ -425,6 +425,8 @@ export interface Translations {
badgePublishedTitle: string;
badgeUnpublished: string;
badgeUnpublishedTitle: string;
badgeOffline: string;
badgeOfflineTitle: string;
badgeStale: string;
badgeStaleTitle: string;
badgeArchived: string;
@@ -447,6 +449,12 @@ export interface Translations {
unpublishAction: string;
publishSuccess: string;
unpublishSuccess: string;
onlineAction: string;
offlineAction: string;
onlineSuccess: string;
offlineSuccess: string;
offlineConfirmTitle: string;
offlineConfirmDescription: string;
pinAction: string;
unpinAction: string;
pinSuccess: string;
@@ -531,6 +539,7 @@ export interface Translations {
conflictOwnedByMeWrongTarget: string;
conflictOwnedByOther: string;
conflictCustom: string;
conflictUnclaimed: string;
conflictPublic: string;
conflictPublicAdminHint: string;
installSuccess: string;
@@ -472,6 +472,8 @@ export const zhCN: Translations = {
badgePublishedTitle: "该技能已对所有用户可见",
badgeUnpublished: "未发布",
badgeUnpublishedTitle: "该技能尚未发布,仅所有者可见",
badgeOffline: "已下线",
badgeOfflineTitle: "技能仍保留在系统中,但不会提供给模型调用",
badgeStale: "过时",
badgeStaleTitle: "长期未使用,已被策展程序标记",
badgeArchived: "归档",
@@ -494,6 +496,13 @@ export const zhCN: Translations = {
unpublishAction: "取消发布",
publishSuccess: "已发布",
unpublishSuccess: "已取消发布",
onlineAction: "上线",
offlineAction: "下线",
onlineSuccess: "技能已上线,模型可以再次调用",
offlineSuccess: "技能已下线,模型将不再调用",
offlineConfirmTitle: "确认下线技能?",
offlineConfirmDescription:
"下线技能“{name}”吗?技能文件和配置会保留,但模型无法调用它,新配置也不能选择它。之后可随时重新上线。",
pinAction: "钉选(防止被自动归档)",
unpinAction: "取消钉选",
pinSuccess: "已钉选",
@@ -582,6 +591,8 @@ export const zhCN: Translations = {
"技能 “{name}” 已由用户 “{owner}” 上传,不能覆盖,请修改技能名称后再上传。",
conflictCustom:
"已存在同名自定义技能 “{name}”,但无法确认上传用户,为避免误覆盖请修改技能名称。",
conflictUnclaimed:
"服务器上存在同名技能 “{name}” 的残留副本(无归属用户,列表中不可见、无法管理)。点击「替换」将清除残留副本并安装您上传的版本。",
conflictPublic: `存在同名内置技能 '{name}',无法覆盖。`,
conflictPublicAdminHint: `存在同名内置技能 '{name}'。点击「重复上传并覆盖」可替换。`,
installSuccess: `技能 '{name}' 已安装。`,
@@ -0,0 +1,44 @@
import assert from "node:assert/strict";
import test from "node:test";
// Node's type-stripping runner requires the explicit TypeScript suffix.
// @ts-expect-error The application compiler intentionally uses extensionless imports.
import * as answerDisplay from "./answer-display.ts";
const { stripKnowledgeRetrievalDiagnostics } = answerDisplay;
test("removes the inline partial-retrieval notice from a historical answer", () => {
const content = [
"## 回答",
"这是基于 Wiki 的结论。[1]",
"",
"**资料不足与检索局限说明:** 本轮仅有部分检索通道成功(VECTOR_UNAVAILABLE),检索结果可能不完整。",
].join("\n");
assert.equal(
stripKnowledgeRetrievalDiagnostics(content),
"## 回答\n这是基于 Wiki 的结论。[1]",
);
});
test("removes a multi-line diagnostic section without changing the answer", () => {
const content = [
"正文第一段。",
"",
"### 资料不足与检索局限说明",
"本轮只有部分检索通道成功。",
"- 内部错误码:VECTOR_UNAVAILABLE",
"",
"正文第二段。",
].join("\n");
assert.equal(
stripKnowledgeRetrievalDiagnostics(content),
"正文第一段。\n\n正文第二段。",
);
});
test("leaves ordinary assistant content unchanged", () => {
const content = "向量检索不可用时,可以继续使用关键词检索。";
assert.equal(stripKnowledgeRetrievalDiagnostics(content), content);
});
@@ -0,0 +1,29 @@
const RETRIEVAL_DIAGNOSTIC_START =
/(?:资料不足与检索局限说明|本轮(?:仅有|只有)部分检索通道成功)/;
/**
* Hide internal retrieval diagnostics from assistant answers, including
* historical messages that already persisted the model-generated notice.
* Retrieval traces remain available to developers and are not modified.
*/
export function stripKnowledgeRetrievalDiagnostics(content: string): string {
if (!RETRIEVAL_DIAGNOSTIC_START.test(content)) return content;
const output: string[] = [];
let skippingDiagnosticBlock = false;
for (const line of content.split(/\r?\n/)) {
if (RETRIEVAL_DIAGNOSTIC_START.test(line)) {
skippingDiagnosticBlock = true;
continue;
}
if (skippingDiagnosticBlock) {
if (!line.trim()) {
skippingDiagnosticBlock = false;
}
continue;
}
output.push(line);
}
return output.join("\n").replace(/\n{3,}/g, "\n\n").trim();
}
@@ -0,0 +1,83 @@
import assert from "node:assert/strict";
import test from "node:test";
// Node's type-stripping runner requires the explicit TypeScript suffix.
// @ts-expect-error The application compiler intentionally uses extensionless imports.
import * as knowledgeReference from "./knowledge-reference.ts";
const {
classifyKnowledgeWikiSource,
createKnowledgeWikiReferenceSelection,
filterKnowledgeWikiSources,
} = knowledgeReference;
test("knowledge Wiki selection excludes generic and unselected citations", () => {
const selection = createKnowledgeWikiReferenceSelection(
["wiki-a", "wiki-a"],
["assistant-a"],
);
assert.ok(selection);
const sources = filterKnowledgeWikiSources(
[
{ type: "llmwiki_wiki", raw: { knowledge_base_id: "wiki-a" } },
{
type: "llmwiki",
raw: { knowledge_base_id: "wiki-a", retrieval_mode: "raw_chunk" },
},
{ type: "assistant_knowledge", raw: { base_id: "assistant-a" } },
{ type: "llmwiki_wiki", raw: { knowledge_base_id: "wiki-other" } },
{ type: "web", raw: { url: "https://example.com" } },
],
selection,
);
assert.deepEqual(
sources.map((source) => source.type),
["llmwiki_wiki", "llmwiki", "assistant_knowledge"],
);
assert.deepEqual(selection.knowledgeBaseIds, ["wiki-a"]);
});
test("empty selection does not enable the Wiki reference view", () => {
assert.equal(createKnowledgeWikiReferenceSelection([], []), undefined);
});
test("formatted Wiki pages and original chunks are classified separately", () => {
assert.equal(
classifyKnowledgeWikiSource({
type: "llmwiki",
raw: { knowledge_base_id: "wiki-a", retrieval_mode: "raw_chunk" },
}),
"raw",
);
assert.equal(
classifyKnowledgeWikiSource({
type: "assistant_knowledge",
raw: {
base_id: "assistant-a",
page_type: "chunk",
slug: "sources/manual/chunk-1",
},
}),
"raw",
);
assert.equal(
classifyKnowledgeWikiSource({
type: "assistant_knowledge",
raw: {
base_id: "assistant-a",
page_type: "article",
slug: "wiki/manual-overview",
},
}),
"wiki",
);
assert.equal(
classifyKnowledgeWikiSource({
type: "llmwiki_wiki",
raw: { knowledge_base_id: "wiki-a", wiki_slug: "wiki/overview" },
}),
"wiki",
);
});
@@ -0,0 +1,91 @@
export type KnowledgeWikiReferenceSelection = {
kind: "wiki";
knowledgeBaseIds: string[];
assistantKnowledgeBaseIds: string[];
};
type KnowledgeReferenceSourceLike = {
type?: string;
retrieval_mode?: unknown;
raw?: Record<string, unknown>;
};
export type KnowledgeWikiSourceKind = "wiki" | "raw";
function normalizedString(value: unknown): string {
return typeof value === "string" ? value.trim().toLowerCase() : "";
}
/** Distinguish generated/formatted Wiki pages from original document chunks. */
export function classifyKnowledgeWikiSource(
source: KnowledgeReferenceSourceLike,
): KnowledgeWikiSourceKind {
const raw = source.raw ?? {};
const retrievalMode = normalizedString(
raw.retrieval_mode ?? source.retrieval_mode,
);
const pageType = normalizedString(raw.page_type);
const rawKind = normalizedString(raw.kind);
if (
retrievalMode === "raw_chunk" ||
pageType === "chunk" ||
rawKind === "chunk"
) {
return "raw";
}
const hasWikiPageIdentity = Boolean(
normalizedString(raw.wiki_slug) ||
normalizedString(raw.wikiSlug) ||
normalizedString(raw.slug),
);
return source.type === "llmwiki_wiki" || hasWikiPageIdentity
? "wiki"
: "raw";
}
function normalizedIds(values: readonly string[] | null | undefined): string[] {
return Array.from(
new Set((values ?? []).map((value) => value.trim()).filter(Boolean)),
);
}
export function createKnowledgeWikiReferenceSelection(
knowledgeBaseIds: readonly string[] | null | undefined,
assistantKnowledgeBaseIds: readonly string[] | null | undefined,
): KnowledgeWikiReferenceSelection | undefined {
const ordinary = normalizedIds(knowledgeBaseIds);
const assistant = normalizedIds(assistantKnowledgeBaseIds);
if (ordinary.length === 0 && assistant.length === 0) return undefined;
return {
kind: "wiki",
knowledgeBaseIds: ordinary,
assistantKnowledgeBaseIds: assistant,
};
}
/**
* Knowledge-space Q&A never reuses unrelated Web/tool citations. Keep only
* sources whose server-authored knowledge-space id belongs to the active
* selection; sources without an auditable space id are deliberately omitted.
*/
export function filterKnowledgeWikiSources<T extends KnowledgeReferenceSourceLike>(
sources: readonly T[],
selection: KnowledgeWikiReferenceSelection,
): T[] {
const ordinary = new Set(selection.knowledgeBaseIds);
const assistant = new Set(selection.assistantKnowledgeBaseIds);
return sources.filter((source) => {
const raw = source.raw ?? {};
const ordinaryId = String(
raw.knowledge_base_id ?? raw.mapping_id ?? "",
).trim();
const assistantId = String(raw.base_id ?? "").trim();
const assistantSource =
source.type === "assistant_knowledge" ||
String(raw.kind ?? "").toLowerCase() === "assistant";
if (assistantSource) {
return assistant.has(assistantId || ordinaryId);
}
return ordinary.has(ordinaryId || assistantId);
});
}
@@ -0,0 +1,78 @@
import assert from "node:assert/strict";
import test from "node:test";
// Node's type-stripping runner requires the explicit TypeScript suffix.
// @ts-expect-error The application compiler intentionally uses extensionless imports.
import { retrievalCandidateCount, retrievalChannelScores, retrievalDiagnosticNumber, retrievalModeLabel, retrievalRankScoreLabel } from "./retrieval-debug.ts";
test("labels every retrieval mode returned by Wiki and assistant search", () => {
assert.equal(retrievalModeLabel("vector"), "向量检索");
assert.equal(retrievalModeLabel("wiki_api"), "Wiki 接口检索");
assert.equal(retrievalModeLabel("hybrid"), "向量 + 关键词混合检索");
assert.equal(retrievalModeLabel("keyword"), "关键词检索");
assert.equal(retrievalModeLabel("overview"), "知识库概览检索");
assert.equal(retrievalModeLabel("raw_chunk"), "原文分块检索");
assert.equal(retrievalModeLabel("wiki_raw_hybrid"), "Wiki + 原文分块混合检索");
assert.equal(retrievalModeLabel("graph_expansion"), "Wiki 图关联扩展");
assert.equal(retrievalModeLabel("wiki_graph_hybrid"), "Wiki 混合召回 + 图扩展");
assert.equal(retrievalModeLabel("wiki_graph_raw_hybrid"), "Wiki 混合召回 + 图扩展 + 原文分块");
assert.equal(retrievalModeLabel("assistant_graph_hybrid"), "助手知识库检索 + 图扩展");
assert.equal(retrievalModeLabel("mixed"), "多通道混合检索");
assert.equal(retrievalModeLabel("empty"), "未命中");
});
test("labels rank scores only with their declared semantics", () => {
assert.equal(retrievalRankScoreLabel("rrf"), "融合排序分");
assert.equal(retrievalRankScoreLabel("graph_decay"), "图扩展分");
assert.equal(retrievalRankScoreLabel("provider_relevance"), "上游相关分");
assert.equal(retrievalRankScoreLabel(undefined, false), "检索排序分");
assert.equal(retrievalRankScoreLabel(undefined, true), null);
});
test("keeps fusion rank separate from channel and vector scores", () => {
const scores = retrievalChannelScores({
score: 0.03125,
retrieval_mode: "hybrid",
channel_score: 0.82,
matched_sections: [{ score: 0.74 }, { score: 0.91 }],
});
assert.deepEqual(scores, [{ label: "通道分", value: 0.82 }]);
assert.deepEqual(
retrievalChannelScores({
retrieval_mode: "vector",
matched_sections: [{ score: 0.74 }, { score: 0.91 }],
}),
[{ label: "向量分", value: 0.91 }],
);
assert.deepEqual(
retrievalChannelScores({ retrieval_mode: "graph_expansion", vector_score: 0.77, graph_score: 0.42 }),
[
{ label: "向量分", value: 0.77 },
{ label: "图扩展分", value: 0.42 },
],
);
assert.deepEqual(
retrievalChannelScores({
retrieval_mode: "graph_expansion",
rank_score: 0.42,
score: 0.99,
rank_score_semantics: "graph_decay",
graph_score: 0.42,
}),
[],
);
});
test("reads current and nested diagnostic shapes", () => {
assert.equal(retrievalCandidateCount({ vector_candidates: 12 }, "vector"), 12);
assert.equal(retrievalCandidateCount({ raw_chunk_candidates: 3 }, "raw_chunk"), 3);
assert.equal(retrievalCandidateCount({ graph_candidates: 5 }, "graph"), 5);
assert.equal(
retrievalCandidateCount({ candidates: { assistant: "4" } }, "assistant"),
4,
);
assert.equal(
retrievalDiagnosticNumber({ top_raw_vector_score: 0.66 }, ["raw_top", "top_raw_vector_score"]),
0.66,
);
});
@@ -0,0 +1,177 @@
export type LlmWikiRetrievalMode =
| "empty"
| "vector"
| "keyword"
| "hybrid"
| "overview"
| "wiki_api"
| "raw_chunk"
| "wiki_raw_hybrid"
| "graph"
| "graph_expansion"
| "wiki_graph_hybrid"
| "wiki_graph_raw_hybrid"
| "assistant_graph_hybrid"
| "assistant"
| "mixed";
type UnknownRecord = Record<string, unknown>;
function asRecord(value: unknown): UnknownRecord | null {
return value !== null && typeof value === "object" && !Array.isArray(value)
? (value as UnknownRecord)
: null;
}
function finiteNumber(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim()) {
const parsed = Number(value);
if (Number.isFinite(parsed)) return parsed;
}
return null;
}
export function retrievalModeLabel(mode: unknown): string {
const normalized = typeof mode === "string" ? mode.trim().toLowerCase() : "";
switch (normalized) {
case "vector":
return "向量检索";
case "keyword":
return "关键词检索";
case "hybrid":
return "向量 + 关键词混合检索";
case "overview":
return "知识库概览检索";
case "wiki_api":
return "Wiki 接口检索";
case "raw_chunk":
return "原文分块检索";
case "wiki_raw_hybrid":
return "Wiki + 原文分块混合检索";
case "graph":
case "graph_expansion":
return "Wiki 图关联扩展";
case "wiki_graph_hybrid":
return "Wiki 混合召回 + 图扩展";
case "wiki_graph_raw_hybrid":
return "Wiki 混合召回 + 图扩展 + 原文分块";
case "assistant_graph_hybrid":
return "助手知识库检索 + 图扩展";
case "assistant":
return "助手知识库检索";
case "mixed":
return "多通道混合检索";
case "empty":
return "未命中";
default:
return normalized ? `未知模式(${normalized})` : "未报告";
}
}
export function formatRetrievalScore(value: unknown): string | null {
const score = finiteNumber(value);
if (score === null) return null;
return score.toFixed(4);
}
export function retrievalRankScoreLabel(
semantics: unknown,
hasChannelScores = false,
): string | null {
const normalized = typeof semantics === "string" ? semantics.trim().toLowerCase() : "";
switch (normalized) {
case "rrf":
return "融合排序分";
case "graph_decay":
return "图扩展分";
case "provider_relevance":
return "上游相关分";
default:
// A pure vector result commonly exposes the same raw value as both the
// top-level score and vector_score. Do not relabel or duplicate it as a
// fusion score when the backend did not declare score semantics.
return hasChannelScores ? null : "检索排序分";
}
}
export interface RetrievalChannelScore {
label: "通道分" | "向量分" | "图扩展分";
value: number;
}
/**
* A Wiki result's top-level score may be an RRF fusion rank. Channel scores
* are deliberately extracted separately so callers never present the RRF
* value as a cosine-similarity percentage.
*/
export function retrievalChannelScores(result: unknown): RetrievalChannelScore[] {
const row = asRecord(result);
if (!row) return [];
const scores: RetrievalChannelScore[] = [];
const channelScore = finiteNumber(row.channel_score);
if (channelScore !== null) scores.push({ label: "通道分", value: channelScore });
const retrievalMode = typeof row.retrieval_mode === "string"
? row.retrieval_mode.trim().toLowerCase()
: "";
const sections = retrievalMode === "vector" && Array.isArray(row.matched_sections)
? row.matched_sections
: [];
const vectorScores = sections
.map((section) => finiteNumber(asRecord(section)?.score))
.filter((value): value is number => value !== null);
const explicitVectorScore = finiteNumber(row.vector_score);
const vectorScore = explicitVectorScore ?? (vectorScores.length > 0 ? Math.max(...vectorScores) : null);
if (vectorScore !== null) scores.push({ label: "向量分", value: vectorScore });
const graphScore = finiteNumber(row.graph_score);
const rankSemantics = typeof row.rank_score_semantics === "string"
? row.rank_score_semantics.trim().toLowerCase()
: "";
const rankScore = finiteNumber(row.rank_score ?? row.score);
const graphScoreAlreadyRenderedAsRank = rankSemantics === "graph_decay"
&& rankScore !== null
&& graphScore !== null
&& Math.abs(rankScore - graphScore) < Number.EPSILON;
if (graphScore !== null && !graphScoreAlreadyRenderedAsRank) {
scores.push({ label: "图扩展分", value: graphScore });
}
return scores;
}
export type RetrievalCandidateChannel = "vector" | "keyword" | "wiki_api" | "graph" | "raw_chunk" | "assistant";
export function retrievalCandidateCount(
diagnostics: unknown,
channel: RetrievalCandidateChannel,
): number | null {
const row = asRecord(diagnostics);
if (!row) return null;
const candidates = asRecord(row.candidates);
const channelDetails = asRecord(row[channel]);
const values = [
row[`${channel}_candidates`],
candidates?.[channel],
channelDetails?.candidates,
channelDetails?.candidate_count,
channelDetails?.count,
];
for (const value of values) {
const parsed = finiteNumber(value);
if (parsed !== null) return parsed;
}
return null;
}
export function retrievalDiagnosticNumber(
diagnostics: unknown,
keys: string[],
): number | null {
const row = asRecord(diagnostics);
if (!row) return null;
for (const key of keys) {
const direct = finiteNumber(row[key]);
if (direct !== null) return direct;
}
return null;
}
@@ -0,0 +1,41 @@
import assert from "node:assert/strict";
import test from "node:test";
// @ts-ignore Node's type-stripping runner requires the explicit TypeScript suffix.
import { resolveRetrievedChunkExcerpt } from "./source-excerpt.ts";
test("returns the exact focus chunk instead of a document-level fallback", () => {
const excerpt = resolveRetrievedChunkExcerpt(
{
focus_chunk_id: "chunk-2",
chunks: [
{ id: "chunk-1", content: "特朗普人物介绍" },
{ id: "chunk-2", content: "海湖庄园位于佛罗里达州。", is_focus: true },
],
},
"每个引用都相同的文档摘要",
);
assert.equal(excerpt, "海湖庄园位于佛罗里达州。");
});
test("matches focus_chunk_id when providers omit is_focus", () => {
const excerpt = resolveRetrievedChunkExcerpt({
focus_chunk_id: "chunk-b",
chunks: [
{ id: "chunk-a", content: "第一段" },
{ id: "chunk-b", content: "第二段" },
],
});
assert.equal(excerpt, "第二段");
});
test("uses the citation excerpt when no focus chunk is available", () => {
const excerpt = resolveRetrievedChunkExcerpt(
{ focus_chunk_id: "missing", chunks: [] },
" 本次召回内容\r\n\r\n\r\n下一段 ",
);
assert.equal(excerpt, "本次召回内容\n\n下一段");
});
@@ -0,0 +1,38 @@
export type SourceContextChunkLike = {
id?: string | null;
content?: string | null;
is_focus?: boolean | null;
};
export type SourceContextLike = {
focus_chunk_id?: string | null;
chunks?: SourceContextChunkLike[] | null;
};
export function cleanRetrievedChunkText(value?: string | null): string {
return (value || "")
.replace(/\r\n/g, "\n")
.replace(/[ \t]+\n/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
/**
* Prefer the exact focus chunk returned by the source-context API. The
* citation payload is retained as a fallback for older providers and Wiki
* results that do not expose a chunk id.
*/
export function resolveRetrievedChunkExcerpt(
context: SourceContextLike | null | undefined,
fallback?: string | null,
): string {
const focusChunkId = String(context?.focus_chunk_id || "").trim();
const chunks = Array.isArray(context?.chunks) ? context.chunks : [];
const focusChunk =
chunks.find((chunk) => chunk?.is_focus === true) ??
(focusChunkId
? chunks.find((chunk) => String(chunk?.id || "").trim() === focusChunkId)
: undefined);
return cleanRetrievedChunkText(focusChunk?.content) || cleanRetrievedChunkText(fallback);
}
@@ -96,6 +96,66 @@ test("writing-mode progress merges into the deep_research_report bar so the summ
assert.deepEqual(types, ["human", "assistant:processing", "assistant"]);
});
test("writing-mode wrap-up stays below a spliced deep-research progress bar", () => {
const types = groupTypes([
{ type: "human", id: "h1", content: "写报告" } as Message,
{
type: "ai",
id: "a1",
content: "",
tool_calls: [{ id: "c1", name: "deep_research_report", args: { query: "新能源" } }],
} as Message,
{
type: "tool",
id: "t1",
name: "deep_research_report",
content: "ok",
tool_call_id: "c1",
} as Message,
{
type: "ai",
id: "progress",
content: "",
tool_calls: [
{ id: "p1", name: "deep_research_progress", args: { label: "生成报告摘要", status: "complete" } },
],
} as Message,
{
type: "ai",
id: "summary",
content: "2025年中国新能源乘用车销量达1,354万辆,渗透率54.6%。",
} as Message,
]);
assert.deepEqual(types, ["human", "assistant:processing", "assistant"]);
});
test("appending the progress bar after the wrap-up splits the pipeline above the summary", () => {
const types = groupTypes([
{
type: "ai",
id: "a1",
content: "",
tool_calls: [{ id: "c1", name: "deep_research_report", args: {} }],
} as Message,
{
type: "ai",
id: "summary",
content: "2025年中国新能源乘用车销量达1,354万辆。",
} as Message,
{
type: "ai",
id: "progress",
content: "",
tool_calls: [
{ id: "p1", name: "deep_research_progress", args: { label: "生成报告摘要", status: "complete" } },
],
} as Message,
]);
assert.deepEqual(types, ["assistant:processing", "assistant", "assistant:processing"]);
});
test("a lone deep-research progress bar still opens its own group", () => {
const types = groupTypes([
{
+10
View File
@@ -370,6 +370,12 @@ export function extractReasoningContentFromMessage(message: Message) {
) {
return message.additional_kwargs.reasoning_content as string | null;
}
if (
message.additional_kwargs &&
typeof message.additional_kwargs.reasoning === "string"
) {
return message.additional_kwargs.reasoning;
}
if (Array.isArray(message.content)) {
const part = message.content[0];
if (part && "thinking" in part) {
@@ -387,6 +393,7 @@ export function removeReasoningContentFromMessage(message: Message) {
return;
}
delete message.additional_kwargs.reasoning_content;
delete message.additional_kwargs.reasoning;
}
export function extractURLFromImageURLContent(
@@ -424,6 +431,9 @@ export function hasReasoning(message: Message) {
if (typeof message.additional_kwargs?.reasoning_content === "string") {
return true;
}
if (typeof message.additional_kwargs?.reasoning === "string") {
return true;
}
if (Array.isArray(message.content)) {
const part = message.content[0];
// Compatible with the Anthropic gateway
@@ -10,6 +10,7 @@ export interface ManagedModel {
base_url: string | null;
request_timeout: number | null;
max_retries: number | null;
max_input_tokens: number | null;
max_tokens: number | null;
temperature: number | null;
stream_usage: boolean | null;
@@ -44,6 +45,7 @@ export interface ModelSavePayload {
base_url?: string | null;
request_timeout?: number | null;
max_retries?: number | null;
max_input_tokens?: number | null;
max_tokens?: number | null;
temperature?: number | null;
stream_usage?: boolean | null;
+1 -1
View File
@@ -7,7 +7,7 @@ export interface Model {
supports_thinking?: boolean;
supports_reasoning_effort?: boolean;
/** Execution profiles handled by the frontend instead of DeerFlow. */
execution_profile?: "single_round_rag" | "multi_round_rag";
execution_profile?: "single_round_rag" | "multi_round_rag" | "direct";
}
export interface TokenUsageSettings {
@@ -30,7 +30,7 @@ test("matches only workspace chats_iframe routes", () => {
assert.equal(isChatsIframeWorkspacePath("/landing"), false);
});
test("forces light theme for landing and chats_iframe routes only", () => {
test("forces the dedicated presentation themes for landing, iframe, and wx chat routes", () => {
assert.equal(resolveForcedThemeForPath("/landing"), "light");
assert.equal(
resolveForcedThemeForPath("/page/workspace/chats_iframe/new"),
@@ -41,6 +41,14 @@ test("forces light theme for landing and chats_iframe routes only", () => {
"light",
);
assert.equal(resolveForcedThemeForPath("/page/workspace/chats/new"), undefined);
assert.equal(
resolveForcedThemeForPath("/page/workspace/wx/chats/new"),
"dark",
);
assert.equal(
resolveForcedThemeForPath("/page/workspace/wx/agents/agent-1/chats/new"),
"dark",
);
});
test("recognizes an explicit chatMode=iframe independently of the legacy marker", () => {
@@ -3,6 +3,8 @@ import { resolveEmbedThemeMode } from "../embed/theme-mode.mjs";
const CHATS_IFRAME_PATH = "/page/workspace/chats_iframe/new";
const IFRAME_LOGIN_SOURCE = "iframe";
const STANDARD_CHATS_PATH = "/page/workspace/chats";
/** Dedicated full chat surface whose presentation is always deep blue. */
export const WX_FULL_CHAT_PATH_PREFIX = "/page/workspace/wx/";
function toSearchParams(search: string): URLSearchParams {
return new URLSearchParams(search.startsWith("?") ? search : `?${search}`);
@@ -328,6 +330,10 @@ export function resolveForcedThemeForPath(
return "light";
}
if (pathname.startsWith(WX_FULL_CHAT_PATH_PREFIX)) {
return "dark";
}
const isLoginPath = pathname === "/login" || pathname.startsWith("/login/");
if (
isChatsIframeWorkspacePath(pathname) ||
@@ -1,7 +1,13 @@
import assert from "node:assert/strict";
import test from "node:test";
import { applyCompactMenuOverrides, isDefaultCompactMenuType, normalizeCompactMenuType } from "./compact-menu-overrides";
import {
applyCompactMenuOverrides,
isDefaultCompactMenuType,
normalizeCompactMenuType,
preserveUnknownCompactMenuOverrides,
snapshotCompactMenuOverrides,
} from "./compact-menu-overrides";
import type { MenuItem } from "./sidebar-menu";
const I = (() => null) as unknown as MenuItem["icon"];
@@ -37,3 +43,66 @@ test("applyCompactMenuOverrides splits sidebar, settings, and toolbox", () => {
assert.equal(applied.toolboxItems[0]?.id, "c");
assert.equal(applied.toolboxItems[0]?.label, "技能工具");
});
test("snapshotCompactMenuOverrides keeps every personal item and lane order", () => {
const items = [
{
id: "a",
label: "我的对话",
defaultLabel: "对话",
defaultIndex: 0,
compactPlacement: "sidebar" as const,
compactOrder: 0,
icon: I,
path: "/a",
},
{
id: "b",
label: "我的工具",
defaultLabel: "工具",
defaultIndex: 1,
compactPlacement: "toolbox" as const,
compactOrder: 0,
icon: I,
path: "/b",
},
{
id: "c",
label: "设置",
defaultLabel: "设置",
defaultIndex: 2,
compactPlacement: "sidebar" as const,
compactOrder: 1,
icon: I,
path: "/c",
},
];
assert.deepEqual(
snapshotCompactMenuOverrides(items).map((item) => [item.nodeId, item.placement, item.sortOrder]),
[
["a", "sidebar", 0],
["b", "toolbox", 0],
["c", "sidebar", 1],
],
);
assert.equal(snapshotCompactMenuOverrides(items)[0]?.label, "我的对话");
});
test("personal compact snapshot preserves a temporarily absent old item", () => {
const visible = snapshotCompactMenuOverrides([
{
id: "a",
label: "对话",
defaultLabel: "对话",
defaultIndex: 0,
compactPlacement: "sidebar",
compactOrder: 0,
icon: I,
path: "/a",
},
]);
const combined = preserveUnknownCompactMenuOverrides(visible, [
{ nodeId: "temporarily-hidden", placement: "toolbox", sortOrder: 0, label: "旧工具" },
]);
assert.deepEqual(combined.map((item) => item.nodeId), ["a", "temporarily-hidden"]);
});
@@ -105,6 +105,38 @@ export function diffCompactMenuOverrides(items: CompactMenuEditorItem[]): Compac
});
}
/**
* 完整个人简洁菜单快照。与 `diffCompactMenuOverrides` 的管理员稀疏集不同,
* 这里必须保留每个现有节点的实际位置、顺序和名称;未出现在用户快照中的
* 后续新增节点仍由后端合并管理员默认配置。
*/
export function snapshotCompactMenuOverrides(items: CompactMenuEditorItem[]): CompactMenuOverride[] {
const laneCounters = new Map<string, number>();
return items.map((item) => {
const lane = item.compactPlacement;
const order = laneCounters.get(lane) ?? 0;
laneCounters.set(lane, order + 1);
return {
nodeId: item.id,
placement: item.compactPlacement,
sortOrder: order,
label: item.label,
};
});
}
/** Preserve personal rows for items temporarily absent from this deployment's editor tree. */
export function preserveUnknownCompactMenuOverrides(
snapshot: CompactMenuOverride[],
previous: CompactMenuOverride[],
): CompactMenuOverride[] {
const visibleIds = new Set(snapshot.map((item) => item.nodeId));
return [
...snapshot,
...previous.filter((item) => !visibleIds.has(item.nodeId)),
];
}
export interface CompactMenuEditorItem extends CompactMenuItem {
defaultLabel: string;
defaultIndex: number;
@@ -8,6 +8,8 @@ import {
flattenMenu,
isDescendant,
mergeFlatNodes,
preserveUnknownMenuOverrides,
snapshotOverrides,
wouldExceedDepth,
type MenuOverride,
} from "./menu-overrides";
@@ -117,3 +119,31 @@ test("套覆盖再 diff 可往返", () => {
assert.equal(diff.length, 1);
assert.equal(diff[0]!.label, "X");
});
test("个人快照包含所有现有节点并保留生效名称", () => {
const merged = mergeFlatNodes(flattenMenu(sampleTree()), [
{ nodeId: "a1", parentId: "a", sortOrder: 0, label: "个人名称", disabled: false },
{ nodeId: "b", parentId: null, sortOrder: 0, label: null, disabled: true },
]);
const snapshot = snapshotOverrides(merged);
assert.deepEqual(snapshot.map((item) => item.nodeId), ["a", "a1", "a2", "a2x", "b"]);
assert.equal(snapshot.find((item) => item.nodeId === "a1")?.label, "个人名称");
assert.equal(snapshot.find((item) => item.nodeId === "b")?.disabled, true);
// A full snapshot intentionally uses concrete labels (not null), so a later
// administrator rename cannot leak into an already customized user's menu.
assert.equal(snapshot.every((item) => typeof item.label === "string"), true);
});
test("个人快照保留编辑树暂时不可见的旧节点", () => {
const visible = snapshotOverrides(mergeFlatNodes(flattenMenu(sampleTree()), []));
const hiddenApp: MenuOverride = {
nodeId: "light-app-disabled",
parentId: "a",
sortOrder: 9,
label: "我的轻应用",
disabled: false,
};
const combined = preserveUnknownMenuOverrides(visible, [hiddenApp]);
assert.equal(combined.at(-1)?.nodeId, "light-app-disabled");
assert.equal(combined.at(-1)?.label, "我的轻应用");
});
@@ -261,3 +261,53 @@ export function diffOverrides(merged: MergedMenuNode[]): MenuOverride[] {
}
return out;
}
/**
* 生成普通用户要保存的完整菜单快照。
*
* 管理员保存的是相对代码默认值的稀疏覆盖;用户保存的是当前生效布局的
* 全量快照,这样管理员以后修改默认菜单时,用户已存在的节点仍保持个人值,
* 而新节点因不在快照中自然继承管理员默认值。
*/
export function snapshotOverrides(merged: MergedMenuNode[]): MenuOverride[] {
const siblings = new Map<string | null, MergedMenuNode[]>();
for (const node of merged) {
const key = node.parentId ?? null;
const list = siblings.get(key) ?? [];
list.push(node);
siblings.set(key, list);
}
const orderById = new Map<string, number>();
for (const list of siblings.values()) {
list
.slice()
.sort((a, b) => a.order - b.order || a.id.localeCompare(b.id))
.forEach((node, index) => orderById.set(node.id, index));
}
return merged.map((node) => ({
nodeId: node.id,
parentId: node.parentId ?? null,
sortOrder: orderById.get(node.id) ?? node.order,
// Keep the effective label, rather than null, so a later administrator rename
// cannot leak through an already-saved personal snapshot.
label: node.label,
disabled: node.disabled,
}));
}
/**
* Keep personal rows for nodes that are temporarily absent from the editor
* tree (for example a disabled iframe light app). Without this, saving an
* unrelated change would silently forget that node and it would later be
* mistaken for an administrator-added menu.
*/
export function preserveUnknownMenuOverrides(
snapshot: MenuOverride[],
previous: MenuOverride[],
): MenuOverride[] {
const visibleIds = new Set(snapshot.map((item) => item.nodeId));
return [
...snapshot,
...previous.filter((item) => !visibleIds.has(item.nodeId)),
];
}
@@ -49,12 +49,23 @@ export type MenuItem = {
path?: string;
/** 额外视为「当前页属于本菜单项」的路由(不在侧栏展示,仅用于高亮)。 */
aliasPaths?: string[];
/** 从当前菜单项自身的 path / aliasPaths 前缀匹配中排除的路由。 */
excludePaths?: string[];
/** 非 admin 用户使用的路由(菜单对普通用户可见时生效,与简洁模式公开排行榜一致) */
userPath?: string;
adminOnly?: boolean;
children?: MenuItem[];
};
/** Markdown 转 Word 功能的菜单定义,供全量与简洁模式共用。 */
export const MARKDOWN_DOCX_MENU_PATH = "/page/workspace/knowledge/markdown-editor";
export const MARKDOWN_DOCX_MENU_ITEM: MenuItem & { path: string } = {
id: "resource-markdown-docx-editor",
label: "md转word",
icon: FileText,
path: MARKDOWN_DOCX_MENU_PATH,
};
export const sidebarMenuItems: MenuItem[] = [
{
id: "qa-center",
@@ -266,12 +277,7 @@ export const sidebarMenuItems: MenuItem[] = [
},
{ id: "mgmt-skills", label: "技能管理", icon: Sparkles, path: "/page/strategy/skills" },
{ id: "mgmt-memory", label: "记忆管理", icon: Brain, path: "/page/strategy/memory" },
{
id: "resource-markdown-docx-editor",
label: "md转word",
icon: FileText,
path: "/page/workspace/knowledge/markdown-editor",
},
MARKDOWN_DOCX_MENU_ITEM,
{
id: "mgmt-tasks",
label: "定时任务",
@@ -338,6 +344,7 @@ export const sidebarMenuItems: MenuItem[] = [
label: "知识库管理",
icon: BookOpen,
path: "/page/workspace/knowledge",
excludePaths: [MARKDOWN_DOCX_MENU_PATH],
},
{
id: "resource-knowledge-base",
@@ -373,6 +380,9 @@ function routeMatchesPathname(pathname: string, route?: string): boolean {
/** 菜单项自身 path / aliasPaths 是否匹配当前路由。 */
export function menuItemMatchesPath(item: MenuItem, pathname: string): boolean {
if (item.excludePaths?.some((path) => routeMatchesPathname(pathname, path))) {
return false;
}
if (routeMatchesPathname(pathname, item.path)) return true;
return item.aliasPaths?.some((p) => routeMatchesPathname(pathname, p)) ?? false;
}
@@ -89,7 +89,8 @@ function useDesensitizeLabel() {
*/
export function useSidebarMenuItems(isAdmin: boolean): MenuItem[] {
const { data: iframeApps = [] } = useLightApps({ appType: "iframe", enabledOnly: true });
const { data: overrides = [] } = useMenuOverrides();
const { data: overrideConfig } = useMenuOverrides();
const overrides = overrideConfig?.overrides ?? [];
const desensitizeLabel = useDesensitizeLabel();
return useMemo(
() =>
+244
View File
@@ -0,0 +1,244 @@
/**
* Web App 站点产物 API 客户端:能力探测 / 站点列表 / 构建 / 预览签名。
*
* 所有接口在后端能力不可用时返回 `{ available: false, reasons }`(HTTP 200),
* 调用方据此优雅降级——入口按钮不渲染、预览页显示未启用态。
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
export interface WebAppCapabilityCheck {
ok: boolean;
detail: string;
}
export interface WebAppCapability {
available: boolean;
enabled: boolean;
template_dir: string;
node_bin: string;
checks: Record<string, WebAppCapabilityCheck>;
reasons?: string[];
}
export interface SiteNavItem {
page: string;
label: string;
}
export interface SiteDistInfo {
exists: boolean;
file_count?: number;
total_bytes?: number;
mtime?: number | null;
}
export interface SiteBuildResult {
success?: boolean;
error?: string;
duration_ms?: number;
stdout_tail?: string;
stderr_tail?: string;
finished_at?: number;
}
export interface SiteBuildJob {
job_id: string;
status: "running" | "success" | "failed";
started_at?: number;
finished_at?: number | null;
error?: string;
result?: SiteBuildResult;
}
export interface SiteInfo {
name: string;
title: string;
description: string;
nav: SiteNavItem[];
map: Record<string, unknown>;
manifest_mtime: number | null;
dist: SiteDistInfo;
last_build: SiteBuildResult | null;
build_job?: SiteBuildJob;
}
export interface SiteListResponse {
available: boolean;
enabled?: boolean;
reasons?: string[];
sites?: SiteInfo[];
}
/** 跨对话站点 gallery 条目:站点信息 + 来源会话 */
export interface SiteGalleryItem extends SiteInfo {
thread_id: string;
thread_title: string;
build_job?: SiteBuildJob;
}
export interface SiteGalleryResponse {
available: boolean;
enabled?: boolean;
reasons?: string[];
sites?: SiteGalleryItem[];
}
export interface SiteBuildStatusResponse {
available: boolean;
job_id?: string;
status?: "running" | "success" | "failed" | "idle";
started_at?: number;
finished_at?: number | null;
error?: string;
result?: SiteBuildResult;
last_build?: SiteBuildResult | null;
}
export interface SitePreviewUrlResponse {
available: boolean;
reasons?: string[];
url_path?: string;
token?: string;
expires_at?: number;
}
async function readJson<T>(res: Response, ctx: string): Promise<T> {
if (!res.ok) {
let detail = `${ctx} 失败(HTTP ${res.status})`;
try {
const body = (await res.json()) as { detail?: string };
if (body?.detail) detail = String(body.detail);
} catch {
// keep default
}
throw new Error(detail);
}
return (await res.json()) as T;
}
const baseUrl = () => `${getBackendBaseURL()}/api`;
export async function fetchWebAppCapability(): Promise<WebAppCapability> {
const res = await apiFetch(`${baseUrl()}/web-app/capability`);
return readJson<WebAppCapability>(res, "站点能力探测");
}
export async function listSites(threadId: string): Promise<SiteListResponse> {
const res = await apiFetch(`${baseUrl()}/threads/${encodeURIComponent(threadId)}/sites`);
return readJson<SiteListResponse>(res, "站点列表");
}
export async function listAllSites(): Promise<SiteGalleryResponse> {
const res = await apiFetch(`${baseUrl()}/web-app/sites`);
return readJson<SiteGalleryResponse>(res, "站点 gallery");
}
export async function buildSite(
threadId: string,
siteName: string,
): Promise<{ available: boolean; job_id?: string; status?: string; reasons?: string[] }> {
const res = await apiFetch(
`${baseUrl()}/threads/${encodeURIComponent(threadId)}/sites/${encodeURIComponent(siteName)}/build`,
{ method: "POST" },
);
return readJson(res, "触发构建");
}
export async function fetchSiteBuildStatus(
threadId: string,
siteName: string,
): Promise<SiteBuildStatusResponse> {
const res = await apiFetch(
`${baseUrl()}/threads/${encodeURIComponent(threadId)}/sites/${encodeURIComponent(siteName)}/build-status`,
);
return readJson<SiteBuildStatusResponse>(res, "构建状态");
}
export async function mintSitePreviewUrl(
threadId: string,
siteName: string,
): Promise<SitePreviewUrlResponse> {
const res = await apiFetch(
`${baseUrl()}/threads/${encodeURIComponent(threadId)}/sites/${encodeURIComponent(siteName)}/preview-url`,
{ method: "POST" },
);
return readJson<SitePreviewUrlResponse>(res, "签发预览地址");
}
/** 站点预览页路由(HashRouter 顶层,独立风格页面) */
export function siteStudioRoute(threadId: string, siteName: string): string {
return `/sites/${encodeURIComponent(threadId)}/${encodeURIComponent(siteName)}`;
}
const CAPABILITY_KEY = ["web-app", "capability"] as const;
const SITES_KEY = (threadId: string) => ["web-app", "sites", threadId] as const;
export function useWebAppCapability(enabled = true) {
return useQuery({
queryKey: CAPABILITY_KEY,
queryFn: fetchWebAppCapability,
enabled,
staleTime: 60_000,
gcTime: 5 * 60_000,
retry: 1,
refetchOnWindowFocus: false,
});
}
export function useSites(threadId: string | undefined, options?: { refetchInterval?: number }) {
return useQuery({
queryKey: SITES_KEY(threadId ?? ""),
queryFn: () => listSites(threadId as string),
enabled: !!threadId,
refetchInterval: options?.refetchInterval ?? false,
refetchOnWindowFocus: false,
});
}
export function useAllSites() {
return useQuery({
queryKey: ["web-app", "all-sites"] as const,
queryFn: listAllSites,
staleTime: 15_000,
refetchOnWindowFocus: false,
});
}
export function useSiteBuildStatus(
threadId: string | undefined,
siteName: string | undefined,
options?: {
refetchInterval?: number | false | ((query: { state: { data: SiteBuildStatusResponse | undefined } }) => number | false);
},
) {
return useQuery({
queryKey: ["web-app", "build-status", threadId ?? "", siteName ?? ""] as const,
queryFn: () => fetchSiteBuildStatus(threadId as string, siteName as string),
enabled: !!threadId && !!siteName,
refetchInterval: options?.refetchInterval ?? false,
refetchOnWindowFocus: false,
});
}
export function useBuildSiteMutation(threadId: string | undefined, siteName: string | undefined) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => buildSite(threadId as string, siteName as string),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: SITES_KEY(threadId ?? "") });
void queryClient.invalidateQueries({
queryKey: ["web-app", "build-status", threadId ?? "", siteName ?? ""],
});
},
});
}
export function useMintPreviewUrlMutation(threadId: string | undefined, siteName: string | undefined) {
return useMutation({
mutationFn: () => mintSitePreviewUrl(threadId as string, siteName as string),
});
}
@@ -0,0 +1,81 @@
import assert from "node:assert/strict";
import test from "node:test";
// Node's type-stripping test runner requires the explicit TypeScript suffix.
// @ts-expect-error The application compiler intentionally uses extensionless imports.
import {
canToggleSkillAvailability,
isSkillOffline,
} from "./skill-availability.ts";
test("marks only an explicitly disabled skill as offline", () => {
assert.equal(isSkillOffline({ enabled: false }), true);
assert.equal(isSkillOffline({ enabled: true }), false);
});
test("keeps legacy payloads without enabled online", () => {
assert.equal(isSkillOffline({}), false);
assert.equal(isSkillOffline(null), false);
assert.equal(isSkillOffline(undefined), false);
});
test("owner control allows only the current user's custom skill", () => {
const ownSkill = {
category: "custom",
builtin: false,
owner_user_id: "user-a",
};
assert.equal(
canToggleSkillAvailability({
skill: ownSkill,
currentUserId: "user-a",
isAdmin: false,
control: "owner",
}),
true,
);
assert.equal(
canToggleSkillAvailability({
skill: ownSkill,
currentUserId: "user-b",
isAdmin: false,
control: "owner",
}),
false,
);
assert.equal(
canToggleSkillAvailability({
skill: { category: "public", builtin: true, owner_user_id: null },
currentUserId: "user-a",
isAdmin: false,
control: "owner",
}),
false,
);
});
test("admin control requires admin role and covers the full catalogue", () => {
const builtinSkill = {
category: "public",
builtin: true,
owner_user_id: null,
};
assert.equal(
canToggleSkillAvailability({
skill: builtinSkill,
currentUserId: "admin-a",
isAdmin: true,
control: "admin",
}),
true,
);
assert.equal(
canToggleSkillAvailability({
skill: builtinSkill,
currentUserId: "user-a",
isAdmin: false,
control: "admin",
}),
false,
);
});
@@ -0,0 +1,48 @@
export interface SkillAvailabilityLike {
enabled?: boolean | null;
}
export interface SkillAvailabilityAccessLike extends SkillAvailabilityLike {
category?: string | null;
builtin?: boolean | null;
owner_user_id?: string | null;
}
export type SkillAvailabilityControl = "admin" | "owner";
/**
* A missing availability flag comes from older API payloads and historically
* meant enabled. Only an explicit false marks a skill as offline.
*/
export function isSkillOffline(
skill: SkillAvailabilityLike | null | undefined,
): boolean {
return skill?.enabled === false;
}
/**
* Availability is global, so the ordinary skills page exposes it only for a
* user's own custom skills. The dedicated admin catalogue can manage the full
* inventory, including built-in and other users' skills.
*/
export function canToggleSkillAvailability({
skill,
currentUserId,
isAdmin,
control,
}: {
skill: SkillAvailabilityAccessLike;
currentUserId: string | null;
isAdmin: boolean;
control?: SkillAvailabilityControl;
}): boolean {
if (control === "admin") return isAdmin;
if (control !== "owner" || !currentUserId) return false;
const isBuiltin = skill.builtin ?? !skill.owner_user_id;
return (
skill.category === "custom" &&
!isBuiltin &&
skill.owner_user_id === currentUserId
);
}
+9 -3
View File
@@ -49,9 +49,12 @@ export async function getSkill(skillName: string): Promise<Skill> {
return response.json() as Promise<Skill>;
}
export async function enableSkill(skillName: string, enabled: boolean) {
export async function enableSkill(
skillName: string,
enabled: boolean,
): Promise<Skill> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/${skillName}`,
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}`,
{
method: "PUT",
headers: {
@@ -62,7 +65,10 @@ export async function enableSkill(skillName: string, enabled: boolean) {
}),
},
);
return response.json();
if (!response.ok) {
throw await parseDetailedError(response, "Failed to update skill availability");
}
return response.json() as Promise<Skill>;
}
export async function setSkillAlwaysOn(
+19 -5
View File
@@ -159,17 +159,31 @@ export function useSetSkillDisplay() {
export function useEnableSkill() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
mutationFn: ({
skillName,
enabled,
}: {
skillName: string;
enabled: boolean;
}) => {
await enableSkill(skillName, enabled);
},
onSuccess: () => {
}) => enableSkill(skillName, enabled),
onSuccess: (data, { skillName }) => {
// The availability endpoint intentionally returns the filesystem skill
// without ownership/tag annotations. Patch only the authoritative flag
// so a fast cache update cannot erase richer list/detail metadata.
queryClient.setQueryData<Skill>(["skill", skillName], (current) =>
current ? { ...current, enabled: data.enabled } : current,
);
queryClient.setQueriesData<Skill[]>({ queryKey: ["skills"] }, (current) =>
Array.isArray(current)
? current.map((item) =>
item.name === skillName
? { ...item, enabled: data.enabled }
: item,
)
: current,
);
void queryClient.invalidateQueries({ queryKey: ["skills"] });
void queryClient.invalidateQueries({ queryKey: ["skill", skillName] });
},
});
}
+4 -1
View File
@@ -1,4 +1,5 @@
import type { Skill } from "./type";
import { isSkillOffline } from "../skill-availability";
/**
* 智能体技能白名单的候选池过滤。
@@ -12,7 +13,9 @@ export function isAgentSkillCandidate(
currentUserId: string | null,
isAdmin = false,
): boolean {
if (!skill.enabled) return false;
// Older API payloads omitted the flag and historically meant enabled.
// Only an explicit false represents the administrator's offline state.
if (isSkillOffline(skill)) return false;
if (isAdmin) return true;
if ((skill.state ?? "active") !== "active") return false;
const builtin = skill.builtin ?? !skill.owner_user_id;
@@ -314,6 +314,42 @@ export interface PublishSquaresUpdate {
default_square_id?: string;
}
/** Agent Gallery category selected for the wx route's common-agent panel. */
export interface WxCommonAgentsSettings {
/** `null` keeps the original common-agent recommendation logic. */
agent_type: string | null;
}
export async function loadWxCommonAgents(): Promise<WxCommonAgentsSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/wx-common-agents`,
);
if (!response.ok) {
throw new Error(`Failed to load wx common agents: ${response.status}`);
}
return (await response.json()) as WxCommonAgentsSettings;
}
export async function updateWxCommonAgents(
agentType: string | null,
): Promise<WxCommonAgentsSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/wx-common-agents`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ agent_type: agentType }),
},
);
if (!response.ok) {
const detail = await response
.json()
.catch(() => ({ detail: "Failed to update wx common agents" }));
throw new Error(detail.detail ?? "Failed to update wx common agents");
}
return (await response.json()) as WxCommonAgentsSettings;
}
export async function loadPublishSquares(): Promise<PublishSquaresSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/publish-squares`,
@@ -601,6 +637,48 @@ export interface ModelConcurrencyUpdate {
user_model_limits?: Record<string, number>;
}
export interface MemoryRuntimeSettings {
enabled: boolean;
}
export async function loadMemoryRuntimeSettings(): Promise<MemoryRuntimeSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/memory`,
);
if (!response.ok) {
throw new Error(`Failed to load memory runtime setting: ${response.status}`);
}
return (await response.json()) as MemoryRuntimeSettings;
}
export async function updateMemoryRuntimeSettings(
body: MemoryRuntimeSettings,
): Promise<MemoryRuntimeSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/memory`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
if (!response.ok) {
const detail = await response
.json()
.catch(() => ({ detail: "Failed to update memory runtime setting" }));
throw new Error(detail.detail ?? "Failed to update memory runtime setting");
}
const data = (await response.json()) as MemoryRuntimeSettings;
writeYLog({
optType: OptType.edit,
optDetail: "CM助手-全量-系统设置-问答记忆开关",
url: "/api/system-settings/memory",
reqParam: body,
returnParam: data,
});
return data;
}
export async function loadSkillCompressionSettings(): Promise<SkillCompressionSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/skill-compression`,
+53 -2
View File
@@ -8,27 +8,32 @@ import {
loadCitationDisplaySettings,
loadLeaderboardSettings,
loadModelConcurrencySettings,
loadMemoryRuntimeSettings,
loadPluginManagementSettings,
loadPrivateSquareStatus,
loadPromptPrefixSettings,
loadPublishSquares,
loadSkillCompressionSettings,
loadStrategyProjectSettings,
loadWxCommonAgents,
updateAgentModuleConfig,
updateAppearanceSettings,
updateCitationDisplaySettings,
updateLeaderboardSettings,
updateModelConcurrencySettings,
updateMemoryRuntimeSettings,
updatePluginManagementSettings,
updatePrivateSquareSettings,
updatePromptPrefixSettings,
updatePublishSquares,
updateSkillCompressionSettings,
updateStrategyProjectSettings,
updateWxCommonAgents,
type AppearanceUpdate,
type CitationDisplayUpdate,
type LeaderboardSettingsUpdate,
type ModelConcurrencyUpdate,
type MemoryRuntimeSettings,
type PluginManagementUpdate,
type PrivateSquareUpdate,
type PromptPrefixUpdate,
@@ -47,6 +52,7 @@ const CITATION_DISPLAY_KEY = ["systemSettings", "citationDisplay"] as const;
const LEADERBOARD_KEY = ["systemSettings", "leaderboard"] as const;
const SKILL_COMPRESSION_KEY = ["systemSettings", "skillCompression"] as const;
const MODEL_CONCURRENCY_KEY = ["systemSettings", "modelConcurrency"] as const;
const MEMORY_RUNTIME_KEY = ["systemSettings", "memoryRuntime"] as const;
const PLUGIN_MANAGEMENT_KEY = ["systemSettings", "pluginManagement"] as const;
const STRATEGY_PROJECTS_KEY = ["systemSettings", "strategyProjects"] as const;
@@ -111,15 +117,36 @@ export function useUpdatePrivateSquareSettings() {
});
}
export function useMemoryRuntimeSettings() {
return useQuery({
queryKey: MEMORY_RUNTIME_KEY,
queryFn: () => loadMemoryRuntimeSettings(),
staleTime: 10_000,
refetchOnWindowFocus: false,
});
}
export function useUpdateMemoryRuntimeSettings() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: MemoryRuntimeSettings) =>
updateMemoryRuntimeSettings(body),
onSuccess: (data) => {
queryClient.setQueryData(MEMORY_RUNTIME_KEY, data);
},
});
}
/** The configured publish squares (发布广场). Readable by any logged-in user —
* the gallery sub-tabs and the publish-flow square picker both need it. */
export function usePublishSquares() {
export function usePublishSquares({ enabled = true }: { enabled?: boolean } = {}) {
return useQuery({
queryKey: PUBLISH_SQUARES_KEY,
queryFn: () => loadPublishSquares(),
staleTime: 60_000,
refetchOnWindowFocus: false,
retry: false,
enabled,
});
}
@@ -207,7 +234,9 @@ export function useLayoutMode(): [LayoutMode, (mode: LayoutMode) => void] {
const agentModuleKey = (key: string) =>
["systemSettings", "agentModule", key] as const;
export function useAgentModuleConfig(key: string) {
export function useAgentModuleConfig(
key: string,
) {
return useQuery({
queryKey: agentModuleKey(key),
queryFn: () => loadAgentModuleConfig(key),
@@ -216,6 +245,28 @@ export function useAgentModuleConfig(key: string) {
});
}
const WX_COMMON_AGENTS_KEY = ["systemSettings", "wxCommonAgents"] as const;
export function useWxCommonAgents({ enabled = true }: { enabled?: boolean } = {}) {
return useQuery({
queryKey: WX_COMMON_AGENTS_KEY,
queryFn: loadWxCommonAgents,
enabled,
staleTime: 60_000,
refetchOnWindowFocus: false,
});
}
export function useUpdateWxCommonAgents() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: updateWxCommonAgents,
onSuccess: (data) => {
queryClient.setQueryData(WX_COMMON_AGENTS_KEY, data);
},
});
}
export function useUpdateAgentModuleConfig(key: string) {
const queryClient = useQueryClient();
return useMutation({
@@ -0,0 +1,35 @@
/** wx 侧栏入口进入智能体/技能页时,类型筛选只保留这三项。 */
export const WX_NANJING_DB_SQUARE_NAME = "南京-db";
export const WX_RESTRICTED_TAB_MINE = "mine";
/** 默认广场尚未返回 id 时的占位 tab value,不发广场查询。 */
export const WX_RESTRICTED_TAB_DEFAULT_SQUARE = "default_square";
/** 南京-db 广场尚未创建时的占位 tab value,列表为空。 */
export const WX_RESTRICTED_TAB_NANJING_DB = "nanjing-db";
export function findNanjingDbSquareId(
squares: Array<{ id: string; name: string }> | undefined,
): string | undefined {
return (squares ?? []).find(
(square) => square.name.trim() === WX_NANJING_DB_SQUARE_NAME,
)?.id;
}
export function wxRestrictedDefaultSquareTabValue(
defaultSquareId: string | undefined,
): string {
return defaultSquareId || WX_RESTRICTED_TAB_DEFAULT_SQUARE;
}
export function wxRestrictedNanjingTabValue(
squares: Array<{ id: string; name: string }> | undefined,
): string {
return findNanjingDbSquareId(squares) || WX_RESTRICTED_TAB_NANJING_DB;
}
export function isWxRestrictedPlaceholderTab(value: string): boolean {
return (
value === WX_RESTRICTED_TAB_DEFAULT_SQUARE ||
value === WX_RESTRICTED_TAB_NANJING_DB
);
}
+17 -11
View File
@@ -826,20 +826,14 @@ export function useThreadStream({
return;
}
// Context compression about to run a (blocking) summarization LLM call —
// surface a transient hint so the wait before the answer streams isn't a
// silent dead air. Emitted by DeerFlowSummarizationMiddleware.
// Background compaction stays silent, including events from older backends.
if (
typeof event === "object" &&
event !== null &&
"type" in event &&
event.type === "context_compacting" &&
"message" in event &&
typeof event.message === "string" &&
event.message.trim()
event.type === "context_compacting"
) {
const e = event as { type: "context_compacting"; message: string };
toast(e.message);
return;
}
},
onError(error) {
@@ -887,6 +881,19 @@ export function useThreadStream({
// made every click look like a crash and could loop indefinitely when a
// different tab's cookie kept overriding this tab's Bearer token.
if (isThreadNotAccessibleError(message)) {
const projectHash = typeof window !== "undefined" ? window.location.hash : "";
const projectMatch = projectHash.match(
/#\/page\/strategy\/qa\/projects\/(groups|solutions)\/[^/]+\//,
);
const legacyProjectMatch = projectHash.match(
/#\/page\/workspace\/projects\/[^/]+\//,
);
if (projectMatch || legacyProjectMatch) {
const category = projectMatch?.[1] === "solutions" ? "solution" : "qa";
toast.info("当前项目对话不存在或无权访问,已返回我的项目。");
navigate(`/page/strategy/qa/projects?type=${category}`, { replace: true });
return;
}
const userLabel = buildCurrentUserLabel();
toast.info(
`当前登录用户为 ${userLabel},原对话不存在或无权访问,已为你打开新对话。`,
@@ -1579,6 +1586,7 @@ export function useThreadStream({
context: {
...mergedContext,
thinking_enabled: mergedContext.mode !== "flash",
thinking_force_disabled: mergedContext.mode === "flash",
is_plan_mode:
mergedContext.mode === "pro" || mergedContext.mode === "ultra",
subagent_enabled: mergedContext.mode === "ultra",
@@ -1591,8 +1599,6 @@ export function useThreadStream({
: mergedContext.mode === "thinking"
? "low"
: undefined),
memory_injection_enabled:
mergedContext.memory_injection_enabled ?? true,
rag_mode_enabled: ragModeEnabled,
summarization_enabled: contextCompressionEnabled,
thread_id: threadId,
@@ -2,6 +2,8 @@ import assert from "node:assert/strict";
import test from "node:test";
import {
analysisProjectIdOfThread,
myProjectConversationInfoOfThread,
myProjectConversationTypeFromSearchParams,
myProjectConversationTypeOfThread,
myProjectThreadMetadata,
@@ -23,6 +25,33 @@ test("project conversation type is read from persisted metadata", () => {
);
});
test("server-owned analysis project metadata resolves type and current project name", () => {
const thread = {
metadata: {
analysis_project_id: "project-1",
analysis_project_mode: "qa",
},
};
assert.deepEqual(
myProjectConversationInfoOfThread(
thread,
new Map([["project-1", "第一个项目"]]),
),
{
type: "qa",
projectId: "project-1",
projectName: "第一个项目",
},
);
assert.equal(analysisProjectIdOfThread(thread), "project-1");
assert.equal(
myProjectConversationInfoOfThread({
metadata: { analysis_project_id: "project-1" },
}),
null,
);
});
test("metadata wins over the compatibility context fallback", () => {
assert.equal(
myProjectConversationTypeOfThread({
@@ -44,3 +73,26 @@ test("new-chat route marker round-trips all supported project types", () => {
assert.equal(myProjectConversationTypeFromSearchParams(params), type);
}
});
test("project identity is preserved in metadata and route markers", () => {
assert.deepEqual(
myProjectThreadMetadata("solution", {
id: "solution-1",
name: "年度行动方案",
}),
{
project_source: "my-projects",
project_type: "solution",
my_project_id: "solution-1",
my_project_name: "年度行动方案",
},
);
const path = withMyProjectConversationMarker(
"/page/workspace/chats/new",
"strategy",
{ id: "strategy-1", name: "增长策略" },
);
const params = new URLSearchParams(path.split("?", 2)[1]);
assert.equal(params.get("projectId"), "strategy-1");
assert.equal(params.get("projectName"), "增长策略");
});
@@ -9,12 +9,22 @@ export const MY_PROJECT_THREAD_SOURCE = "my-projects" as const;
export const MY_PROJECT_THREAD_SOURCE_KEY = "project_source" as const;
export const MY_PROJECT_THREAD_TYPE_KEY = "project_type" as const;
export const MY_PROJECT_THREAD_ID_KEY = "my_project_id" as const;
export const MY_PROJECT_THREAD_NAME_KEY = "my_project_name" as const;
export const MY_PROJECT_QUERY_SOURCE_KEY = "projectSource" as const;
export const MY_PROJECT_QUERY_TYPE_KEY = "projectType" as const;
export const MY_PROJECT_QUERY_ID_KEY = "projectId" as const;
export const MY_PROJECT_QUERY_NAME_KEY = "projectName" as const;
export type MyProjectConversationType = "qa" | "strategy" | "solution";
export type MyProjectConversationInfo = {
type: MyProjectConversationType;
projectId: string | null;
projectName: string | null;
};
export const MY_PROJECT_CONVERSATION_LABELS: Record<
MyProjectConversationType,
string
@@ -30,34 +40,119 @@ const MY_PROJECT_CONVERSATION_TYPES = new Set<MyProjectConversationType>([
"solution",
]);
function typeFromRecord(
function nonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function markedInfoFromRecord(
value: Record<string, unknown> | null | undefined,
): MyProjectConversationType | null {
): MyProjectConversationInfo | null {
if (value?.[MY_PROJECT_THREAD_SOURCE_KEY] !== MY_PROJECT_THREAD_SOURCE) {
return null;
}
const type = value[MY_PROJECT_THREAD_TYPE_KEY];
return typeof type === "string" &&
MY_PROJECT_CONVERSATION_TYPES.has(type as MyProjectConversationType)
? (type as MyProjectConversationType)
: null;
if (
typeof type !== "string" ||
!MY_PROJECT_CONVERSATION_TYPES.has(type as MyProjectConversationType)
) {
return null;
}
return {
type: type as MyProjectConversationType,
projectId: nonEmptyString(value[MY_PROJECT_THREAD_ID_KEY]),
projectName: nonEmptyString(value[MY_PROJECT_THREAD_NAME_KEY]),
};
}
/**
* Project conversations created by the persisted analysis-project service use
* server-owned ``analysis_project_*`` metadata instead of the browser marker.
* Recognise both shapes so older project threads immediately gain the same UI.
*/
function analysisProjectInfoFromRecord(
value: Record<string, unknown> | null | undefined,
): MyProjectConversationInfo | null {
const projectId = nonEmptyString(value?.analysis_project_id);
const mode = value?.analysis_project_mode;
if (
!projectId ||
typeof mode !== "string" ||
!MY_PROJECT_CONVERSATION_TYPES.has(mode as MyProjectConversationType)
) {
return null;
}
return {
type: mode as MyProjectConversationType,
projectId,
projectName:
nonEmptyString(value?.analysis_project_name) ??
(typeof value?.analysis_project === "object" &&
value.analysis_project !== null
? nonEmptyString(
(value.analysis_project as Record<string, unknown>).name,
)
: null),
};
}
function infoFromRecord(
value: Record<string, unknown> | null | undefined,
): MyProjectConversationInfo | null {
return markedInfoFromRecord(value) ?? analysisProjectInfoFromRecord(value);
}
export function myProjectThreadMetadata(
type: MyProjectConversationType,
project?: { id?: string | null; name?: string | null },
): Record<string, unknown> {
return {
[MY_PROJECT_THREAD_SOURCE_KEY]: MY_PROJECT_THREAD_SOURCE,
[MY_PROJECT_THREAD_TYPE_KEY]: type,
...(nonEmptyString(project?.id)
? { [MY_PROJECT_THREAD_ID_KEY]: nonEmptyString(project?.id) }
: {}),
...(nonEmptyString(project?.name)
? { [MY_PROJECT_THREAD_NAME_KEY]: nonEmptyString(project?.name) }
: {}),
};
}
/** Read persisted metadata first; context is only a compatibility fallback. */
/**
* Read persisted metadata first; context is only a compatibility fallback.
* ``projectNames`` lets list UIs resolve names for legacy analysis-project
* threads whose immutable binding predates ``analysis_project_name``.
*/
export function myProjectConversationInfoOfThread(
thread: {
metadata?: Record<string, unknown> | null;
context?: Record<string, unknown> | null;
},
projectNames?: ReadonlyMap<string, string>,
): MyProjectConversationInfo | null {
const info = infoFromRecord(thread.metadata) ?? infoFromRecord(thread.context);
if (!info) return null;
const resolvedName =
info.projectName ??
(info.projectId ? nonEmptyString(projectNames?.get(info.projectId)) : null);
return resolvedName === info.projectName
? info
: { ...info, projectName: resolvedName };
}
export function myProjectConversationTypeOfThread(thread: {
metadata?: Record<string, unknown> | null;
context?: Record<string, unknown> | null;
}): MyProjectConversationType | null {
return typeFromRecord(thread.metadata) ?? typeFromRecord(thread.context);
return myProjectConversationInfoOfThread(thread)?.type ?? null;
}
export function analysisProjectIdOfThread(thread: {
metadata?: Record<string, unknown> | null;
context?: Record<string, unknown> | null;
}): string | null {
const metadataInfo = analysisProjectInfoFromRecord(thread.metadata);
if (metadataInfo) return metadataInfo.projectId;
return analysisProjectInfoFromRecord(thread.context)?.projectId ?? null;
}
export function myProjectConversationTypeFromSearchParams(params: {
@@ -77,6 +172,7 @@ export function myProjectConversationTypeFromSearchParams(params: {
export function withMyProjectConversationMarker(
path: string,
type: MyProjectConversationType,
project?: { id?: string | null; name?: string | null },
): string {
const hashIndex = path.indexOf("#");
const hash = hashIndex >= 0 ? path.slice(hashIndex) : "";
@@ -89,6 +185,10 @@ export function withMyProjectConversationMarker(
);
params.set(MY_PROJECT_QUERY_SOURCE_KEY, MY_PROJECT_THREAD_SOURCE);
params.set(MY_PROJECT_QUERY_TYPE_KEY, type);
const projectId = nonEmptyString(project?.id);
const projectName = nonEmptyString(project?.name);
if (projectId) params.set(MY_PROJECT_QUERY_ID_KEY, projectId);
if (projectName) params.set(MY_PROJECT_QUERY_NAME_KEY, projectName);
const search = params.toString();
return `${pathname}${search ? `?${search}` : ""}${hash}`;
}
@@ -0,0 +1,28 @@
import assert from "node:assert/strict";
import test from "node:test";
import { listPathOfThread } from "./utils";
import type { AgentThread } from "./types";
const projectThread = {
thread_id: "chat-1",
metadata: { analysis_project_id: "project-1" },
} as unknown as AgentThread;
test("project history keeps the My Projects workspace and return destination", () => {
for (const current of ["", "/page/strategy/qa/projects", "/page/strategy/qa/projects/groups/project-1/chats/chat-1"]) {
assert.equal(listPathOfThread(projectThread, current), "/page/strategy/qa/projects/groups/project-1/chats/chat-1");
}
});
test("solution project history stays in the solution collection", () => {
const solutionThread = {
...projectThread,
metadata: { ...projectThread.metadata, analysis_project_mode: "solution" },
} as unknown as AgentThread;
assert.equal(listPathOfThread(solutionThread), "/page/strategy/qa/projects/solutions/project-1/chats/chat-1");
});
test("ordinary and bootstrap chat routes remain available", () => {
assert.equal(listPathOfThread({ thread_id: "chat-2", metadata: {} } as unknown as AgentThread, "/page/strategy/qa/projects"), "/page/workspace/chats/chat-2");
assert.equal(listPathOfThread({ thread_id: "setup", metadata: { thread_type: "bootstrap" } } as unknown as AgentThread), "/page/workspace/agents/new/setup");
});
+4 -3
View File
@@ -14,12 +14,13 @@ export interface AgentThreadContext extends Record<string, unknown> {
thread_id: string;
model_name: string | undefined;
thinking_enabled: boolean;
/** Flash mode: force vLLM/Qwen ``enable_thinking=false`` even without a per-model disable shape. */
thinking_force_disabled?: boolean;
is_plan_mode: boolean;
subagent_enabled: boolean;
reasoning_effort?: "minimal" | "low" | "medium" | "high";
writing_mode?: boolean;
writing_artifact_path?: string;
memory_injection_enabled?: boolean;
rag_mode_enabled?: boolean;
/** Per-request switch for context compression (上下文压缩). Default off. */
summarization_enabled?: boolean;
@@ -29,8 +30,8 @@ export interface AgentThreadContext extends Record<string, unknown> {
llmwiki_knowledge_base_ids?: string[];
/** Assistant knowledge-base ids selected for this conversation. */
assistant_knowledge_base_ids?: string[];
/** Direct RAG modes handled in the frontend; these requests never enter Lead Agent. */
execution_profile?: "single_round_rag" | "multi_round_rag";
/** Frontend-only execution paths that never enter Lead Agent (RAG / 极速直连). */
execution_profile?: "single_round_rag" | "multi_round_rag" | "direct";
agent_id?: string;
/** @deprecated kept for reading legacy thread metadata; prefer agent_id */
agent_name?: string;
@@ -0,0 +1,312 @@
import type { Message } from "@langchain/langgraph-sdk";
import { useCallback, useEffect, useRef, useState } from "react";
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import { readRuntimeFlag } from "@/env";
import { createClientId } from "@/lib/client-id";
import { textOfMessage } from "./utils";
const STORAGE_PREFIX = "workspace.direct-chat.";
const MAX_HISTORY_MESSAGES = 20;
export function isDirectChatProfile(context: unknown): boolean {
return (
typeof context === "object" &&
context !== null &&
(context as { execution_profile?: unknown }).execution_profile === "direct"
);
}
/** 是否在模式菜单展示「极速」。仅 runtime-config.js 显式为 "true" 时开启,默认关闭。 */
export function isTurboModeVisible(): boolean {
return readRuntimeFlag("VITE_TURBO_MODE_VISIBLE");
}
function storageKey(threadId: string) {
return `${STORAGE_PREFIX}${threadId}`;
}
function readStoredMessages(threadId: string): Message[] {
if (typeof window === "undefined" || !threadId) return [];
try {
const raw = window.sessionStorage.getItem(storageKey(threadId));
const parsed: unknown = raw ? JSON.parse(raw) : [];
return Array.isArray(parsed) ? (parsed as Message[]) : [];
} catch {
return [];
}
}
function writeStoredMessages(threadId: string, messages: Message[]) {
if (typeof window === "undefined" || !threadId) return;
try {
window.sessionStorage.setItem(storageKey(threadId), JSON.stringify(messages));
} catch {
// Quota errors must never interrupt an already-running answer.
}
}
function isAbortError(error: unknown): boolean {
return error instanceof DOMException
? error.name === "AbortError"
: error instanceof Error && error.name === "AbortError";
}
function historyFromMessages(messages: Message[]): Array<{
role: "user" | "assistant";
content: string;
}> {
const history: Array<{ role: "user" | "assistant"; content: string }> = [];
for (const message of messages) {
if (message.type !== "human" && message.type !== "ai") continue;
const content = textOfMessage(message)?.trim();
if (!content) continue;
history.push({
role: message.type === "human" ? "user" : "assistant",
content,
});
}
return history.slice(-MAX_HISTORY_MESSAGES);
}
function parseSseBlocks(buffer: string): {
events: Array<{ event?: string; data: string }>;
rest: string;
} {
const parts = buffer.split("\n\n");
const rest = parts.pop() ?? "";
const events: Array<{ event?: string; data: string }> = [];
for (const part of parts) {
let event: string | undefined;
const dataLines: string[] = [];
for (const line of part.split("\n")) {
if (line.startsWith(":")) continue;
if (line.startsWith("event:")) event = line.slice(6).trim();
else if (line.startsWith("data:")) dataLines.push(line.slice(5).trimStart());
}
if (dataLines.length > 0) {
events.push({ event, data: dataLines.join("\n") });
}
}
return { events, rest };
}
async function readErrorDetail(response: Response): Promise<string> {
try {
const payload: unknown = await response.json();
if (
payload &&
typeof payload === "object" &&
"detail" in payload &&
typeof (payload as { detail: unknown }).detail === "string"
) {
return (payload as { detail: string }).detail;
}
} catch {
// Fall through to status text.
}
return `极速问答失败(${response.status})`;
}
/**
* Frontend-only adapter that talks to Gateway ``POST /api/direct-chat/stream``.
* It never submits a LangGraph run or touches the lead-agent stack.
*/
export function useDirectChatMessages(threadId: string | undefined) {
const [messages, setMessages] = useState<Message[]>(() =>
readStoredMessages(threadId ?? ""),
);
const messagesRef = useRef(messages);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const controllerRef = useRef<AbortController | null>(null);
useEffect(() => {
const stored = readStoredMessages(threadId ?? "");
messagesRef.current = stored;
setMessages(stored);
setError(null);
}, [threadId]);
useEffect(() => {
messagesRef.current = messages;
}, [messages]);
useEffect(() => () => controllerRef.current?.abort(), []);
const updateMessages = useCallback(
(targetThreadId: string, updater: (current: Message[]) => Message[]) => {
setMessages((current) => {
const next = updater(current);
messagesRef.current = next;
writeStoredMessages(targetThreadId, next);
return next;
});
},
[],
);
const stop = useCallback(() => controllerRef.current?.abort(), []);
const submit = useCallback(
async ({
targetThreadId,
query,
modelName,
}: {
targetThreadId: string;
query: string;
modelName?: string;
}) => {
if (isLoading) return;
const question = query.trim();
if (!question) return;
const humanId = createClientId("direct-human");
const assistantId = createClientId("direct-ai");
const clientTs = new Date().toISOString();
const history = historyFromMessages(messagesRef.current);
const userMessage: Message = {
id: humanId,
type: "human",
content: [{ type: "text", text: question }],
additional_kwargs: { client_ts: clientTs, direct_chat: true },
};
const initialAssistant: Message = {
id: assistantId,
type: "ai",
content: "",
additional_kwargs: { direct_chat: true },
};
updateMessages(targetThreadId, (current) => [
...current,
userMessage,
initialAssistant,
]);
setError(null);
setIsLoading(true);
const controller = new AbortController();
controllerRef.current = controller;
let answer = "";
const refreshAssistant = (content: string) => {
updateMessages(targetThreadId, (current) =>
current.map((item) =>
item.id === assistantId
? ({
...item,
content,
additional_kwargs: {
...item.additional_kwargs,
direct_chat: true,
},
} as Message)
: item,
),
);
};
try {
const response = await apiFetch(
`${getBackendBaseURL()}/api/direct-chat/stream`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
signal: controller.signal,
body: JSON.stringify({
model_name: modelName,
messages: [...history, { role: "user", content: question }],
}),
},
);
if (!response.ok) {
throw new Error(await readErrorDetail(response));
}
if (!response.body) {
throw new Error("极速问答未返回数据流。");
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const parsed = parseSseBlocks(buffer);
buffer = parsed.rest;
for (const block of parsed.events) {
let payload: unknown = null;
try {
payload = JSON.parse(block.data);
} catch {
continue;
}
if (block.event === "error") {
const detail =
payload &&
typeof payload === "object" &&
"detail" in payload &&
typeof (payload as { detail: unknown }).detail === "string"
? (payload as { detail: string }).detail
: "极速问答失败。";
throw new Error(detail);
}
if (
block.event === "delta" &&
payload &&
typeof payload === "object" &&
"text" in payload &&
typeof (payload as { text: unknown }).text === "string"
) {
answer += (payload as { text: string }).text;
refreshAssistant(answer);
}
if (
block.event === "result" &&
payload &&
typeof payload === "object" &&
"answer" in payload &&
typeof (payload as { answer: unknown }).answer === "string"
) {
answer = (payload as { answer: string }).answer;
refreshAssistant(answer);
}
}
}
if (!answer) {
throw new Error("模型未返回任何文本。");
}
} catch (caught) {
if (!isAbortError(caught)) {
const message =
caught instanceof Error ? caught.message : "极速问答失败。";
setError(message);
updateMessages(targetThreadId, (current) =>
current.map((item) =>
item.id === assistantId
? ({
...item,
content: answer || message,
additional_kwargs: {
...item.additional_kwargs,
direct_chat: true,
},
} as Message)
: item,
),
);
}
} finally {
if (controllerRef.current === controller) controllerRef.current = null;
setIsLoading(false);
}
},
[isLoading, updateMessages],
);
return { messages, isLoading, error, submit, stop };
}
@@ -0,0 +1,36 @@
import { useMemo } from "react";
import { useAnalysisProjects } from "@/core/analysis-projects/api";
import { analysisProjectIdOfThread } from "./project-metadata";
type ProjectTaggedThread = {
metadata?: Record<string, unknown> | null;
context?: Record<string, unknown> | null;
};
/**
* Resolve the current display name for persisted project threads.
*
* The project id/mode lives on every thread, while older rows predate the
* denormalised project name. The project list is therefore the source of
* truth (and also reflects project renames immediately). TanStack Query
* deduplicates this lookup across every recent-conversation surface.
*/
export function useProjectThreadNames(
threads: readonly ProjectTaggedThread[],
): ReadonlyMap<string, string> {
const hasAnalysisProjectThread = useMemo(
() => threads.some((thread) => analysisProjectIdOfThread(thread) !== null),
[threads],
);
const { data } = useAnalysisProjects({ enabled: hasAnalysisProjectThread });
return useMemo(
() =>
new Map(
(data?.projects ?? []).map((project) => [project.id, project.name]),
),
[data?.projects],
);
}
@@ -131,6 +131,18 @@ test("tool_finished marks success and finalize guards only active runs", () => {
assert.equal(peekResearchReportStream(tid2), null);
});
test("finalize after report_completed counts as success when tool_finished never arrives", () => {
const tid = nextThread();
ingestAll(tid, [
{ type: "report_delta", jobId: "jdone2", payload: { delta: "全文", offset: 0 } },
{ type: "report_completed", jobId: "jdone2", payload: { sourceCount: 1 } },
]);
assert.equal(peekResearchReportStream(tid)?.status, "active");
assert.equal(peekResearchReportStream(tid)?.phase, "done");
finalizeResearchReportStream(tid);
assert.equal(peekResearchReportStream(tid)?.status, "success");
});
test("progress tool calls reveal stages stepwise and mark interruption", () => {
const active = streamOf({
jobId: "j6",
@@ -116,6 +116,13 @@ export function clearResearchReportStream(threadId: string) {
export function finalizeResearchReportStream(threadId: string) {
const store = getStore(threadId);
if (store.state?.status !== "active") return;
// 写作已完成(report_completed)但 tool_finished 可能被脆弱 SSE 丢掉:
// 流正常结束应记为成功,否则工作台不会插入 report.md 文件卡片。
if (store.state.phase === "done" && store.state.reportText.trim()) {
store.state = { ...store.state, status: "success", error: null };
emitNow(store);
return;
}
store.state = { ...store.state, status: "error", error: store.state.error ?? "已中断" };
emitNow(store);
}
+31 -7
View File
@@ -45,10 +45,17 @@ export function pathOfThread(
base = "/page/workspace",
) {
const threadId = typeof thread === "string" ? thread : thread.thread_id;
const projectId =
typeof thread === "string"
? undefined
: thread.metadata?.analysis_project_id;
if (typeof projectId === "string" && projectId) {
return `${base}/projects/${encodeURIComponent(projectId)}/chats/${encodeURIComponent(threadId)}`;
}
const agentRef =
typeof thread === "string"
? pickAgentRef(context, null)
: agentRefOfThread(thread) ?? pickAgentRef(context, null);
: (agentRefOfThread(thread) ?? pickAgentRef(context, null));
return agentRef
? `${base}/agents/${encodeURIComponent(agentRef)}/chats/${threadId}`
@@ -120,7 +127,9 @@ export function isSetupWizardThread(thread: {
metadata?: Record<string, unknown> | null;
}): boolean {
const threadType = thread.metadata?.thread_type;
return threadType === "writing_setup" || threadType === "report_structure_setup";
return (
threadType === "writing_setup" || threadType === "report_structure_setup"
);
}
/** 侧栏 / 全部对话应隐藏的系统 / 向导会话(bootstrap 除外,仍会展示)。 */
@@ -130,7 +139,10 @@ export function isHiddenFromRecentChats(thread: {
const metadata = thread.metadata;
if (metadata?.system === true) return true;
const threadType = metadata?.thread_type;
return typeof threadType === "string" && HIDDEN_FROM_RECENT_CHAT_TYPES.has(threadType);
return (
typeof threadType === "string" &&
HIDDEN_FROM_RECENT_CHAT_TYPES.has(threadType)
);
}
/** 调用方显式按 thread_type / system 过滤时,不要再套一层「最近对话」隐藏。 */
@@ -151,18 +163,28 @@ export function bootstrapResumePath(threadId: string): string {
}
/** 列表项点击目标:bootstrap 会话回创建页恢复,其余走普通问答路由。 */
export function listPathOfThread(thread: AgentThread): string {
export function listPathOfThread(thread: AgentThread, currentPath = ""): string {
const projectId = thread.metadata?.analysis_project_id;
if (typeof projectId === "string" && projectId) {
const isSolution = thread.metadata?.analysis_project_mode === "solution";
const collection = isSolution ? "solutions" : "groups";
return `/page/strategy/qa/projects/${collection}/${encodeURIComponent(projectId)}/chats/${encodeURIComponent(thread.thread_id)}`;
}
return isBootstrapThread(thread)
? bootstrapResumePath(thread.thread_id)
: pathOfThread(thread);
}
export function isThreadPinned(thread: AgentThread): boolean {
return (thread.metadata as Record<string, unknown> | null)?.is_pinned === true;
return (
(thread.metadata as Record<string, unknown> | null)?.is_pinned === true
);
}
export function isThreadFavorite(thread: AgentThread): boolean {
return (thread.metadata as Record<string, unknown> | null)?.is_favorite === true;
return (
(thread.metadata as Record<string, unknown> | null)?.is_favorite === true
);
}
/**
@@ -179,7 +201,9 @@ export function sortThreadsPinnedFirst(threads: AgentThread[]): AgentThread[] {
return [...pinned, ...rest];
}
export function sanitizeThreadTitleForDisplay(title: string | null | undefined) {
export function sanitizeThreadTitleForDisplay(
title: string | null | undefined,
) {
if (!title) return "Untitled";
const cleaned = title
@@ -113,6 +113,17 @@ test("normal mode auto-opens every new artifact like before writing mode existed
);
});
test("Word artifacts stay download-only in every mode", () => {
assert.equal(
shouldAutoOpenWritingArtifact("/mnt/user-data/outputs/fixed.docx"),
false,
);
assert.equal(
shouldAutoOpenWritingArtifact("/mnt/user-data/outputs/source.doc", [], true),
false,
);
});
test("a later user turn releases report.md live edits", () => {
const followUp = [
{ type: "human", content: "写一篇论文" },
@@ -1,5 +1,7 @@
/** Only the writing-mode deliverable counts as a follow-up edit target. */
import { isWordDocumentFile } from "@/core/utils/files";
const OUTPUTS_PREFIX = "/mnt/user-data/outputs";
export function normalizeWritingPath(path: string | null | undefined): string {
@@ -105,6 +107,11 @@ export function shouldAutoOpenWritingArtifact(
if (!path) {
return false;
}
// DOC/DOCX are delivered as download-only files. They have no meaningful
// sandbox preview, so never let a completed Word artifact open the panel.
if (isWordDocumentFile(path)) {
return false;
}
if (!writingMode) {
return true;
}
+79 -2
View File
@@ -4,6 +4,12 @@
import { getBackendBaseURL } from "../config";
import { apiFetch } from "../api";
import { assertChatUploadFiles } from "./file-validation";
import {
cloneDefaultUploadPolicy,
normalizeUploadPolicy,
type UploadPolicy,
} from "./policy";
export interface UploadedFileInfo {
filename: string;
@@ -34,8 +40,73 @@ async function readErrorDetail(
response: Response,
fallback: string,
): Promise<string> {
const error = await response.json().catch(() => ({ detail: fallback }));
return error.detail ?? fallback;
const error = (await response
.json()
.catch(() => ({ detail: fallback }))) as { detail?: unknown };
const detail = error.detail;
if (typeof detail === "string" && detail.trim()) return detail;
if (Array.isArray(detail)) {
const messages = detail
.map((item) => {
if (!item || typeof item !== "object") return String(item ?? "");
const record = item as { loc?: unknown; msg?: unknown };
const location = Array.isArray(record.loc)
? record.loc
.filter((part) => part !== "body")
.map(String)
.join(".")
: "";
const message = typeof record.msg === "string" ? record.msg : "参数无效";
return location ? `${location}:${message}` : message;
})
.filter(Boolean);
if (messages.length > 0) return messages.join(";");
}
if (detail && typeof detail === "object") {
const message = (detail as { message?: unknown }).message;
if (typeof message === "string" && message.trim()) return message;
}
return fallback;
}
/**
* Read the effective chat upload policy. Upload stays usable during a rolling
* backend upgrade: a missing/unavailable endpoint falls back to bounded local
* defaults instead of silently becoming unlimited.
*/
export async function getUploadPolicy(): Promise<UploadPolicy> {
try {
const response = await apiFetch(`${getBackendBaseURL()}/api/upload-policy`);
if (!response.ok) return cloneDefaultUploadPolicy();
return normalizeUploadPolicy(await response.json());
} catch {
return cloneDefaultUploadPolicy();
}
}
export async function updateUploadPolicy(
policy: UploadPolicy,
): Promise<UploadPolicy> {
const response = await apiFetch(`${getBackendBaseURL()}/api/upload-policy`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(policy),
});
if (!response.ok) {
throw new Error(await readErrorDetail(response, "保存上传设置失败"));
}
return normalizeUploadPolicy(await response.json());
}
export async function resetUploadPolicy(): Promise<UploadPolicy> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/upload-policy/reset`,
{ method: "POST" },
);
if (!response.ok) {
throw new Error(await readErrorDetail(response, "恢复默认上传设置失败"));
}
return normalizeUploadPolicy(await response.json());
}
/**
@@ -45,6 +116,12 @@ export async function uploadFiles(
threadId: string,
files: File[],
): Promise<UploadResponse> {
// Defense in depth for upload paths that do not use PromptInput (for
// example roundtable orchestration). This API is chat-specific; skill ZIP,
// config JSON and other administration uploads use separate endpoints.
const policy = await getUploadPolicy();
assertChatUploadFiles(files, policy);
const formData = new FormData();
files.forEach((file) => {
@@ -0,0 +1,116 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
getUploadPolicyGroup,
validateChatUploadFiles,
type UploadFileLike,
} from "./file-validation";
import {
DEFAULT_UPLOAD_POLICY,
MEBIBYTE,
normalizeUploadPolicy,
} from "./policy";
function file(
name: string,
size = 1,
type = "application/octet-stream",
): UploadFileLike {
return { name, size, type };
}
test("fallback policy matches the backend defaults", () => {
assert.equal(DEFAULT_UPLOAD_POLICY.max_files_per_message, 20);
assert.equal(DEFAULT_UPLOAD_POLICY.max_total_bytes, 100 * MEBIBYTE);
assert.equal(DEFAULT_UPLOAD_POLICY.groups.document.max_bytes, 10 * MEBIBYTE);
assert.equal(DEFAULT_UPLOAD_POLICY.groups.image.max_bytes, 10 * MEBIBYTE);
assert.equal(DEFAULT_UPLOAD_POLICY.groups.spreadsheet.max_bytes, 5 * MEBIBYTE);
assert.deepEqual(DEFAULT_UPLOAD_POLICY.groups.document.extensions, [
".txt",
".md",
".doc",
".docx",
".ppt",
".pptx",
".pdf",
]);
assert.deepEqual(DEFAULT_UPLOAD_POLICY.groups.spreadsheet.extensions, [
".xls",
".xlsx",
".csv",
]);
});
test("extension matching is case-insensitive and does not depend on MIME", () => {
assert.equal(getUploadPolicyGroup(file("REPORT.DOCX", 10, "")), "document");
assert.equal(getUploadPolicyGroup(file("table.CSV", 10, "")), "spreadsheet");
assert.equal(getUploadPolicyGroup(file("photo.PNG", 10, "")), "image");
});
test("unsupported extensions and group limits are rejected", () => {
const result = validateChatUploadFiles([
file("archive.zip", 10),
file("huge.xlsx", 5 * MEBIBYTE + 1),
file("ok.pdf", 10 * MEBIBYTE),
]);
assert.deepEqual(result.accepted.map((item) => item.name), ["ok.pdf"]);
assert.deepEqual(result.issues.map((item) => item.code), [
"unsupported_type",
"max_file_size",
]);
});
test("count validation includes attachments already selected", () => {
const existing = Array.from({ length: 19 }, (_, index) =>
file(`old-${index}.txt`),
);
const result = validateChatUploadFiles(
[file("twentieth.txt"), file("twenty-first.txt")],
existing,
);
assert.deepEqual(result.accepted.map((item) => item.name), ["twentieth.txt"]);
assert.equal(result.issues[0]?.code, "max_files");
});
test("total byte budget is enforced incrementally", () => {
const policy = normalizeUploadPolicy({
...DEFAULT_UPLOAD_POLICY,
max_total_bytes: 12,
groups: {
...DEFAULT_UPLOAD_POLICY.groups,
document: {
...DEFAULT_UPLOAD_POLICY.groups.document,
max_bytes: 12,
},
},
});
const result = validateChatUploadFiles(
[file("a.txt", 7), file("b.txt", 6), file("c.txt", 5)],
[],
policy,
);
assert.deepEqual(result.accepted.map((item) => item.name), ["a.txt", "c.txt"]);
assert.equal(result.issues[0]?.code, "max_total_size");
});
test("malformed policy values stay bounded and soft output cannot exceed hard", () => {
const policy = normalizeUploadPolicy({
max_files_per_message: 0,
max_total_bytes: -1,
large_file: {
threshold_bytes: 0,
max_result_chars: 99_000,
hard_max_result_chars: 2_000,
},
});
assert.equal(policy.max_files_per_message, 20);
assert.equal(policy.max_total_bytes, 100 * MEBIBYTE);
assert.equal(policy.large_file.threshold_bytes, MEBIBYTE);
assert.equal(policy.large_file.max_result_chars, 2_000);
assert.equal(policy.large_file.hard_max_result_chars, 2_000);
});
@@ -1,3 +1,9 @@
import {
DEFAULT_UPLOAD_POLICY,
type UploadPolicy,
type UploadPolicyGroupKey,
} from "./policy";
const MACOS_APP_BUNDLE_CONTENT_TYPES = new Set([
"",
"application/octet-stream",
@@ -32,3 +38,146 @@ export function splitUnsupportedUploadFiles(fileList: File[] | FileList) {
message: rejected.length > 0 ? MACOS_APP_BUNDLE_UPLOAD_MESSAGE : undefined,
};
}
export type UploadFileLike = Pick<File, "name" | "size" | "type">;
export type UploadValidationCode =
| "unsupported_package"
| "unsupported_type"
| "max_file_size"
| "max_files"
| "max_total_size";
export interface UploadValidationIssue {
code: UploadValidationCode;
message: string;
filename?: string;
}
export interface UploadValidationResult<T extends UploadFileLike> {
accepted: T[];
rejected: T[];
issues: UploadValidationIssue[];
}
const GROUP_LABELS: Record<UploadPolicyGroupKey, string> = {
document: "文档",
image: "图片",
spreadsheet: "表格",
};
function formatBytes(bytes: number): string {
if (bytes >= 1024 * 1024) {
return `${Number((bytes / (1024 * 1024)).toFixed(2))} MiB`;
}
if (bytes >= 1024) return `${Number((bytes / 1024).toFixed(2))} KiB`;
return `${bytes} B`;
}
export function getUploadFileExtension(filename: string): string {
const dot = filename.lastIndexOf(".");
return dot > -1 ? filename.slice(dot).toLowerCase() : "";
}
export function getUploadPolicyGroup(
file: Pick<UploadFileLike, "name">,
policy: UploadPolicy = DEFAULT_UPLOAD_POLICY,
): UploadPolicyGroupKey | null {
const extension = getUploadFileExtension(file.name);
if (!extension) return null;
for (const group of Object.keys(policy.groups) as UploadPolicyGroupKey[]) {
if (policy.groups[group].extensions.includes(extension)) return group;
}
return null;
}
/**
* Apply the chat-upload policy to every path that can add attachments. Valid
* files are kept while invalid/excess files are reported and omitted.
*/
export function validateChatUploadFiles<T extends UploadFileLike>(
fileList: T[] | ArrayLike<T>,
existingFiles: UploadFileLike[] = [],
policy: UploadPolicy = DEFAULT_UPLOAD_POLICY,
): UploadValidationResult<T> {
const incoming = Array.from(fileList);
const accepted: T[] = [];
const rejected: T[] = [];
const issues: UploadValidationIssue[] = [];
let totalBytes = existingFiles.reduce(
(total, file) => total + Math.max(0, file.size),
0,
);
for (const file of incoming) {
let issue: UploadValidationIssue | undefined;
if (isLikelyMacOSAppBundle(file)) {
issue = {
code: "unsupported_package",
filename: file.name,
message: MACOS_APP_BUNDLE_UPLOAD_MESSAGE,
};
} else {
const group = getUploadPolicyGroup(file, policy);
if (!group) {
issue = {
code: "unsupported_type",
filename: file.name,
message: `不支持文件“${file.name}”的格式。允许的扩展名:${Object.values(
policy.groups,
)
.flatMap((item) => item.extensions)
.join("、")}`,
};
} else if (file.size > policy.groups[group].max_bytes) {
issue = {
code: "max_file_size",
filename: file.name,
message: `${GROUP_LABELS[group]}“${file.name}”超过 ${formatBytes(
policy.groups[group].max_bytes,
)} 的单文件上限。`,
};
} else if (
existingFiles.length + accepted.length >=
policy.max_files_per_message
) {
issue = {
code: "max_files",
filename: file.name,
message: `每条消息最多上传 ${policy.max_files_per_message} 个文件。`,
};
} else if (totalBytes + file.size > policy.max_total_bytes) {
issue = {
code: "max_total_size",
filename: file.name,
message: `单条消息的附件总大小不能超过 ${formatBytes(
policy.max_total_bytes,
)}。`,
};
}
if (!issue) totalBytes += Math.max(0, file.size);
}
if (issue) {
rejected.push(file);
issues.push(issue);
} else {
accepted.push(file);
}
}
return { accepted, rejected, issues };
}
export function assertChatUploadFiles(
files: UploadFileLike[],
policy: UploadPolicy = DEFAULT_UPLOAD_POLICY,
): void {
const result = validateChatUploadFiles(files, [], policy);
if (result.rejected.length > 0) {
throw new Error(
Array.from(new Set(result.issues.map((issue) => issue.message))).join("\n"),
);
}
}
+34
View File
@@ -7,11 +7,45 @@ import { useCallback } from "react";
import {
deleteUploadedFile,
getUploadPolicy,
listUploadedFiles,
resetUploadPolicy,
updateUploadPolicy,
uploadFiles,
type UploadedFileInfo,
type UploadResponse,
} from "./api";
import type { UploadPolicy } from "./policy";
export const uploadPolicyQueryKey = ["uploads", "policy"] as const;
export function useUploadPolicy() {
return useQuery({
queryKey: uploadPolicyQueryKey,
queryFn: getUploadPolicy,
staleTime: 5 * 60 * 1000,
});
}
export function useUpdateUploadPolicy() {
const queryClient = useQueryClient();
return useMutation<UploadPolicy, Error, UploadPolicy>({
mutationFn: updateUploadPolicy,
onSuccess: (policy) => {
queryClient.setQueryData(uploadPolicyQueryKey, policy);
},
});
}
export function useResetUploadPolicy() {
const queryClient = useQueryClient();
return useMutation<UploadPolicy, Error, void>({
mutationFn: resetUploadPolicy,
onSuccess: (policy) => {
queryClient.setQueryData(uploadPolicyQueryKey, policy);
},
});
}
/**
* Hook to upload files
+1
View File
@@ -5,4 +5,5 @@
export * from "./api";
export * from "./file-validation";
export * from "./hooks";
export * from "./policy";
export * from "./prompt-input-files";
+141
View File
@@ -0,0 +1,141 @@
export const MEBIBYTE = 1024 * 1024;
export type UploadPolicyGroupKey = "document" | "image" | "spreadsheet";
export interface UploadPolicyGroup {
max_bytes: number;
extensions: string[];
}
export interface UploadPolicy {
max_files_per_message: number;
max_total_bytes: number;
groups: Record<UploadPolicyGroupKey, UploadPolicyGroup>;
large_file: {
threshold_bytes: number;
max_result_chars: number;
hard_max_result_chars: number;
};
}
export const DEFAULT_UPLOAD_POLICY: UploadPolicy = {
max_files_per_message: 20,
max_total_bytes: 100 * MEBIBYTE,
groups: {
document: {
max_bytes: 10 * MEBIBYTE,
extensions: [".txt", ".md", ".doc", ".docx", ".ppt", ".pptx", ".pdf"],
},
image: {
max_bytes: 10 * MEBIBYTE,
extensions: [".jpg", ".jpeg", ".png"],
},
spreadsheet: {
max_bytes: 5 * MEBIBYTE,
extensions: [".xls", ".xlsx", ".csv"],
},
},
large_file: {
threshold_bytes: MEBIBYTE,
max_result_chars: 12_000,
hard_max_result_chars: 32_000,
},
};
function positiveInteger(value: unknown, fallback: number): number {
const parsed = typeof value === "number" ? value : Number(value);
return Number.isFinite(parsed) && parsed > 0
? Math.floor(parsed)
: fallback;
}
export function normalizeUploadExtension(extension: string): string {
const normalized = extension.trim().toLowerCase();
if (!normalized) return "";
return normalized.startsWith(".") ? normalized : `.${normalized}`;
}
function normalizeGroup(
value: unknown,
fallback: UploadPolicyGroup,
): UploadPolicyGroup {
const candidate =
value && typeof value === "object"
? (value as Partial<UploadPolicyGroup>)
: {};
const extensions = Array.isArray(candidate.extensions)
? Array.from(
new Set(
candidate.extensions
.filter((item): item is string => typeof item === "string")
.map(normalizeUploadExtension)
.filter(Boolean),
),
)
: [];
return {
max_bytes: positiveInteger(candidate.max_bytes, fallback.max_bytes),
extensions: extensions.length > 0 ? extensions : [...fallback.extensions],
};
}
/**
* Treat server configuration as untrusted input. A partial/malformed response
* must never turn into an unlimited upload policy in the browser.
*/
export function normalizeUploadPolicy(value: unknown): UploadPolicy {
const candidate =
value && typeof value === "object" ? (value as Partial<UploadPolicy>) : {};
const groups =
candidate.groups && typeof candidate.groups === "object"
? candidate.groups
: ({} as Partial<UploadPolicy["groups"]>);
const largeFile: Partial<UploadPolicy["large_file"]> =
candidate.large_file && typeof candidate.large_file === "object"
? candidate.large_file
: {};
const hardMax = positiveInteger(
largeFile.hard_max_result_chars,
DEFAULT_UPLOAD_POLICY.large_file.hard_max_result_chars,
);
const requestedSoftMax = positiveInteger(
largeFile.max_result_chars,
DEFAULT_UPLOAD_POLICY.large_file.max_result_chars,
);
return {
max_files_per_message: positiveInteger(
candidate.max_files_per_message,
DEFAULT_UPLOAD_POLICY.max_files_per_message,
),
max_total_bytes: positiveInteger(
candidate.max_total_bytes,
DEFAULT_UPLOAD_POLICY.max_total_bytes,
),
groups: {
document: normalizeGroup(
groups.document,
DEFAULT_UPLOAD_POLICY.groups.document,
),
image: normalizeGroup(groups.image, DEFAULT_UPLOAD_POLICY.groups.image),
spreadsheet: normalizeGroup(
groups.spreadsheet,
DEFAULT_UPLOAD_POLICY.groups.spreadsheet,
),
},
large_file: {
threshold_bytes: positiveInteger(
largeFile.threshold_bytes,
DEFAULT_UPLOAD_POLICY.large_file.threshold_bytes,
),
max_result_chars: Math.min(requestedSoftMax, hardMax),
hard_max_result_chars: hardMax,
},
};
}
export function cloneDefaultUploadPolicy(): UploadPolicy {
return normalizeUploadPolicy(DEFAULT_UPLOAD_POLICY);
}
+7
View File
@@ -152,6 +152,13 @@ export function getFileExtension(filepath: string) {
return filepath.split(".").pop()!.toLocaleLowerCase();
}
/** Word files are download-only artifacts: browsers cannot preview them reliably. */
export function isWordDocumentFile(filepath: string): boolean {
const pathname = filepath.split(/[?#]/, 1)[0] ?? filepath;
const extension = getFileExtension(pathname);
return extension === "doc" || extension === "docx";
}
const IMAGE_EXTENSIONS = new Set([
"png",
"jpg",
@@ -46,3 +46,26 @@ test("prepareGeneratedHtml injects the ES5 chart size shim and anchor handler",
assert.match(out, /getElementsByTagName\("canvas"\)/);
assert.doesNotMatch(out, /\?\.|=>|`/);
});
test("does not inject visualization runtimes into an ordinary HTML page", () => {
const html = `<!doctype html><html><body><h1>普通页面</h1></body></html>`;
const out = prepareGeneratedHtml(html);
assert.doesNotMatch(out, /data-df-html-runtime/);
assert.doesNotMatch(out, /\/assets\/(?:mermaid|echarts|leaflet)/);
});
test("keeps the explicit Gateway HTML runtime used by new pages", () => {
const html = `<!doctype html><html><head>
<link rel="stylesheet" href="/api/public/html-runtime/leaflet.css">
<script defer src="/api/public/html-runtime/leaflet.js"></script>
<script defer src="/api/public/html-runtime/deerflow-runtime.js"></script>
</head><body><div id="map" class="df-map"></div>
<script type="application/json" data-df-map="map">{"markers":[]}</script></body></html>`;
const out = prepareGeneratedHtml(html);
assert.match(out, /\/api\/public\/html-runtime\/leaflet\.js/);
assert.match(out, /\/api\/public\/html-runtime\/deerflow-runtime\.js/);
assert.doesNotMatch(out, /data-df-html-runtime=/);
assert.doesNotMatch(out, /localhost\/assets\/leaflet/);
});
@@ -13,8 +13,144 @@ import { injectInPageAnchorHandler } from "./markdown";
const MAX_PASSES = 12;
export function prepareGeneratedHtml(html: string): string {
return injectInPageAnchorHandler(injectLegacyChartShim(downlevelModernColors(html ?? "")));
export type GeneratedHtmlRuntimeAssets = {
g6ScriptUrl: string;
mermaidScriptUrl: string;
echartsScriptUrl: string;
echartsChinaGeoJson: unknown;
mapTileUrl: string;
mapTileAttribution: string;
leafletScriptUrl: string;
leafletStyleText: string;
};
export function prepareGeneratedHtml(
html: string,
): string {
const prepared = injectLegacyChartShim(downlevelModernColors(html ?? ""));
return injectInPageAnchorHandler(prepared);
}
const KNOWN_RUNTIME_SCRIPT_RE = /<script\b([^>]*?)\bsrc\s*=\s*(["'])([^"']+)\2([^>]*)>\s*<\/script\s*>/gi;
const KNOWN_RUNTIME_STYLE_RE = /<link\b([^>]*?)\bhref\s*=\s*(["'])([^"']+)\2([^>]*)>/gi;
function runtimeKind(url: string): "g6" | "mermaid" | "echarts" | "leaflet" | null {
if (/(?:@antv\/g6|(?:^|\/)g6(?:\.min)?\.js)/i.test(url)) return "g6";
if (/mermaid(?:\.min)?\.(?:js|mjs)|\bmermaid@/i.test(url)) return "mermaid";
if (/echarts(?:\.min)?\.js|\becharts@/i.test(url)) return "echarts";
if (/leaflet(?:\.min)?\.(?:js|css)|\bleaflet@/i.test(url)) return "leaflet";
return null;
}
function escapeHtmlAttribute(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
function safeJsonForScript(value: unknown): string {
return JSON.stringify(value).replace(/</g, "\\u003c");
}
function safeStyleText(value: string): string {
return value.replace(/<\/style/gi, "<\\/style");
}
function injectAfterHeadOpen(html: string, tags: string): string {
if (!tags) return html;
if (/<head\b[^>]*>/i.test(html)) {
return html.replace(/<head\b[^>]*>/i, (head) => `${head}${tags}`);
}
if (/<html\b[^>]*>/i.test(html)) {
return html.replace(/<html\b[^>]*>/i, (root) => `${root}<head>${tags}</head>`);
}
return `<head>${tags}</head>${html}`;
}
function injectBeforeBodyClose(html: string, tags: string): string {
if (/<\/body>/i.test(html)) return html.replace(/<\/body>/i, `${tags}</body>`);
return html + tags;
}
/**
* Replace CDN visualization runtimes with app-bundled assets and initialize the
* declarative contracts used by generated HTML pages. The iframe remains
* sandboxed without allow-same-origin; only script/style URLs are substituted.
*/
export function injectGeneratedHtmlRuntimes(
html: string,
assets: GeneratedHtmlRuntimeAssets,
): string {
// New generated pages load their browser runtimes directly from Gateway.
// Leave those explicit source references untouched; this fallback only
// applies to older artifacts that used the declarative blocks without them.
if (/\/api\/public\/html-runtime\/deerflow-runtime\.js/i.test(html)) return html;
if (!html || html.includes("data-df-html-runtime")) return html;
const needsG6 = /data-df-flow\s*=|\bG6\s*\.\s*Graph\b|@antv\/g6|(?:^|[/'"])[gG]6(?:\.min)?\.js/i.test(html);
const needsMermaid = /class\s*=\s*["'][^"']*\bmermaid\b|\bmermaid\s*\.|mermaid(?:\.min)?\.(?:js|mjs)|\bmermaid@/i.test(html);
const needsEcharts = /data-df-chart\s*=|\becharts\s*\.|echarts(?:\.min)?\.js|\becharts@/i.test(html);
const needsLeaflet = /data-df-map\s*=|\bL\s*\.\s*map\s*\(|leaflet(?:\.min)?\.(?:js|css)|\bleaflet@/i.test(html);
const needsEchartsChinaMap = needsEcharts && /\bmap\s*:\s*["']china["']|["']map["']\s*:\s*["']china["']/i.test(html);
if (!needsG6 && !needsMermaid && !needsEcharts && !needsLeaflet) return html;
let prepared = html.replace(
KNOWN_RUNTIME_SCRIPT_RE,
(tag, _before: string, _quote: string, url: string) =>
runtimeKind(url) ? "" : tag,
);
prepared = prepared.replace(
KNOWN_RUNTIME_STYLE_RE,
(tag, _before: string, _quote: string, url: string) =>
runtimeKind(url) === "leaflet" ? "" : tag,
);
const headTags = [
needsLeaflet
? `<style data-df-html-runtime="leaflet-css">${safeStyleText(assets.leafletStyleText)}</style>`
: "",
needsG6
? `<script data-df-html-runtime="g6" src="${escapeHtmlAttribute(assets.g6ScriptUrl)}"></script>`
: "",
needsMermaid
? `<script data-df-html-runtime="mermaid" src="${escapeHtmlAttribute(assets.mermaidScriptUrl)}"></script>`
: "",
needsEcharts
? `<script data-df-html-runtime="echarts" src="${escapeHtmlAttribute(assets.echartsScriptUrl)}"></script>`
: "",
needsEchartsChinaMap
? `<script data-df-html-runtime="echarts-china-map">if(window.echarts){window.echarts.registerMap("china",${safeJsonForScript(assets.echartsChinaGeoJson)});}</script>`
: "",
needsLeaflet
? `<script data-df-html-runtime="leaflet" src="${escapeHtmlAttribute(assets.leafletScriptUrl)}"></script>`
: "",
`<style data-df-html-runtime="styles">.df-flow,.df-chart,.df-map{position:relative;min-height:360px;width:100%}.df-runtime-error{box-sizing:border-box;margin:12px 0;padding:12px 14px;border:1px solid #fca5a5;border-radius:8px;background:#fef2f2;color:#991b1b;font:14px/1.5 sans-serif}.df-flow-summary{margin:10px 0;color:inherit;font:13px/1.6 sans-serif}.df-flow-summary summary{cursor:pointer;font-weight:600}.df-flow-summary ol{margin:8px 0 0;padding-left:24px}.df-map-tile-error{position:absolute;z-index:1000;left:12px;right:12px;bottom:12px;padding:9px 12px;border-radius:7px;background:rgba(127,29,29,.92);color:#fff;font:13px/1.4 sans-serif;pointer-events:none}</style>`,
].join("");
prepared = injectAfterHeadOpen(prepared, headTags);
return injectBeforeBodyClose(
prepared,
generatedHtmlRuntimeBootstrap(assets.mapTileUrl, assets.mapTileAttribution),
);
}
function generatedHtmlRuntimeBootstrap(mapTileUrl: string, mapTileAttribution: string): string {
return `<script data-df-html-runtime="bootstrap">(function(){
function ready(fn){if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",fn);else fn();}
function errorBox(target,message){var box=document.createElement("div");box.className="df-runtime-error";box.setAttribute("role","alert");box.textContent=message;if(target&&target.parentNode)target.parentNode.insertBefore(box,target.nextSibling);}
function byId(id){return id?document.getElementById(id):null;}
function jsonBlocks(name){return document.querySelectorAll('script[type="application/json"]['+name+']');}
function parse(block){try{return JSON.parse(block.textContent||"{}");}catch(e){errorBox(block,"图形数据格式错误:"+(e&&e.message?e.message:"无法解析 JSON"));return null;}}
function positiveSize(target){if(!target)return false;if(target.clientHeight<40)target.style.minHeight="360px";if(target.clientWidth<40)target.style.width="100%";return true;}
function initFlows(){var blocks=jsonBlocks("data-df-flow");for(var i=0;i<blocks.length;i++){var block=blocks[i],target=byId(block.getAttribute("data-df-flow")),data=parse(block);if(!target||!data)continue;if(!window.G6||!window.G6.Graph){errorBox(target,"流程图运行库加载失败,请刷新页面后重试");continue;}try{positiveSize(target);var nodes=data.nodes||[],edges=data.edges||[];target.setAttribute("role","img");target.setAttribute("aria-label",(data.title||"流程图")+",包含 "+nodes.length+" 个步骤和 "+edges.length+" 条连线");var colors={start:["#0f766e","#2dd4bf"],end:["#7c3aed","#a78bfa"],decision:["#b45309","#f59e0b"],risk:["#b91c1c","#f87171"],process:["#1d4ed8","#60a5fa"],stage:["#4338ca","#818cf8"],task:["#334155","#94a3b8"]};var shapes={start:"ellipse",end:"ellipse",decision:"diamond",risk:"hexagon",process:"rect",stage:"rect",task:"rect"};var graphData={nodes:[],edges:[]};for(var n=0;n<nodes.length;n++){var node=nodes[n],pair=colors[node.type]||colors.task;graphData.nodes.push({id:String(node.id),data:{label:node.label||node.id,description:node.description||"",fill:node.color||pair[0],stroke:pair[1],shape:shapes[node.type]||"rect"}});}for(var e=0;e<edges.length;e++){var edge=edges[e];graphData.edges.push({source:String(edge.source),target:String(edge.target),data:{label:edge.label||""}});}var graph=new window.G6.Graph({container:target,data:graphData,autoFit:"view",padding:32,animation:!(window.matchMedia&&window.matchMedia("(prefers-reduced-motion: reduce)").matches),layout:{type:"dagre",rankdir:data.direction==="TB"?"TB":"LR",nodesep:36,ranksep:72},node:{type:function(d){return d.data.shape;},style:{size:[176,68],radius:14,fill:function(d){return d.data.fill;},stroke:function(d){return d.data.stroke;},lineWidth:2,shadowColor:"rgba(15,23,42,.28)",shadowBlur:12,labelText:function(d){return d.data.label+(d.data.description?"\n"+d.data.description:"");},labelFill:"#f8fafc",labelFontSize:14,labelFontWeight:600,labelLineHeight:20,labelWordWrap:true,labelMaxWidth:148}},edge:{type:"polyline",style:{stroke:"#94a3b8",lineWidth:2,radius:12,endArrow:true,labelText:function(d){return d.data.label;},labelFill:"#475569",labelFontSize:12,labelBackground:true,labelBackgroundFill:"#f8fafc",labelBackgroundRadius:6,labelPadding:[4,7]}},behaviors:["drag-canvas","zoom-canvas","drag-element"]});var rendered=graph.render();if(rendered&&rendered.catch)rendered.catch(function(err){errorBox(target,"流程图渲染失败:"+(err&&err.message?err.message:"请检查流程数据"));});var summary=document.createElement("details");summary.className="df-flow-summary";var heading=document.createElement("summary");heading.textContent="查看流程步骤";summary.appendChild(heading);var list=document.createElement("ol");for(var s=0;s<nodes.length;s++){var item=document.createElement("li");item.textContent=(nodes[s].label||nodes[s].id)+(nodes[s].description?" — "+nodes[s].description:"");list.appendChild(item);}summary.appendChild(list);target.parentNode.insertBefore(summary,target.nextSibling);(function(instance){window.addEventListener("resize",function(){instance.resize();});})(graph);}catch(err){errorBox(target,"流程图渲染失败:"+(err&&err.message?err.message:"请检查流程数据"));}}}
function initMermaid(){var nodes=document.querySelectorAll(".mermaid");if(!nodes.length)return;if(!window.mermaid){for(var i=0;i<nodes.length;i++)errorBox(nodes[i],"流程图库加载失败,请刷新页面后重试");return;}try{window.mermaid.initialize({startOnLoad:false,securityLevel:"strict",theme:"default"});var result=window.mermaid.run({nodes:nodes});if(result&&result.catch)result.catch(function(e){errorBox(nodes[0],"流程图渲染失败:"+(e&&e.message?e.message:"请检查 Mermaid 语法"));});}catch(e){errorBox(nodes[0],"流程图渲染失败:"+(e&&e.message?e.message:"请检查 Mermaid 语法"));}}
function initCharts(){var blocks=jsonBlocks("data-df-chart");for(var i=0;i<blocks.length;i++){var block=blocks[i],target=byId(block.getAttribute("data-df-chart")),option=parse(block);if(!target||!option)continue;if(!window.echarts){errorBox(target,"图表运行库加载失败,请刷新页面后重试");continue;}try{positiveSize(target);target.setAttribute("role","img");target.setAttribute("aria-label",option.title&&option.title.text?option.title.text:"数据图表");var chart=window.echarts.init(target);chart.setOption(option);(function(instance){window.addEventListener("resize",function(){instance.resize();});})(chart);}catch(e){errorBox(target,"图表渲染失败:"+(e&&e.message?e.message:"请检查图表数据"));}}}
function lngLat(value){if(!value||value.length<2)return null;var lng=Number(value[0]),lat=Number(value[1]);if(!isFinite(lng)||!isFinite(lat)||lng<-180||lng>180||lat<-90||lat>90)return null;return [lat,lng];}
function escapeText(value){return String(value==null?"":value).replace(/[&<>\"']/g,function(ch){return {"&":"&amp;","<":"&lt;",">":"&gt;",'\"':"&quot;","'":"&#39;"}[ch];});}
function initMaps(){var blocks=jsonBlocks("data-df-map");for(var i=0;i<blocks.length;i++){var block=blocks[i],target=byId(block.getAttribute("data-df-map")),data=parse(block);if(!target||!data)continue;if(!window.L){errorBox(target,"地图运行库加载失败,请刷新页面后重试");continue;}try{positiveSize(target);target.setAttribute("role","region");target.setAttribute("aria-label",data.title||"地图");var map=window.L.map(target,{zoomControl:true});var tileErrors=0;window.L.tileLayer(${safeJsonForScript(mapTileUrl)},{maxZoom:20,attribution:${safeJsonForScript(mapTileAttribution)}}).on("tileerror",function(){tileErrors+=1;if(tileErrors===3){var warning=document.createElement("div");warning.className="df-map-tile-error";warning.setAttribute("role","status");warning.textContent="地图底图加载失败;地点、路线和区域数据仍可查看";target.appendChild(warning);}}).addTo(map);var bounds=[];var markers=data.markers||[];for(var m=0;m<markers.length;m++){var marker=markers[m],point=lngLat(marker.coordinate);if(!point)continue;bounds.push(point);var dot=window.L.circleMarker(point,{radius:7,color:"#1d4ed8",weight:2,fillColor:"#3b82f6",fillOpacity:.9}).addTo(map);var popup="<strong>"+escapeText(marker.name||"地点")+"</strong>"+(marker.description?"<br>"+escapeText(marker.description):"");dot.bindPopup(popup);dot.bindTooltip(escapeText(marker.name||"地点"));}var lines=data.lines||[];for(var l=0;l<lines.length;l++){var linePoints=[];var rawLine=lines[l].coordinates||[];for(var p=0;p<rawLine.length;p++){var lp=lngLat(rawLine[p]);if(lp){linePoints.push(lp);bounds.push(lp);}}if(linePoints.length>1)window.L.polyline(linePoints,{color:"#2563eb",weight:3,opacity:.85}).addTo(map);}var polygons=data.polygons||[];for(var g=0;g<polygons.length;g++){var polygonPoints=[];var rawPolygon=polygons[g].coordinates||[];for(var q=0;q<rawPolygon.length;q++){var pp=lngLat(rawPolygon[q]);if(pp){polygonPoints.push(pp);bounds.push(pp);}}if(polygonPoints.length>2)window.L.polygon(polygonPoints,{color:"#7c3aed",weight:2,fillColor:"#8b5cf6",fillOpacity:.2}).addTo(map);}var center=lngLat(data.center);if(center)map.setView(center,Number(data.zoom)||6);else if(bounds.length)map.fitBounds(bounds,{padding:[28,28],maxZoom:Number(data.zoom)||10});else map.setView([35.8617,104.1954],Number(data.zoom)||4);(function(instance){setTimeout(function(){instance.invalidateSize();},0);})(map);}catch(e){errorBox(target,"地图渲染失败:"+(e&&e.message?e.message:"请检查地图数据"));}}}
ready(function(){initFlows();initMermaid();initCharts();initMaps();});
})();</script>`;
}
export function downlevelModernColors(html: string): string {
@@ -0,0 +1,13 @@
import { readRuntimeEnv } from "@/env";
export const DEFAULT_MAP_TILE_URL =
"https://mt0.google.com/vt/lyrs=m&hl=zh-CN&x={x}&y={y}&z={z}";
/** Browser-side tile template used only by ordinary-Q&A message-list maps. */
export function getMapTileUrl(): string {
return readRuntimeEnv("VITE_MAP_TILE_URL")?.trim() || DEFAULT_MAP_TILE_URL;
}
export function getMapTileAttribution(tileUrl = getMapTileUrl()): string {
return /(?:^|\.)google\.(?:com|cn)\b/i.test(tileUrl) ? "Google" : "";
}
+2 -1
View File
@@ -113,7 +113,8 @@ export function getMermaidImageFileName(filepath: string, extension = "png"): st
return `${name}.${extension}`;
}
function triggerBlobDownload(blob: Blob, filename: string): void {
/** Download an in-memory artifact while reliably releasing its object URL. */
export function triggerBlobDownload(blob: Blob, filename: string): void {
const objectUrl = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = objectUrl;
@@ -116,6 +116,14 @@ export interface EightBfTaskProcessReportReceipt {
id: string;
}
/**
* 外部任务系统要求 8BF 会商页提交的过程产物携带 eightTask=1,
* 用于与岗位会商等其它入口的产物区分来源。
*/
function processReportBody(input: EightBfTaskProcessReportInput): string {
return JSON.stringify({ ...input, eightTask: 1 });
}
/**
* 当前 DeerFlow 的外部业务请求适配器沿用任务系统的默认约定:使用
* `Authorization: Bearer admin`,而不是 DeerFlow 登录接口返回的 token。
@@ -157,7 +165,10 @@ async function externalTaskError(response: Response, fallback: string): Promise<
}
function findProcessReportId(payload: unknown): string {
if (!payload || typeof payload !== "object") return "";
if (payload == null) return "";
// 外部任务系统的实际返回形如 {code:200, data:307, msg:"success"}:
// `data` 直接就是新增记录的 id(数字 / 字符串),不再包一层对象。
if (typeof payload !== "object") return firstText(payload);
const source = payload as Record<string, unknown>;
const direct = firstText(
source.id,
@@ -170,9 +181,10 @@ function findProcessReportId(payload: unknown): string {
}
/**
* Create the external task system's process report in its unsubmitted state.
* The local gateway stores the returned id so a refresh can later submit the
* same report instead of creating a duplicate one.
* Create the external task system's process report **and submit it in one
* call**(submitStatus=1). Response shape is `{code:200, data:<id>, msg}` —
* `data` is the new record id. The local gateway stores that id so a refresh
* can still reconcile the same report instead of creating a duplicate.
*/
export async function createEightBfTaskProcessReport(
input: EightBfTaskProcessReportInput,
@@ -180,7 +192,7 @@ export async function createEightBfTaskProcessReport(
const response = await fetchExternalTaskSystem(PROCESS_REPORT_ADD_PATH, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
body: processReportBody(input),
});
if (!response.ok) throw await externalTaskError(response, "过程产物登记失败");
const id = findProcessReportId(await response.json().catch(() => null));
@@ -188,7 +200,7 @@ export async function createEightBfTaskProcessReport(
return { id };
}
/** Submit an already-registered process report to the external task system. */
/** Flip an already-registered (submitStatus=0) process report to submitted. Only used to resume legacy ledger rows; the normal path submits directly on create. */
export async function updateEightBfTaskProcessReport(
reportId: string,
input: EightBfTaskProcessReportInput,
@@ -198,7 +210,7 @@ export async function updateEightBfTaskProcessReport(
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
body: processReportBody(input),
},
);
if (!response.ok) throw await externalTaskError(response, "过程产物提交失败");
@@ -32,6 +32,7 @@ import {
} from "@/position-roundtable/lib/positions";
import type { DraftStep3SummarySnapshot } from "@/roundtable-planning/api/drafts";
import { EIGHTBF_INTENT_AGENT_ID } from "@/roundtable-planning/api/intent";
import { updateChain } from "@/roundtable-planning/api/chains";
import type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent";
import "@/position-roundtable/styles/intent-understanding.css";
import type { Step1PanelProps } from "@/roundtable-planning/components/Step1Panel";
@@ -46,6 +47,8 @@ export interface EightBfStep1PanelProps extends Step1PanelProps {
chain: BusinessChain | null;
canConfigureChain: boolean;
onConfigureChain: () => void;
/** 业务链被本面板更新(产物 position 等)后回调,页面据此刷新链配置。 */
onChainUpdated?: (chain: BusinessChain) => void;
selectedModel: string;
modelOptions: Array<{ label: string; value: string }>;
onSelectedModelChange: (model: string) => void;
@@ -84,6 +87,7 @@ export function EightBfStep1Panel({
chain,
canConfigureChain,
onConfigureChain,
onChainUpdated,
selectedModel,
modelOptions,
onSelectedModelChange,
@@ -153,6 +157,7 @@ export function EightBfStep1Panel({
chain={chain}
canConfigureChain={canConfigureChain}
onConfigureChain={onConfigureChain}
onChainUpdated={onChainUpdated}
onCollapse={() => setRightPanelOpen(false)}
step2Dialogues={step2Dialogues}
step2ThreadIds={step2ThreadIds}
@@ -299,6 +304,7 @@ function EightBfAgentWorkspace({
chain,
canConfigureChain,
onConfigureChain,
onChainUpdated,
onCollapse,
step2Dialogues,
step2ThreadIds,
@@ -310,6 +316,7 @@ function EightBfAgentWorkspace({
chain: BusinessChain | null;
canConfigureChain: boolean;
onConfigureChain: () => void;
onChainUpdated?: (chain: BusinessChain) => void;
onCollapse: () => void;
step2Dialogues: unknown[];
step2ThreadIds: ThreadIdMap | null;
@@ -448,6 +455,13 @@ function EightBfAgentWorkspace({
jobResponsibilities={selectedSeat?.job_responsibilities ?? ""}
skillsIcon={<img src={doubleArrowDecoration} alt="" aria-hidden="true" />}
skillsTitle="技能列表"
belowSkillsSlot={chain && selectedSeat ? (
<SeatArtifactPositionEditor
chain={chain}
seat={selectedSeat}
onUpdated={onChainUpdated}
/>
) : undefined}
showCommonQuestions
panelTitle="智能体配置"
embedded
@@ -612,3 +626,82 @@ function arrayValue(...values: unknown[]): string[] {
}
return [];
}
/**
* 「智能体配置」面板里、技能列表下方的「产物提交 position」编辑器。
*
* 直接读写该席位在业务链上配置的 artifact_position(保存即 PUT 业务链的 seats),
* 用户无需去业务链编辑页找链配 position,也不易配错链。只有业务链 owner / 管理员
* 能保存(后端校验);提交产物时按席位取这里保存的值,留空按 0。
*/
function SeatArtifactPositionEditor({
chain,
seat,
onUpdated,
}: {
chain: BusinessChain;
seat: ChainSeat;
onUpdated?: (chain: BusinessChain) => void;
}) {
const [value, setValue] = useState(seat.artifact_position?.toString() ?? "");
const [saving, setSaving] = useState(false);
// 席位切换 / 外部链数据刷新时,回填链上的当前值。
useEffect(() => {
setValue(seat.artifact_position?.toString() ?? "");
}, [seat.agent_id, seat.artifact_position]);
const trimmed = value.trim();
const parsed = trimmed === "" ? null : Number.parseInt(trimmed, 10);
const nextValue = parsed !== null && Number.isFinite(parsed) ? parsed : null;
const dirty = nextValue !== (seat.artifact_position ?? null);
const handleSave = async () => {
if (saving || !dirty) return;
setSaving(true);
try {
const seats = chain.seats.map((s) =>
s.agent_id === seat.agent_id ? { ...s, artifact_position: nextValue } : s,
);
const updated = await updateChain(chain.id, { seats });
if (!updated) throw new Error("业务链已不存在,可能被删除");
onUpdated?.(updated);
toast.success(`已保存「${seat.name || seat.agent_id}」的产物 position`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存产物 position 失败");
} finally {
setSaving(false);
}
};
return (
<div className="mt-3 rounded-lg border border-[#2b8ffe]/30 bg-[#042b59]/60 p-3">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-semibold text-[#aaceff]">产物提交 position</span>
<div className="flex items-center gap-1.5">
<input
type="number"
inputMode="numeric"
value={value}
placeholder="0"
aria-label="产物提交 position"
onChange={(event) => setValue(event.target.value)}
className="h-7 w-20 rounded-md border border-[#2b8ffe]/40 bg-[#032680]/60 px-2 text-xs font-semibold tabular-nums text-[#e8f2ff] outline-none focus:border-[#2b8ffe] [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
/>
<button
type="button"
disabled={!dirty || saving}
onClick={() => void handleSave()}
className="inline-flex h-7 items-center gap-1 rounded-md border border-[#2b8ffe]/50 bg-[#0a3c8f]/70 px-2.5 text-xs font-semibold text-[#e8f2ff] transition hover:bg-[#0a3c8f] disabled:cursor-not-allowed disabled:opacity-50"
>
{saving && <Loader2 className="size-3 animate-spin" />}
保存
</button>
</div>
</div>
<p className="mt-1.5 text-[10px] leading-relaxed text-[#8aa0c4]">
该席位提交过程产物时传给任务系统的 position 值,留空按 0 提交。保存会写入业务链「{chain.title}」,仅链所有者 / 管理员可保存。
</p>
</div>
);
}
@@ -88,14 +88,50 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
() => buildEightBfTaskContext(task, taskDictionaries),
[task, taskDictionaries],
);
// 席位「产物提交 position」有效取值映射(提交产物时按席位智能体取值):
// 1) 绑定链的席位 —— 键收录小写 agent_id + 小写席位名(会话侧席位标识在不同
// 表面可能以 id 或名称出现,双键匹配避免查不到);
// 2) 其它公共链上同一 agent_id 配置的值(历史会话的席位可能来自切换绑定前的
// 旧链,agent_id 跨链唯一指向同一智能体,可安全回退;**名称不跨链匹配**,
// 避免不同链的同名席位互相误配)。
// 绑定链优先(先写入),未配置的席位不进 map(提交时回退默认 0)。
const seatArtifactPositions = useMemo(() => {
const map: Record<string, number> = {};
const put = (key: string, value: number) => {
const normalized = key.trim().toLowerCase();
if (normalized && !(normalized in map)) map[normalized] = value;
};
for (const seat of configuredChain?.seats ?? []) {
if (typeof seat.artifact_position === "number" && Number.isFinite(seat.artifact_position)) {
put(seat.agent_id, seat.artifact_position);
if (seat.name?.trim()) put(seat.name, seat.artifact_position);
}
}
for (const chain of chains) {
if (chain.id === configuredChain?.id) continue;
for (const seat of chain.seats ?? []) {
if (typeof seat.artifact_position === "number" && Number.isFinite(seat.artifact_position)) {
put(seat.agent_id, seat.artifact_position);
}
}
}
return map;
}, [chains, configuredChain]);
const taskArtifactSubmissionConfig = useMemo<TaskArtifactSubmissionConfig | undefined>(
() => taskId ? {
taskId,
position: 0,
positionFor: (target) => {
const agentId = target.agentId?.trim();
if (!agentId) return null;
return seatArtifactPositions[agentId.toLowerCase()]
?? seatArtifactPositions[target.agentName?.trim().toLowerCase() ?? ""]
?? null;
},
createProcessReport: createEightBfTaskProcessReport,
updateProcessReport: updateEightBfTaskProcessReport,
} : undefined,
[taskId],
[taskId, seatArtifactPositions],
);
useEffect(() => {
@@ -199,6 +235,12 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
if (canConfigureChain) setIsSettingsOpen(true);
}, [canConfigureChain]);
// 「智能体配置」面板里保存产物 position 后,把最新链数据写回页面状态,
// 让 seatArtifactPositions / 提交产物取值即时生效(无需刷新页面)。
const handleChainUpdated = useCallback((updated: BusinessChain) => {
setChains((prev) => prev.map((chain) => (chain.id === updated.id ? updated : chain)));
}, []);
const toolbarSlot = (
<>
{canConfigureChain && isSettingsOpen && (
@@ -293,6 +335,7 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
chain={configuredChain}
canConfigureChain={canConfigureChain}
onConfigureChain={openChainSettings}
onChainUpdated={handleChainUpdated}
/>
)}
renderStep1LeftSidebar={({ intentReady, onCollapse, submittedSeatKeys, onSeatClick }) => (
@@ -343,6 +386,8 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
}
rootClassName="eightbf-roundtable"
taskArtifactSubmissionConfig={taskArtifactSubmissionConfig}
// 会商「智能体配置」抽屉里只读展示各席位在业务链上配置的产物提交 position。
seatArtifactPositions={seatArtifactPositions}
/>
</div>
);
@@ -48,7 +48,7 @@ const MAX_INTENT_LENGTH = 500;
const MAX_SAMPLE_TEXT_LENGTH = 120000;
const MATERIAL_SOURCE_OPTIONS: Array<{ value: MaterialSource; label: string; desc: string }> = [
{ value: 'general', label: '通用检索', desc: '素材收集专家按配置技能检索' },
{ value: 'general', label: '通用检索', desc: '素材收集专家自由选用全部技能检索' },
{ value: 'knowledge_base', label: '知识库检索', desc: '直连内网知识库检索接口' },
{ value: 'notebook', label: '我的空间', desc: '从空间中检索素材' },
];
@@ -152,7 +152,7 @@ interface MaterialConfirmCardProps {
type MaterialSource = NonNullable<UserInterventionPayload['materialSource']>;
const MATERIAL_SOURCE_OPTIONS: Array<{ value: MaterialSource; label: string; desc: string }> = [
{ value: 'general', label: '通用检索', desc: '素材收集专家按配置技能检索' },
{ value: 'general', label: '通用检索', desc: '素材收集专家自由选用全部技能检索' },
{ value: 'knowledge_base', label: '知识库检索', desc: '直连内网知识库接口' },
{ value: 'notebook', label: '我的空间', desc: '检索当前笔记空间' },
];
@@ -218,7 +218,7 @@ function MaterialConfirmCard({
async function handleReSearch() {
setSubmitting(true);
// 带输入 = 后端在素材收集专家配置的技能中匹配检索、新素材追加到列表;
// 带输入 = 后端从全部启用技能中匹配检索、新素材追加到列表;
// 无输入 = 重新生成检索词、覆盖式重检
await onSubmit({
action: 're_search',
@@ -142,7 +142,7 @@ const DRAFT_ID = 'aw-draft';
const AI_WRITING_AGENT_MODULE_KEY = 'ai-writing';
const AI_WRITING_AGENT_MODULE_TITLE = 'AI 写作智能体';
const AI_WRITING_LAST_AGENT_KEY = `agent-module:${AI_WRITING_AGENT_MODULE_KEY}:last-agent`;
const SHOW_NOTEBOOK_SPACE_ENTRIES = false;
const SHOW_NOTEBOOK_SPACE_ENTRIES = true;
// Match the original canvas page proportions (chat=35, artifact=65)
const DEFAULT_LAYOUT = { [WRITING_ID]: 35, [DRAFT_ID]: 65 };
@@ -257,7 +257,7 @@ export interface UserInterventionPayload {
approvedMaterialIds?: string[];
/** @deprecated 旧版多关键词追加。新版统一用 userQuery 单输入框 */
extraKeywords?: string[];
/** PAUSE-1 素材确认:用户单输入框写的自然语言(想补充检索的内容)。后端在素材收集专家配置的技能中匹配最相关的技能检索,新素材**追加**到列表。 */
/** PAUSE-1 素材确认:用户单输入框写的自然语言(想补充检索的内容)。后端从全部启用技能中匹配最相关的技能检索,新素材**追加**到列表。 */
userQuery?: string;
/** PAUSE-1 素材确认:本次补充/重新检索使用的检索方式。 */
materialSource?: "general" | "knowledge_base" | "notebook";
@@ -298,7 +298,7 @@ export interface AIWritingRequest {
writingMode: "strict" | "loose";
/** 是否在成稿里加参考文献(正文 [n] 角标 + 文末「参考文献」列表,像论文)。默认 true。 */
addReferences?: boolean;
/** 检索类型:general=通用检索(素材收集专家按配置技能检索,默认知识库检索技能)/
/** 检索类型:general=通用检索(素材收集专家从全部启用技能自由挑选检索,内置知识库检索保底)/
* knowledge_base=知识库检索(直连内网知识库 ES 接口,不走技能编排)/ notebook=我的空间 */
materialSource: "general" | "knowledge_base" | "notebook";
/** 不收集素材,直接用通用知识写作:勾选后跳过整个素材检索流程(含素材确认暂停点),
+4 -2
View File
@@ -49,7 +49,8 @@ export default function AdminSkillsPage() {
const currentUserId = storedAuth?.user_id ?? null;
// Hide archived/stale entries here — they remain accessible from the regular
// skills settings dialog. The admin page is for live skills only.
// skills settings dialog. Keep both online and offline active entries so an
// administrator can bring an offline skill back online later.
const filteredSkills = useMemo(() => {
const needle = search.trim().toLowerCase();
return skills
@@ -97,7 +98,7 @@ export default function AdminSkillsPage() {
>
<div className="flex items-start justify-between gap-4 border-b px-6 py-4">
<p className="text-muted-foreground text-sm">
管理员视图:查看、编辑、删除所有用户的技能(包括内置技能与未发布的自定义技能)。
管理员视图:查看、编辑、上线/下线或删除所有用户的技能(包括内置技能与未发布的自定义技能)。
</p>
<div className="relative w-64 shrink-0">
<SearchIcon className="text-muted-foreground absolute left-2.5 top-1/2 size-4 -translate-y-1/2" />
@@ -142,6 +143,7 @@ export default function AdminSkillsPage() {
skill={skill}
currentUserId={currentUserId}
isAdmin
availabilityControl="admin"
knowledgeBindings={(knowledgeBindings.data ?? []).filter(
(binding) => binding.skill_name === skill.name,
)}
@@ -1206,7 +1206,7 @@ export default function AdminUserLeaderboardPage() {
<Button
variant="outline"
size="sm"
onClick={() => navigate("/page/strategy/admin/user-leaderboard/settings")}
onClick={() => navigate("/page/workspace/admin/user-leaderboard/settings")}
className="h-9 w-9 px-0"
title="排行榜设置"
aria-label="排行榜设置"
@@ -76,7 +76,7 @@ export default function AdminUserLeaderboardSettingsPage() {
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => navigate("/page/strategy/admin/user-leaderboard")}>
<Button variant="outline" onClick={() => navigate("/page/workspace/admin/user-leaderboard")}>
<ArrowLeft className="mr-1 size-4" />
返回排行榜
</Button>
+184 -19
View File
@@ -13,11 +13,14 @@ import { useLocation } from "react-router-dom";
import { useRouteBase } from "@/core/route-base";
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { Button } from "@/components/ui/button";
import { AgentChatSidebar } from "@/components/workspace/agents/agent-chat-sidebar";
import { AgentDetailPanel } from "@/components/workspace/agents/agent-detail-panel";
import { AgentFeedbackScoreCard } from "@/components/workspace/agents/agent-feedback-score-card";
import { canViewAgentFeedbackScore } from "@/components/workspace/agents/agent-feedback-visibility";
import { AgentQuestionChips } from "@/components/workspace/agents/agent-question-chips";
import {
AgentGuide,
@@ -40,14 +43,17 @@ import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicato
import { Tooltip } from "@/components/workspace/tooltip";
import { normalizeAgentQuestions, useAgent } from "@/core/agents";
import { getAPIClient } from "@/core/api";
import { getStoredAuth } from "@/core/auth";
import { useTheme } from "@/contexts/ThemeContext";
import { useI18n } from "@/core/i18n/hooks";
import { queueLlmWikiConversationDeposit } from "@/core/llmwiki/conversation-deposit";
import { createKnowledgeWikiReferenceSelection } from "@/core/llmwiki/knowledge-reference";
import { useModels } from "@/core/models/hooks";
import { useNotification } from "@/core/notification/hooks";
import { saveLastAgentId, useThreadSettings } from "@/core/settings";
import { stopActiveThreadBeforeClarificationReply, stopThreadRun, useThreadStream } from "@/core/threads/hooks";
import { useResearchWritingFeed } from "@/core/threads/use-research-writing-feed";
import { useDirectChatMessages, isDirectChatProfile, isTurboModeVisible } from "@/core/threads/use-direct-chat";
import { clearResearchReportStream } from "@/core/threads/use-research-report-stream";
import { textOfMessage } from "@/core/threads/utils";
import { resolveWritingModeReportPath } from "@/core/threads/writing-mode";
@@ -260,7 +266,9 @@ export default function AgentChatPage({
const routeBase = useRouteBase();
const { t } = useI18n();
const { baseMode } = useTheme();
const mayViewAgentFeedbackScore = canViewAgentFeedbackScore(getStoredAuth());
const [showFollowups, setShowFollowups] = useState(false);
const [isConversationAtBottom, setIsConversationAtBottom] = useState(true);
const router = useRouter();
// 独立智能体页的左列是 AgentChatSidebar,而不是外层 WorkspaceSidebar;
// 由本页维护其收展状态,避免顶部按钮切到了不可见的另一层侧栏。
@@ -373,6 +381,7 @@ export default function AgentChatPage({
const [selectedAssistantKnowledgeBaseIds, setSelectedAssistantKnowledgeBaseIds] = useState<string[]>(
() => pendingAssistantKnowledgeSelection.current ?? [],
);
const [knowledgeSuggestedQuestions, setKnowledgeSuggestedQuestions] = useState<string[]>([]);
const [knowledgeSelectionReady, setKnowledgeSelectionReady] = useState(
() => pendingKnowledgeSelection.current !== null || pendingAssistantKnowledgeSelection.current !== null,
);
@@ -462,6 +471,31 @@ export default function AgentChatPage({
});
}, [isNewThread, threadId]);
// 任务深链隐藏了选库器,因此新会话必须显式继承智能体绑定的普通知识库。
// 即使智能体没有绑定任何库也要进入 ready,避免自动开场一直等待或抢在
// agent 请求完成前创建一个不带知识范围的 thread。
useEffect(() => {
if (!taskCtxOn || !isNewThread) return;
if (isAgentLoading) {
setKnowledgeSelectionLoading(true);
setKnowledgeSelectionReady(false);
return;
}
const ids = Array.from(
new Set(
(Array.isArray(agent?.llmwiki_knowledge_base_ids)
? agent.llmwiki_knowledge_base_ids
: [])
.map((item) => String(item).trim())
.filter(Boolean),
),
);
setSelectedKnowledgeBaseIds(ids);
setSelectedAssistantKnowledgeBaseIds([]);
setKnowledgeSelectionReady(true);
setKnowledgeSelectionLoading(false);
}, [agent?.llmwiki_knowledge_base_ids, isAgentLoading, isNewThread, taskCtxOn]);
useEffect(() => {
if (isNewThread) {
setHasStartedConversation(hasPendingForThisAgent());
@@ -560,9 +594,26 @@ export default function AgentChatPage({
},
});
const directChat = useDirectChatMessages(threadId);
const directChatSelected =
isDirectChatProfile(settings.context) && isTurboModeVisible();
// 写作模式深度研究进度:注入 displayThread 尾部(进度步骤条 + report.md
// 流式 stub),成功且真实产物落地后自动撤出。
const { displayThread } = useResearchWritingFeed(threadId, thread);
const { displayThread: researchDisplayThread } = useResearchWritingFeed(threadId, thread);
const displayThread = useMemo(() => {
const base = researchDisplayThread;
if (directChat.messages.length === 0 && !directChat.isLoading) {
return base;
}
const messages = [...base.messages, ...directChat.messages];
return {
...base,
values: { ...base.values, messages },
messages,
isLoading: base.isLoading || directChat.isLoading,
} as typeof base;
}, [directChat.isLoading, directChat.messages, researchDisplayThread]);
// ── agentfx「入库」协助卡 ──────────────────────────────────────────────────
// 任务工作区(taskBind)里,内置研判智能体写出的 task-reports.json 产物落地且
@@ -743,6 +794,63 @@ export default function AgentChatPage({
const handleSubmit = useCallback(
(message: PromptInputMessage, extraContext?: Record<string, unknown>) => {
if (taskCtxOn && isNewThread && !knowledgeSelectionReady) return;
if (directChatSelected) {
if (message.files && message.files.length > 0) {
toast.error("极速问答暂不支持附件,请移除附件后重试。");
return;
}
setHasStartedConversation(true);
saveLastAgentId(agent_id);
window.dispatchEvent(
new CustomEvent("workspace:sandbox-resume-autoscroll"),
);
void (async () => {
try {
let targetThreadId = threadId;
if (isNewThread) {
const created = await getAPIClient().threads.create({
metadata: {
agent_id,
...(Object.keys(conversationMetadata).length > 0
? conversationMetadata
: {}),
},
});
targetThreadId = created.thread_id;
knowledgeSelectionThread.current = targetThreadId;
activeThreadIdRef.current = targetThreadId;
setThreadId(targetThreadId);
setIsNewThread(false);
history.replaceState(
null,
"",
`${window.location.pathname}${window.location.search}#${agentChatsBase}/${targetThreadId}`,
);
const title = message.text.trim().replace(/\s+/g, " ").slice(0, 60);
if (title) {
void getAPIClient().threads
.updateState(targetThreadId, { values: { title } })
.catch(() => undefined);
}
}
if (!targetThreadId) {
toast.error("无法创建会话。");
return;
}
await directChat.submit({
targetThreadId,
query: message.text,
modelName: settings.context.model_name,
});
} catch (error) {
toast.error(
error instanceof Error ? error.message : "极速问答失败。",
);
}
})();
return;
}
setHasStartedConversation(true);
// Remember this agent so the new-chat landing screen pre-selects it next
// time (covers both direct agent chats and the ChatPage handoff replay).
@@ -767,7 +875,24 @@ export default function AgentChatPage({
...pageReportThemeContext,
});
},
[sendMessage, threadId, agent_id, settings.context.writing_mode, writingArtifactPath, baseMode],
[
sendMessage,
threadId,
agent_id,
settings.context.writing_mode,
settings.context.model_name,
writingArtifactPath,
baseMode,
directChat,
directChatSelected,
isNewThread,
conversationMetadata,
agentChatsBase,
setThreadId,
setIsNewThread,
knowledgeSelectionReady,
taskCtxOn,
],
);
// 任务流程:切换 / 新建会话时,停掉「上一会话服务端在跑」的 run(老对话要停止)。
@@ -801,6 +926,7 @@ export default function AgentChatPage({
useEffect(() => {
if (pendingMessageHandled.current) return;
if (!agent_id) return;
if (taskCtxOn && isNewThread && !knowledgeSelectionReady) return;
let pending: { agentId?: string; threadId?: string; text?: string } | null = null;
try {
const raw = sessionStorage.getItem("pendingAgentMessage");
@@ -817,13 +943,15 @@ export default function AgentChatPage({
pendingMessageHandled.current = true;
sessionStorage.removeItem("pendingAgentMessage");
handleSubmit({ text: pending.text, files: [] });
}, [agent_id, threadId, isNewThread, handleSubmit]);
}, [agent_id, threadId, isNewThread, handleSubmit, knowledgeSelectionReady, taskCtxOn]);
// 纯绑定任务深链(3qfx/xdfx):用户主动「新建对话」进 landing 后,由 header 右上角「开始回答」
// 按钮手动触发发出开场信息(见下方 header)。手动场景不自动发,避免新建对话竞态导致的重复发送。
const handleTaskStart = useCallback(() => {
if (taskStartText) handleSubmit({ text: taskStartText, files: [] });
}, [handleSubmit, taskStartText]);
if (knowledgeSelectionReady && taskStartText) {
handleSubmit({ text: taskStartText, files: [] });
}
}, [handleSubmit, knowledgeSelectionReady, taskStartText]);
// 首次进入且该任务无历史(taskAutoStart):3qfx/xdfx 直接自动发出开场信息,无需手动点「开始回答」。
// autoStartedRef 一次性、不随 threadId 抖动重置,故只发一次(首次进入是干净 mount,无上一会话之忧)。
@@ -832,10 +960,11 @@ export default function AgentChatPage({
if (!taskAutoStart) return;
if (!taskStartText) return;
if (!isNewThread) return;
if (!knowledgeSelectionReady) return;
if (autoStartedRef.current) return;
autoStartedRef.current = true;
handleSubmit({ text: taskStartText, files: [] });
}, [taskAutoStart, taskStartText, isNewThread, handleSubmit]);
}, [taskAutoStart, taskStartText, isNewThread, knowledgeSelectionReady, handleSubmit]);
const handleClarificationSubmit = useCallback(
async (answer: string) => {
@@ -852,8 +981,12 @@ export default function AgentChatPage({
);
const handleStop = useCallback(async () => {
if (directChat.isLoading) {
directChat.stop();
return;
}
await thread.stop();
}, [thread]);
}, [directChat, thread]);
const isLandingState = isNewThread && !hasStartedConversation;
// 任务闸门面板:仅 rwfx(taskGate)在新建对话且尚未开始时,于问答页顶部展示目的树 + 覆盖确认。
@@ -867,16 +1000,33 @@ export default function AgentChatPage({
const status = thread.error
? "error"
: thread.isLoading
: thread.isLoading || directChat.isLoading
? "streaming"
: "ready";
const knowledgeReferences = useMemo(
() =>
knowledgeSelectionReady
? createKnowledgeWikiReferenceSelection(
selectedKnowledgeBaseIds,
selectedAssistantKnowledgeBaseIds,
)
: undefined,
[
knowledgeSelectionReady,
selectedAssistantKnowledgeBaseIds,
selectedKnowledgeBaseIds,
],
);
return (
<ThreadContext.Provider
value={{
thread: displayThread,
knowledgeReferencesEnabled:
knowledgeSelectionReady && selectedKnowledgeBaseIds.length > 0,
knowledgeSelectionReady &&
(selectedKnowledgeBaseIds.length > 0 ||
selectedAssistantKnowledgeBaseIds.length > 0),
knowledgeReferences,
}}
>
{/* Root: fixed-height horizontal split. ChatBox (chat + artifact
@@ -994,7 +1144,7 @@ export default function AgentChatPage({
size="sm"
className="mr-1 bg-[var(--color-main)] text-white shadow-sm hover:bg-[var(--brand-950)]"
onClick={handleTaskStart}
disabled={isDemoMode}
disabled={isDemoMode || !knowledgeSelectionReady}
>
开始回答
</Button>
@@ -1029,7 +1179,7 @@ export default function AgentChatPage({
// 非嵌入态:走该智能体的独立新对话页(按当前路由 base)。
router.push(
isEmbedded
? `/page/workspace/chats/new?agent=${agent_id}`
? `${routeBase}/chats/new?agent=${agent_id}`
: `${agentChatsBase}/new`,
);
}}
@@ -1042,7 +1192,7 @@ export default function AgentChatPage({
</div>
</header>
{showTaskGate && (
{showTaskGate && knowledgeSelectionReady && (
<TaskPurposePanel
taskId={taskId}
onStart={handleGateStart}
@@ -1067,7 +1217,7 @@ export default function AgentChatPage({
<div data-agent-guide="chat-questions" className="mb-3">
<AgentQuestionChips
questions={commonQuestions}
disabled={isDemoMode}
disabled={isDemoMode || (taskCtxOn && !knowledgeSelectionReady)}
onAsk={(text) => handleSubmit({ text, files: [] })}
/>
</div>
@@ -1079,12 +1229,18 @@ export default function AgentChatPage({
className="w-full"
isNewThread
hideAdminRecommendedQuestions
knowledgeSuggestedQuestions={knowledgeSuggestedQuestions}
threadId={threadId}
assistantId={agent_id}
autoFocus
status={status}
context={settings.context}
disabled={isDemoMode || knowledgeSelectionLoading}
disabled={
isDemoMode ||
knowledgeSelectionLoading ||
(taskCtxOn && !knowledgeSelectionReady) ||
directChat.isLoading
}
onContextChange={(context) =>
setSettings("context", context)
}
@@ -1099,17 +1255,18 @@ export default function AgentChatPage({
assistantValue={selectedAssistantKnowledgeBaseIds}
onChange={changeKnowledgeSelection}
onAssistantChange={changeAssistantKnowledgeSelection}
onSuggestedQuestionsChange={setKnowledgeSuggestedQuestions}
autoSelectAll={
isNewThread &&
pendingKnowledgeSelection.current === null &&
pendingAssistantKnowledgeSelection.current === null
}
disabled={isDemoMode || knowledgeSelectionLoading}
disabled={isDemoMode || knowledgeSelectionLoading || directChat.isLoading}
label="知识空间"
/>
)
}
onFollowupsVisibilityChange={setShowFollowups}
onFollowupsPresenceChange={setShowFollowups}
onSubmit={handleSubmit}
onStop={handleStop}
/>
@@ -1139,6 +1296,8 @@ export default function AgentChatPage({
tokenUsageEnabled={tokenUsageEnabled}
// 输入框在流内(MessageList 下方),按钮贴对话区底部即可。
scrollButtonClassName="bottom-2"
onAtBottomChange={setIsConversationAtBottom}
answerFeedbackEnabled
onClarificationSubmit={handleClarificationSubmit}
onEditResubmit={(text) => handleSubmit({ text, files: [] })}
// agentfx「入库」协助卡:消息流尾部注入的虚拟消息经此插槽渲染。
@@ -1175,7 +1334,7 @@ export default function AgentChatPage({
assistantId={agent_id}
status={status}
context={settings.context}
disabled={isDemoMode || knowledgeSelectionLoading}
disabled={isDemoMode || knowledgeSelectionLoading || directChat.isLoading}
onContextChange={(context) =>
setSettings("context", context)
}
@@ -1190,13 +1349,14 @@ export default function AgentChatPage({
assistantValue={selectedAssistantKnowledgeBaseIds}
onChange={changeKnowledgeSelection}
onAssistantChange={changeAssistantKnowledgeSelection}
disabled={isDemoMode || knowledgeSelectionLoading}
disabled={isDemoMode || knowledgeSelectionLoading || directChat.isLoading}
readOnly
label="知识空间"
/>
)
}
onFollowupsVisibilityChange={setShowFollowups}
followupsDisplayAllowed={isConversationAtBottom}
onFollowupsPresenceChange={setShowFollowups}
onSubmit={handleSubmit}
onStop={handleStop}
/>
@@ -1227,6 +1387,11 @@ export default function AgentChatPage({
onCollapsedChange={setDetailCollapsed}
showRecentChats={!taskCtxOn}
compactAppearanceActions={compactAppearanceActions}
feedbackScoreSlot={
mayViewAgentFeedbackScore ? (
<AgentFeedbackScoreCard agentId={agent_id} />
) : undefined
}
/>
</div>
</div>
+247
View File
@@ -0,0 +1,247 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Bot } from "lucide-react";
import { useRouter } from "next/navigation";
import { useAgents } from "@/core/agents";
import type { Agent } from "@/core/agents";
import { themeToBaseMode, useTheme } from "@/contexts/ThemeContext";
import "@/styles/agents-orbit.css";
import "@/styles/agents-orbit-dark.css";
import "@/styles/agents-orbit-light.css";
/** 转盘旋转过渡时长(ms),与 CSS 中 .ao-type-arm 的 transition 保持一致。 */
const DIAL_TRANSITION_MS = 850;
/** 一个类型 = 一个标签(取智能体的第一个标签;无标签归入「未分类」)。 */
interface AgentGroup {
id: string;
name: string;
agents: Agent[];
}
function groupAgentsByType(agents: Agent[]): AgentGroup[] {
const map = new Map<string, AgentGroup>();
for (const agent of agents) {
const tag = agent.tags?.[0];
const id = tag?.id ?? "__uncategorized__";
let group = map.get(id);
if (!group) {
group = { id, name: tag?.name ?? "未分类", agents: [] };
map.set(id, group);
}
group.agents.push(agent);
}
return Array.from(map.values()).sort((a, b) =>
a.name.localeCompare(b.name, "zh-Hans-CN"),
);
}
const mod = (value: number, n: number): number => ((value % n) + n) % n;
/**
* 密码转盘模型:
* - n 个类型始终均匀占据右半圆的 n 个固定槽位(间距恒定,不随点击改变);
* - 激活槽位固定为最贴近正右侧中间的槽,被点击的类型旋转到该槽;
* - 点击上半部分 → 转盘顺时针转(k > 0),下半部分 → 逆时针(k < 0);
* - 超出半圆端点的标签沿“背面长路”补位(角度 ±360 扫过左半圆,途中半透明),
* 动画结束后角度归一化回槽位角(同一位置 mod 360,无视觉跳变)。
*/
export default function AgentsOrbitPage() {
const router = useRouter();
const { theme } = useTheme();
const isDark = themeToBaseMode(theme) === "dark";
const { agents, isLoading } = useAgents();
const groups = useMemo(() => groupAgentsByType(agents), [agents]);
const [activeId, setActiveId] = useState<string | null>(null);
/** 转盘动画期间的非归一化角度(含 ±360 长路,长路全程在左半圆不可视区内)。 */
const [anim, setAnim] = useState<Record<string, number> | null>(null);
/** 归一化帧:瞬移到等价角度时禁用过渡。 */
const [frozen, setFrozen] = useState(false);
const timerRef = useRef<number | null>(null);
useEffect(() => {
return () => {
if (timerRef.current != null) window.clearTimeout(timerRef.current);
};
}, []);
if (isLoading) {
return (
<div className={`agents-orbit-page ${isDark ? "ao-dark" : "ao-light"}`}>
<div className="ao-state">
<div className="ao-spinner" />
<span>正在加载智能体…</span>
</div>
</div>
);
}
if (groups.length === 0) {
return (
<div className={`agents-orbit-page ${isDark ? "ao-dark" : "ao-light"}`}>
<div className="ao-state">
<span>还没有可展示的智能体</span>
</div>
</div>
);
}
const n = groups.length;
const midSlot = Math.floor((n - 1) / 2);
const step = 180 / n;
/** 槽位 j 的世界角:-90°(正上) → +90°(正下),均布在右半圆开区间内。 */
const slotAngle = (slot: number) => -90 + (slot + 0.5) * step;
const requestedIdx = activeId
? groups.findIndex((g) => g.id === activeId)
: -1;
const activeIdx = requestedIdx >= 0 ? requestedIdx : midSlot;
const active = groups[activeIdx];
/** 静态分组数组的旋转量:group(gi) 落在槽位 (gi + shift) mod n,激活组恰在 midSlot。 */
const shift = midSlot - activeIdx;
const groupAtSlot = (slot: number) => groups[mod(slot - shift, n)];
const handleTypeClick = (slot: number) => {
const group = groupAtSlot(slot);
if (group.id === active.id) return;
const k = midSlot - slot; // 正=顺时针向下转,负=逆时针向上转
const angles: Record<string, number> = {};
for (let j = 0; j < n; j++) {
const g = groupAtSlot(j);
const nextSlot = j + k;
let angle = slotAngle(mod(nextSlot, n));
if (nextSlot >= n) {
angle += 360; // 从底部滑出(进入左半圆不可视区),绕背回顶部补位
} else if (nextSlot < 0) {
angle -= 360; // 从顶部滑出(进入左半圆不可视区),绕背回底部补位
}
angles[g.id] = angle;
}
setActiveId(group.id);
setAnim(angles);
if (timerRef.current != null) window.clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(() => {
// 动画结束:归一化长路角度(同一位置 mod 360),下一帧恢复过渡。
setFrozen(true);
setAnim(null);
requestAnimationFrame(() =>
requestAnimationFrame(() => setFrozen(false)),
);
}, DIAL_TRANSITION_MS + 60);
};
const handleAgentClick = (agent: Agent) => {
router.push(`/page/workspace/agents/${agent.id}/chats/new`);
};
const themeClass = isDark ? "ao-dark" : "ao-light";
return (
<div
className={`agents-orbit-page ${themeClass}${n >= 8 ? " ao-crowded" : ""}`}
>
<div className="ao-header">
<span className="ao-header-title">智能体 · 星轨转盘</span>
<button
type="button"
className="ao-header-link"
onClick={() => router.push("/page/workspace/agents")}
>
经典视图
</button>
</div>
{/* 左侧星轨舞台:中心圆盘 + 中心图片 + 三层圆环 + 卫星 + 类型转盘 */}
<div className="ao-stage">
<div className="ao-root">
<div className="ao-ring ao-ring--ticks" />
<div className="ao-ring ao-ring--dash" />
<div className="ao-ring ao-ring--arc" />
<div className="ao-sat-orbit">
<div className="ao-sat" />
</div>
<div className="ao-core">
<div className="ao-core-img">
<Bot size={60} strokeWidth={1.4} />
</div>
</div>
{/* 转盘容器:左半圆被裁剪为不可视区域,绕背旋转不可见 */}
<div className="ao-types">
{groups.map((group, gi) => {
const slot = mod(gi + shift, n);
const angle = anim
? (anim[group.id] ?? slotAngle(slot))
: slotAngle(slot);
const isActive = group.id === active.id;
const transitionStyle = frozen ? { transition: "none" } : {};
return (
<div
key={group.id}
className="ao-type-arm"
style={{
transform: `rotate(${angle}deg)`,
...transitionStyle,
}}
>
<button
type="button"
className={`ao-type-anchor${isActive ? " is-active" : ""}`}
style={{
transform: `translate(-50%, -50%) rotate(${-angle}deg)`,
...transitionStyle,
}}
onClick={() => handleTypeClick(slot)}
title={`${group.name} · ${group.agents.length} 个智能体`}
>
<span
className="ao-type-chip"
style={{ animationDelay: `${-gi * 0.8}s` }}
>
{group.name}
</span>
</button>
</div>
);
})}
</div>
</div>
</div>
{/* 右侧面板:当前类型下的智能体(圆角正方形图标 + 名称),与舞台 1:1 */}
<aside className="ao-panel">
<div className="ao-panel-head">
<div className="ao-panel-title">{active.name}</div>
<div className="ao-panel-sub">共 {active.agents.length} 个智能体</div>
</div>
{active.agents.length > 0 ? (
<div className="ao-agents" key={active.id}>
{active.agents.map((agent) => (
<button
key={agent.id}
type="button"
className="ao-agent"
onClick={() => handleAgentClick(agent)}
title={agent.description || agent.name}
>
<span className="ao-agent-icon">
{(agent.name || "?").trim().charAt(0).toUpperCase()}
</span>
<span className="ao-agent-name">{agent.name}</span>
</button>
))}
</div>
) : (
<div className="ao-state">
<span>该类型下暂时没有智能体</span>
</div>
)}
</aside>
</div>
);
}
+7 -2
View File
@@ -1,5 +1,10 @@
import { AgentGallery } from "@/components/workspace/agents/agent-gallery";
export default function AgentsPage() {
return <AgentGallery />;
export default function AgentsPage({
restrictWxTypes = false,
}: {
/** wx 侧栏入口:类型 Tab 只保留 我的 / 默认广场 / 南京-db。 */
restrictWxTypes?: boolean;
} = {}) {
return <AgentGallery restrictWxTypes={restrictWxTypes} />;
}
@@ -0,0 +1,5 @@
import "@/styles/agents-plaza.css";
export default function AgentsPlazaPage() {
return <div className="agents-plaza-page" />;
}
Loaded 100 of 864 files, more files were not shown because too many files have changed in this diff. Show more