sync update from LAN repo (source ji @ 85078a31)
This commit is contained in:
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) => ({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
"'": "'",
|
||||
'"': """,
|
||||
})[character] ?? character);
|
||||
}
|
||||
@@ -0,0 +1,682 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
Handle,
|
||||
Panel,
|
||||
Position,
|
||||
ReactFlow,
|
||||
ReactFlowProvider,
|
||||
useReactFlow,
|
||||
type Edge,
|
||||
type Node,
|
||||
type NodeProps,
|
||||
} from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import {
|
||||
Download,
|
||||
FileJson,
|
||||
ImageDown,
|
||||
LocateFixed,
|
||||
Maximize2,
|
||||
Minus,
|
||||
Plus,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { renderSvgElementToPngBlob, triggerBlobDownload } from "@/core/utils/mermaid";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { VisualizationCard } from "./visualization-card";
|
||||
import type { MindmapNodeKind, MindmapSpec } from "./visualization-spec";
|
||||
|
||||
const NODE_WIDTH = 184;
|
||||
const NODE_HEIGHT = 68;
|
||||
const LAYER_GAP = 244;
|
||||
const ROW_GAP = 88;
|
||||
const PREVIEW_DEPTH = 2;
|
||||
|
||||
const KIND_META: Record<MindmapNodeKind, { label: string; className: string }> = {
|
||||
topic: { label: "主题", className: "border-primary/50 bg-primary/10" },
|
||||
dimension: { label: "维度", className: "border-sky-500/45 bg-sky-500/10" },
|
||||
finding: { label: "发现", className: "border-violet-500/45 bg-violet-500/10" },
|
||||
evidence: { label: "依据", className: "border-emerald-500/45 bg-emerald-500/10" },
|
||||
risk: { label: "风险", className: "border-amber-500/50 bg-amber-500/10" },
|
||||
action: { label: "建议", className: "border-rose-500/45 bg-rose-500/10" },
|
||||
};
|
||||
|
||||
type MindmapNode = MindmapSpec["nodes"][number];
|
||||
|
||||
type LayoutNode = MindmapNode & {
|
||||
depth: number;
|
||||
x: number;
|
||||
y: number;
|
||||
hiddenCount: number;
|
||||
hasChildren: boolean;
|
||||
collapsed: boolean;
|
||||
};
|
||||
|
||||
type MindmapLayout = {
|
||||
nodes: LayoutNode[];
|
||||
edges: Array<{ id: string; from: string; to: string }>;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
function childrenByParent(spec: MindmapSpec) {
|
||||
const children = new Map<string, MindmapNode[]>();
|
||||
for (const node of spec.nodes) {
|
||||
if (node.parentId === null) continue;
|
||||
const list = children.get(node.parentId) ?? [];
|
||||
list.push(node);
|
||||
children.set(node.parentId, list);
|
||||
}
|
||||
return children;
|
||||
}
|
||||
|
||||
function getRoot(spec: MindmapSpec): MindmapNode {
|
||||
return spec.nodes.find((node) => node.parentId === null)!;
|
||||
}
|
||||
|
||||
function descendantsCount(id: string, children: Map<string, MindmapNode[]>): number {
|
||||
let count = 0;
|
||||
for (const child of children.get(id) ?? []) {
|
||||
count += 1 + descendantsCount(child.id, children);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* A rooted analysis tree uses a left-to-right layout in messages. It reads more
|
||||
* naturally than a radial graph inside a narrow chat column and stays stable
|
||||
* when the same persisted message is replayed.
|
||||
*/
|
||||
function layoutMindmap(
|
||||
spec: MindmapSpec,
|
||||
maxDepth?: number,
|
||||
collapsedIds: ReadonlySet<string> = new Set(),
|
||||
): MindmapLayout {
|
||||
const children = childrenByParent(spec);
|
||||
const root = getRoot(spec);
|
||||
const visible = new Map<string, { node: MindmapNode; depth: number }>();
|
||||
|
||||
const collect = (node: MindmapNode, depth: number) => {
|
||||
visible.set(node.id, { node, depth });
|
||||
if ((maxDepth !== undefined && depth >= maxDepth) || collapsedIds.has(node.id)) return;
|
||||
for (const child of children.get(node.id) ?? []) collect(child, depth + 1);
|
||||
};
|
||||
collect(root, 0);
|
||||
|
||||
const unitHeight = (id: string): number => {
|
||||
const childIds = (children.get(id) ?? []).filter((child) => visible.has(child.id));
|
||||
if (childIds.length === 0) return 1;
|
||||
return childIds.reduce((sum, child) => sum + unitHeight(child.id), 0);
|
||||
};
|
||||
|
||||
const positions = new Map<string, { x: number; y: number }>();
|
||||
const assign = (id: string, depth: number, top: number): number => {
|
||||
const ownUnits = unitHeight(id);
|
||||
positions.set(id, {
|
||||
x: depth * LAYER_GAP,
|
||||
y: top + ((ownUnits - 1) * ROW_GAP) / 2,
|
||||
});
|
||||
let nextTop = top;
|
||||
for (const child of children.get(id) ?? []) {
|
||||
if (!visible.has(child.id)) continue;
|
||||
nextTop = assign(child.id, depth + 1, nextTop) + ROW_GAP;
|
||||
}
|
||||
return top + (ownUnits - 1) * ROW_GAP;
|
||||
};
|
||||
assign(root.id, 0, 0);
|
||||
|
||||
const nodes = [...visible.values()].map(({ node, depth }) => ({
|
||||
...node,
|
||||
depth,
|
||||
x: positions.get(node.id)?.x ?? 0,
|
||||
y: positions.get(node.id)?.y ?? 0,
|
||||
hiddenCount:
|
||||
(maxDepth !== undefined && depth === maxDepth) || collapsedIds.has(node.id)
|
||||
? descendantsCount(node.id, children)
|
||||
: 0,
|
||||
hasChildren: (children.get(node.id) ?? []).length > 0,
|
||||
collapsed: collapsedIds.has(node.id),
|
||||
}));
|
||||
const nodeIds = new Set(nodes.map((node) => node.id));
|
||||
const edges = nodes
|
||||
.filter((node) => node.parentId && nodeIds.has(node.parentId))
|
||||
.map((node) => ({ id: `${node.parentId}->${node.id}`, from: node.parentId!, to: node.id }));
|
||||
const maxX = Math.max(...nodes.map((node) => node.x), 0);
|
||||
const maxY = Math.max(...nodes.map((node) => node.y), 0);
|
||||
return {
|
||||
nodes,
|
||||
edges,
|
||||
width: maxX + NODE_WIDTH + 64,
|
||||
height: maxY + NODE_HEIGHT + 64,
|
||||
};
|
||||
}
|
||||
|
||||
type FlowNodeData = {
|
||||
node: LayoutNode;
|
||||
selected: boolean;
|
||||
onToggle?: (id: string) => void;
|
||||
};
|
||||
|
||||
function MindmapFlowNode({ data }: NodeProps) {
|
||||
const { node, selected, onToggle } = data as FlowNodeData;
|
||||
const meta = KIND_META[node.kind];
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-[184px] rounded-xl border bg-background px-3 py-2 shadow-sm transition-shadow",
|
||||
"hover:shadow-md focus-within:ring-2 focus-within:ring-primary/50",
|
||||
meta.className,
|
||||
selected && "ring-2 ring-primary ring-offset-2 ring-offset-background",
|
||||
)}
|
||||
title={node.label}
|
||||
>
|
||||
{node.parentId ? <Handle type="target" position={Position.Left} className="!size-1.5 !border-0 !bg-border" /> : null}
|
||||
<div className="mb-1 flex items-center gap-1.5 text-[11px] leading-none text-muted-foreground">
|
||||
<span className="font-medium">{meta.label}</span>
|
||||
{node.hiddenCount > 0 ? (
|
||||
<span className="rounded-full bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
||||
+{node.hiddenCount}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="line-clamp-2 text-sm font-medium leading-5 text-foreground">{node.label}</div>
|
||||
{node.hasChildren ? (
|
||||
<>
|
||||
<Handle type="source" position={Position.Right} className="!size-6 !border-0 !bg-transparent" />
|
||||
<button
|
||||
type="button"
|
||||
className="absolute -right-3 top-1/2 z-20 flex size-6 -translate-y-1/2 items-center justify-center rounded-full border border-slate-400 bg-background text-slate-600 shadow-sm transition-colors hover:border-primary hover:bg-primary hover:text-primary-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
aria-label={`${node.collapsed ? "展开" : "收起"}${node.label}`}
|
||||
aria-expanded={!node.collapsed}
|
||||
title={node.collapsed ? "展开子节点" : "收起子节点"}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onToggle?.(node.id);
|
||||
}}
|
||||
>
|
||||
{node.collapsed ? <Plus aria-hidden="true" className="size-3.5" /> : <Minus aria-hidden="true" className="size-3.5" />}
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const NODE_TYPES = { mindmap: MindmapFlowNode };
|
||||
|
||||
function MindmapCanvasControls({
|
||||
compact,
|
||||
onClose,
|
||||
}: {
|
||||
compact: boolean;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const rf = useReactFlow();
|
||||
const buttonClassName = compact ? "size-7" : "size-8";
|
||||
const padding = compact ? 0.16 : 0.12;
|
||||
|
||||
return (
|
||||
<Panel position="top-right" className="flex items-center gap-1 rounded-lg border bg-background/95 p-1 shadow-sm backdrop-blur">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={buttonClassName}
|
||||
title="放大"
|
||||
aria-label="放大思维导图"
|
||||
onClick={() => void rf.zoomIn({ duration: 150 })}
|
||||
>
|
||||
<Plus aria-hidden="true" className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={buttonClassName}
|
||||
title="缩小"
|
||||
aria-label="缩小思维导图"
|
||||
onClick={() => void rf.zoomOut({ duration: 150 })}
|
||||
>
|
||||
<Minus aria-hidden="true" className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={buttonClassName}
|
||||
title="重置位置与缩放"
|
||||
aria-label="重置思维导图位置与缩放"
|
||||
onClick={() => void rf.fitView({ duration: 220, padding })}
|
||||
>
|
||||
<LocateFixed aria-hidden="true" className="size-4" />
|
||||
</Button>
|
||||
{onClose ? (
|
||||
<>
|
||||
<span aria-hidden="true" className="mx-0.5 h-4 w-px bg-border" />
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={buttonClassName}
|
||||
title="关闭"
|
||||
aria-label="关闭思维导图"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X aria-hidden="true" className="size-4" />
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function overviewNodeColor(kind: MindmapNodeKind): string {
|
||||
return svgColors(kind).fill;
|
||||
}
|
||||
|
||||
function MindmapOverview({ layout }: { layout: MindmapLayout }) {
|
||||
const nodesById = new Map(layout.nodes.map((node) => [node.id, node]));
|
||||
|
||||
return (
|
||||
<Panel position="bottom-right" className="overflow-hidden rounded-lg border bg-background/95 p-1 shadow-md backdrop-blur">
|
||||
<div className="mb-1 px-1.5 pt-0.5 text-[10px] font-medium text-muted-foreground">全图预览</div>
|
||||
<svg
|
||||
className="block h-28 w-44 rounded bg-muted/30"
|
||||
viewBox={`0 0 ${layout.width} ${layout.height}`}
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
role="img"
|
||||
aria-label="思维导图全图预览"
|
||||
>
|
||||
{layout.edges.map((edge) => {
|
||||
const from = nodesById.get(edge.from);
|
||||
const to = nodesById.get(edge.to);
|
||||
if (!from || !to) return null;
|
||||
return (
|
||||
<path
|
||||
key={edge.id}
|
||||
d={`M ${from.x + NODE_WIDTH} ${from.y + NODE_HEIGHT / 2} L ${to.x} ${to.y + NODE_HEIGHT / 2}`}
|
||||
fill="none"
|
||||
stroke="#64748b"
|
||||
strokeWidth="4"
|
||||
strokeOpacity="0.8"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{layout.nodes.map((node) => (
|
||||
<rect
|
||||
key={node.id}
|
||||
x={node.x}
|
||||
y={node.y}
|
||||
width={NODE_WIDTH}
|
||||
height={NODE_HEIGHT}
|
||||
rx="10"
|
||||
fill={overviewNodeColor(node.kind)}
|
||||
stroke="#94a3b8"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function MindmapCanvas({
|
||||
spec,
|
||||
preview,
|
||||
selectedId,
|
||||
onSelect,
|
||||
onClose,
|
||||
}: {
|
||||
spec: MindmapSpec;
|
||||
preview: boolean;
|
||||
selectedId?: string | null;
|
||||
onSelect?: (id: string) => void;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const rf = useReactFlow();
|
||||
const [collapsedIds, setCollapsedIds] = useState<Set<string>>(() => new Set());
|
||||
const toggleNode = useCallback((id: string) => {
|
||||
setCollapsedIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
const layout = useMemo(
|
||||
() => layoutMindmap(spec, preview ? PREVIEW_DEPTH : undefined, collapsedIds),
|
||||
[collapsedIds, preview, spec],
|
||||
);
|
||||
const nodes = useMemo<Node[]>(
|
||||
() =>
|
||||
layout.nodes.map((node) => ({
|
||||
id: node.id,
|
||||
type: "mindmap",
|
||||
position: { x: node.x, y: node.y },
|
||||
width: NODE_WIDTH,
|
||||
height: NODE_HEIGHT,
|
||||
data: { node, selected: node.id === selectedId, onToggle: toggleNode } satisfies FlowNodeData,
|
||||
})),
|
||||
[layout.nodes, selectedId, toggleNode],
|
||||
);
|
||||
const edges = useMemo<Edge[]>(
|
||||
() =>
|
||||
layout.edges.map((edge) => ({
|
||||
id: edge.id,
|
||||
source: edge.from,
|
||||
target: edge.to,
|
||||
type: "smoothstep",
|
||||
style: { stroke: "#64748b", strokeWidth: 2, strokeOpacity: 0.8 },
|
||||
})),
|
||||
[layout.edges],
|
||||
);
|
||||
const handleNodeClick = useCallback((id: string) => {
|
||||
const node = layout.nodes.find((item) => item.id === id);
|
||||
if (node?.hasChildren) {
|
||||
toggleNode(id);
|
||||
return;
|
||||
}
|
||||
onSelect?.(id);
|
||||
}, [layout.nodes, onSelect, toggleNode]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
void rf.fitView({ padding: preview ? 0.16 : 0.12, duration: 180 });
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [collapsedIds, preview, rf, spec]);
|
||||
|
||||
return (
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
nodeTypes={NODE_TYPES}
|
||||
fitView
|
||||
fitViewOptions={{ padding: preview ? 0.16 : 0.12 }}
|
||||
minZoom={0.12}
|
||||
maxZoom={2}
|
||||
nodesDraggable={false}
|
||||
nodesConnectable={false}
|
||||
elementsSelectable={Boolean(onSelect)}
|
||||
panOnDrag
|
||||
panOnScroll={false}
|
||||
zoomOnScroll
|
||||
zoomOnDoubleClick
|
||||
zoomOnPinch
|
||||
preventScrolling
|
||||
onNodeClick={(_, node) => handleNodeClick(node.id)}
|
||||
aria-label={`${spec.title}思维导图`}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} gap={18} size={1} color="var(--border)" />
|
||||
<MindmapCanvasControls compact={preview} onClose={onClose} />
|
||||
{!preview ? <MindmapOverview layout={layout} /> : null}
|
||||
</ReactFlow>
|
||||
);
|
||||
}
|
||||
|
||||
function svgEscape(value: string): string {
|
||||
return value.replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[char]!);
|
||||
}
|
||||
|
||||
function splitSvgLabel(label: string): string[] {
|
||||
const chunks = label.match(/.{1,16}/gu) ?? [label];
|
||||
return chunks.slice(0, 2);
|
||||
}
|
||||
|
||||
function buildMindmapSvg(spec: MindmapSpec): string {
|
||||
const layout = layoutMindmap(spec);
|
||||
const offset = 32;
|
||||
const lines = layout.edges
|
||||
.map((edge) => {
|
||||
const from = layout.nodes.find((node) => node.id === edge.from)!;
|
||||
const to = layout.nodes.find((node) => node.id === edge.to)!;
|
||||
const x1 = from.x + NODE_WIDTH + offset;
|
||||
const y1 = from.y + NODE_HEIGHT / 2 + offset;
|
||||
const x2 = to.x + offset;
|
||||
const y2 = to.y + NODE_HEIGHT / 2 + offset;
|
||||
const control = Math.max(42, (x2 - x1) / 2);
|
||||
return `<path d="M ${x1} ${y1} C ${x1 + control} ${y1}, ${x2 - control} ${y2}, ${x2} ${y2}" fill="none" stroke="#94a3b8" stroke-width="1.5"/>`;
|
||||
})
|
||||
.join("");
|
||||
const nodes = layout.nodes
|
||||
.map((node) => {
|
||||
const meta = KIND_META[node.kind];
|
||||
const { fill, stroke } = svgColors(node.kind);
|
||||
const lines = splitSvgLabel(node.label)
|
||||
.map((line, index) => `<tspan x="${node.x + offset + 14}" dy="${index === 0 ? 0 : 18}">${svgEscape(line)}</tspan>`)
|
||||
.join("");
|
||||
return [
|
||||
`<g>`,
|
||||
`<rect x="${node.x + offset}" y="${node.y + offset}" width="${NODE_WIDTH}" height="${NODE_HEIGHT}" rx="12" fill="${fill}" stroke="${stroke}" stroke-width="1.5"/>`,
|
||||
`<text x="${node.x + offset + 14}" y="${node.y + offset + 18}" font-family="Microsoft YaHei, Arial, sans-serif" font-size="10" fill="#64748b">${meta.label}</text>`,
|
||||
`<text x="${node.x + offset + 14}" y="${node.y + offset + 42}" font-family="Microsoft YaHei, Arial, sans-serif" font-size="13" font-weight="600" fill="#0f172a">${lines}</text>`,
|
||||
`</g>`,
|
||||
].join("");
|
||||
})
|
||||
.join("");
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="${layout.width + offset}" height="${layout.height + offset}" viewBox="0 0 ${layout.width + offset} ${layout.height + offset}" role="img" aria-label="${svgEscape(spec.title)}"><rect width="100%" height="100%" fill="#ffffff"/><text x="32" y="26" font-family="Microsoft YaHei, Arial, sans-serif" font-size="16" font-weight="700" fill="#0f172a">${svgEscape(spec.title)}</text>${lines}${nodes}</svg>`;
|
||||
}
|
||||
|
||||
function svgColors(kind: MindmapNodeKind): { fill: string; stroke: string } {
|
||||
switch (kind) {
|
||||
case "topic": return { fill: "#eff6ff", stroke: "#60a5fa" };
|
||||
case "dimension": return { fill: "#f0f9ff", stroke: "#38bdf8" };
|
||||
case "evidence": return { fill: "#ecfdf5", stroke: "#34d399" };
|
||||
case "risk": return { fill: "#fffbeb", stroke: "#fbbf24" };
|
||||
case "action": return { fill: "#fff1f2", stroke: "#fb7185" };
|
||||
default: return { fill: "#f5f3ff", stroke: "#a78bfa" };
|
||||
}
|
||||
}
|
||||
|
||||
function safeDownloadName(title: string, extension: string): string {
|
||||
const clean = title.replace(/[\\/:*?"<>|]/g, "-").trim().slice(0, 80) || "分析思维导图";
|
||||
return `${clean}.${extension}`;
|
||||
}
|
||||
|
||||
function MindmapDownloads({ spec }: { spec: MindmapSpec }) {
|
||||
const [downloading, setDownloading] = useState<"png" | "svg" | "json" | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const download = useCallback(async (format: "png" | "svg" | "json") => {
|
||||
setDownloading(format);
|
||||
try {
|
||||
if (format === "json") {
|
||||
triggerBlobDownload(
|
||||
new Blob([JSON.stringify(spec, null, 2)], { type: "application/json;charset=utf-8" }),
|
||||
safeDownloadName(spec.title, "mindmap.json"),
|
||||
);
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
const svgMarkup = buildMindmapSvg(spec);
|
||||
if (format === "svg") {
|
||||
triggerBlobDownload(
|
||||
new Blob([svgMarkup], { type: "image/svg+xml;charset=utf-8" }),
|
||||
safeDownloadName(spec.title, "svg"),
|
||||
);
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
const host = document.createElement("div");
|
||||
host.style.cssText = "position:fixed;left:-10000px;top:-10000px;pointer-events:none;opacity:0";
|
||||
host.innerHTML = svgMarkup;
|
||||
document.body.appendChild(host);
|
||||
try {
|
||||
const svg = host.querySelector("svg");
|
||||
if (!(svg instanceof SVGSVGElement)) throw new Error("无法生成思维导图图片");
|
||||
const blob = await renderSvgElementToPngBlob(svg);
|
||||
triggerBlobDownload(blob, safeDownloadName(spec.title, "png"));
|
||||
setOpen(false);
|
||||
} finally {
|
||||
host.remove();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to download mindmap", error);
|
||||
toast.error(error instanceof Error ? error.message : "下载思维导图失败");
|
||||
} finally {
|
||||
setDownloading(null);
|
||||
}
|
||||
}, [spec]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="size-9"
|
||||
title="下载思维导图"
|
||||
aria-label="下载思维导图"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<Download aria-hidden="true" className="size-4" />
|
||||
</Button>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-sm p-5">
|
||||
<DialogHeader className="text-left">
|
||||
<DialogTitle>下载思维导图</DialogTitle>
|
||||
<DialogDescription>选择需要导出的文件格式。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-auto min-h-11 justify-start gap-3 px-3 py-2.5 text-left"
|
||||
onClick={() => void download("png")}
|
||||
disabled={downloading !== null}
|
||||
>
|
||||
<ImageDown aria-hidden="true" className="size-4 shrink-0" />
|
||||
<span>
|
||||
<span className="block text-sm font-medium">PNG 图片</span>
|
||||
<span className="block text-xs font-normal text-muted-foreground">
|
||||
{downloading === "png" ? "正在生成…" : "便于在文档或图片中使用"}
|
||||
</span>
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-auto min-h-11 justify-start gap-3 px-3 py-2.5 text-left"
|
||||
onClick={() => void download("svg")}
|
||||
disabled={downloading !== null}
|
||||
>
|
||||
<Download aria-hidden="true" className="size-4 shrink-0" />
|
||||
<span>
|
||||
<span className="block text-sm font-medium">SVG 矢量图</span>
|
||||
<span className="block text-xs font-normal text-muted-foreground">适合无损缩放和二次编辑</span>
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-auto min-h-11 justify-start gap-3 px-3 py-2.5 text-left"
|
||||
onClick={() => void download("json")}
|
||||
disabled={downloading !== null}
|
||||
>
|
||||
<FileJson aria-hidden="true" className="size-4 shrink-0" />
|
||||
<span>
|
||||
<span className="block text-sm font-medium">JSON 数据</span>
|
||||
<span className="block text-xs font-normal text-muted-foreground">保留完整节点结构</span>
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MindmapDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
spec,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
spec: MindmapSpec;
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="flex h-[calc(100dvh-2rem)] !w-[calc(100vw-2rem)] !max-w-[calc(100vw-2rem)] flex-col gap-0 overflow-hidden rounded-xl p-0 sm:!max-w-[calc(100vw-2rem)]"
|
||||
>
|
||||
<DialogTitle className="sr-only">{spec.title}</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
思维导图画布。支持鼠标滚轮缩放、拖动画布,并可使用右上角按钮调整视图。
|
||||
</DialogDescription>
|
||||
<div className="relative min-h-0 flex-1 bg-muted/20">
|
||||
<div className="absolute inset-0">
|
||||
<ReactFlowProvider>
|
||||
<MindmapCanvas spec={spec} preview={false} onClose={() => onOpenChange(false)} />
|
||||
</ReactFlowProvider>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MindmapRenderer({ spec }: { spec: MindmapSpec }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const root = getRoot(spec);
|
||||
const ariaSummary = spec.nodes.map((node) => `${KIND_META[node.kind].label}:${node.label}`).join(";");
|
||||
|
||||
return (
|
||||
<>
|
||||
<VisualizationCard className="my-4" >
|
||||
<section aria-label={`${spec.title}思维导图`}>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3 border-b px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="truncate text-sm font-semibold">{spec.title}</h2>
|
||||
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[11px] font-medium text-primary">思维导图</span>
|
||||
</div>
|
||||
<p className="mt-1 line-clamp-2 text-xs leading-5 text-muted-foreground">
|
||||
{spec.summary || `围绕“${root.label}”整理的 ${spec.nodes.length} 个分析节点。`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<MindmapDownloads spec={spec} />
|
||||
<Button type="button" size="sm" className="h-8 gap-1.5" onClick={() => setOpen(true)}>
|
||||
<Maximize2 aria-hidden="true" className="size-3.5" />
|
||||
展开查看
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="sr-only">{ariaSummary}</p>
|
||||
<div className="h-[20rem] bg-muted/15">
|
||||
<ReactFlowProvider>
|
||||
<MindmapCanvas
|
||||
spec={spec}
|
||||
preview
|
||||
onSelect={() => {
|
||||
setOpen(true);
|
||||
}}
|
||||
/>
|
||||
</ReactFlowProvider>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3 border-t px-4 py-2 text-xs text-muted-foreground">
|
||||
<span>预览显示前 {PREVIEW_DEPTH + 1} 层;完整导图可缩放查看。</span>
|
||||
<span>{spec.nodes.length} 个节点</span>
|
||||
</div>
|
||||
</section>
|
||||
</VisualizationCard>
|
||||
<MindmapDialog open={open} onOpenChange={setOpen} spec={spec} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { Component, lazy, Suspense, useMemo, type ErrorInfo, type ReactNode } from "react";
|
||||
import { AlertTriangle, Loader2 } from "lucide-react";
|
||||
|
||||
import {
|
||||
parseVisualizationSpec,
|
||||
type MapSpec,
|
||||
type MindmapSpec,
|
||||
type VisualizationLanguage,
|
||||
} from "./visualization-spec";
|
||||
|
||||
const MapRenderer = lazy(() => import("./map-renderer"));
|
||||
const MindmapRenderer = lazy(() => import("./mindmap-renderer"));
|
||||
|
||||
type VisualizationBlockProps = {
|
||||
language: VisualizationLanguage;
|
||||
source: string;
|
||||
complete: boolean;
|
||||
isLoading: boolean;
|
||||
};
|
||||
|
||||
function LoadingCard({ label = "正在生成可视化内容…" }: { label?: string }) {
|
||||
return (
|
||||
<div
|
||||
className="my-4 flex min-h-32 w-full max-w-none items-center justify-center rounded-xl border bg-muted/20 text-muted-foreground"
|
||||
role="status"
|
||||
>
|
||||
<Loader2 aria-hidden="true" className="mr-2 size-4 animate-spin" />
|
||||
<span className="text-sm">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorCard({
|
||||
message,
|
||||
source,
|
||||
title = "可视化内容暂时无法显示",
|
||||
}: {
|
||||
message: string;
|
||||
source?: string;
|
||||
title?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="my-4 w-full max-w-none rounded-xl border border-destructive/30 bg-destructive/5 p-4"
|
||||
role="alert"
|
||||
>
|
||||
<div className="flex items-start gap-2 text-sm text-destructive">
|
||||
<AlertTriangle aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
|
||||
<div>
|
||||
<div className="font-medium">{title}</div>
|
||||
<div className="mt-1 text-xs opacity-80">{message}</div>
|
||||
</div>
|
||||
</div>
|
||||
{source ? (
|
||||
<details className="mt-3 text-xs text-muted-foreground">
|
||||
<summary className="cursor-pointer select-none">查看原始数据</summary>
|
||||
<pre className="mt-2 max-h-48 overflow-auto whitespace-pre-wrap rounded-lg bg-muted/50 p-3">
|
||||
{source}
|
||||
</pre>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
class VisualizationErrorBoundary extends Component<
|
||||
{ children: ReactNode; source: string },
|
||||
{ error: string | null }
|
||||
> {
|
||||
state = { error: null as string | null };
|
||||
|
||||
static getDerivedStateFromError(error: unknown) {
|
||||
return { error: error instanceof Error ? error.message : "可视化渲染失败" };
|
||||
}
|
||||
|
||||
componentDidCatch(error: unknown, info: ErrorInfo) {
|
||||
console.error("Visualization renderer failed", error, info.componentStack);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.error) {
|
||||
return <ErrorCard message={this.state.error} source={this.props.source} />;
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export function VisualizationBlock({
|
||||
language,
|
||||
source,
|
||||
complete,
|
||||
isLoading,
|
||||
}: VisualizationBlockProps) {
|
||||
const result = useMemo(
|
||||
() => parseVisualizationSpec(language, source),
|
||||
[language, source],
|
||||
);
|
||||
|
||||
if (!result.ok) {
|
||||
if (isLoading && !complete) {
|
||||
return <LoadingCard label={language === "mindmap-json" ? "正在整理思维导图…" : undefined} />;
|
||||
}
|
||||
return (
|
||||
<ErrorCard
|
||||
title={language === "mindmap-json" ? "思维导图暂时无法显示" : "地图内容暂时无法显示"}
|
||||
message={result.error}
|
||||
source={source}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<VisualizationErrorBoundary key={`${language}-${source.length}`} source={source}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<LoadingCard
|
||||
label={language === "mindmap-json" ? "正在加载思维导图组件…" : "正在加载本地地图组件…"}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{language === "mindmap-json" ? (
|
||||
<MindmapRenderer spec={result.spec as MindmapSpec} />
|
||||
) : (
|
||||
<MapRenderer spec={result.spec as MapSpec} />
|
||||
)}
|
||||
</Suspense>
|
||||
</VisualizationErrorBoundary>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function VisualizationCard({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
"my-4 w-full max-w-none overflow-hidden rounded-xl border bg-background shadow-sm",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
// @ts-expect-error Node's type-stripping test runner requires the explicit .ts suffix.
|
||||
import { parseVisualizationSpec, splitVisualizationContent } from "./visualization-spec.ts";
|
||||
|
||||
test("ordinary markdown remains one unchanged segment", () => {
|
||||
const content = "普通回答\n\n```ts\nconst value = 1;\n```";
|
||||
assert.deepEqual(splitVisualizationContent(content), [{ type: "markdown", content }]);
|
||||
});
|
||||
|
||||
test("splits a dedicated map block while keeping surrounding markdown", () => {
|
||||
const content = [
|
||||
"下面是地图:\n",
|
||||
"```map-json",
|
||||
'{"title":"地点","markers":[{"name":"北京","coordinate":[116.4,39.9]}]}',
|
||||
"```",
|
||||
"\n地图结束。",
|
||||
].join("\n");
|
||||
const segments = splitVisualizationContent(content);
|
||||
assert.equal(segments.length, 3);
|
||||
assert.equal(segments[0]?.type, "markdown");
|
||||
assert.equal(segments[1]?.type, "visualization");
|
||||
assert.equal(segments[2]?.type, "markdown");
|
||||
});
|
||||
|
||||
test("accepts the map contract", () => {
|
||||
const map = parseVisualizationSpec(
|
||||
"map-json",
|
||||
JSON.stringify({ markers: [{ name: "北京", coordinate: [116.4, 39.9] }] }),
|
||||
);
|
||||
assert.equal(map.ok, true);
|
||||
});
|
||||
|
||||
test("accepts a dedicated mindmap contract", () => {
|
||||
const result = parseVisualizationSpec(
|
||||
"mindmap-json",
|
||||
JSON.stringify({
|
||||
title: "形势分析",
|
||||
rootId: "root",
|
||||
nodes: [
|
||||
{ id: "root", parentId: null, label: "形势分析", kind: "topic" },
|
||||
{ id: "risk", parentId: "root", label: "主要风险", kind: "risk" },
|
||||
{ id: "action", parentId: "root", label: "处置建议", kind: "action" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.ok && result.spec.type, "mindmap");
|
||||
});
|
||||
|
||||
test("renders a completed legacy mindmap fence as a mindmap", () => {
|
||||
const content = [
|
||||
"```mindmap",
|
||||
'{"rootId":"root","nodes":[{"id":"root","parentId":null,"label":"主题"}]}',
|
||||
"```",
|
||||
].join("\n");
|
||||
const visual = splitVisualizationContent(content).find((segment) => segment.type === "visualization");
|
||||
assert.ok(visual && visual.type === "visualization");
|
||||
assert.equal(visual.language, "mindmap-json");
|
||||
assert.equal(visual.complete, true);
|
||||
});
|
||||
|
||||
test("rejects mindmaps with a cycle or missing parent", () => {
|
||||
const cycle = parseVisualizationSpec(
|
||||
"mindmap-json",
|
||||
JSON.stringify({
|
||||
nodes: [
|
||||
{ id: "root", parentId: null, label: "主题" },
|
||||
{ id: "a", parentId: "b", label: "A" },
|
||||
{ id: "b", parentId: "a", label: "B" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
assert.equal(cycle.ok, false);
|
||||
|
||||
const missingParent = parseVisualizationSpec(
|
||||
"mindmap-json",
|
||||
JSON.stringify({
|
||||
nodes: [
|
||||
{ id: "root", parentId: null, label: "主题" },
|
||||
{ id: "a", parentId: "missing", label: "A" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
assert.equal(missingParent.ok, false);
|
||||
});
|
||||
|
||||
test("rejects dense mindmaps before they can overload the canvas", () => {
|
||||
const tooManyChildren = parseVisualizationSpec(
|
||||
"mindmap-json",
|
||||
JSON.stringify({
|
||||
nodes: [
|
||||
{ id: "root", parentId: null, label: "主题" },
|
||||
{ id: "a", parentId: "root", label: "维度 A" },
|
||||
{ id: "b", parentId: "root", label: "维度 B" },
|
||||
{ id: "c", parentId: "root", label: "维度 C" },
|
||||
{ id: "d", parentId: "root", label: "维度 D" },
|
||||
{ id: "e", parentId: "root", label: "维度 E" },
|
||||
{ id: "f", parentId: "root", label: "维度 F" },
|
||||
{ id: "g", parentId: "root", label: "维度 G" },
|
||||
{ id: "h", parentId: "root", label: "维度 H" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
assert.equal(tooManyChildren.ok, false);
|
||||
|
||||
const tooManyNodes = parseVisualizationSpec(
|
||||
"mindmap-json",
|
||||
JSON.stringify({
|
||||
nodes: [
|
||||
{ id: "root", parentId: null, label: "主题" },
|
||||
...Array.from({ length: 36 }, (_, index) => ({
|
||||
id: `node-${index}`,
|
||||
parentId: "root",
|
||||
label: `节点 ${index + 1}`,
|
||||
})),
|
||||
],
|
||||
}),
|
||||
);
|
||||
assert.equal(tooManyNodes.ok, false);
|
||||
assert.equal(
|
||||
tooManyNodes.ok ? "" : tooManyNodes.error,
|
||||
"思维导图节点过多(37 个);请将同类信息合并后控制在 36 个以内",
|
||||
);
|
||||
});
|
||||
|
||||
test("renders a valid map contract from a generic json fence", () => {
|
||||
const content = [
|
||||
"下面是地图:",
|
||||
"```json",
|
||||
'{"title":"城市分布","markers":[{"name":"北京","coordinate":[116.4,39.9]}]}',
|
||||
"```",
|
||||
].join("\n");
|
||||
const segments = splitVisualizationContent(content);
|
||||
const visual = segments.find((segment) => segment.type === "visualization");
|
||||
assert.ok(visual && visual.type === "visualization");
|
||||
assert.equal(visual.language, "map-json");
|
||||
});
|
||||
|
||||
test("keeps non-map json fences on the ordinary Markdown path", () => {
|
||||
const content = '```json\n{"name":"普通数据","items":[1,2,3]}\n```';
|
||||
assert.deepEqual(splitVisualizationContent(content), [{ type: "markdown", content }]);
|
||||
});
|
||||
|
||||
test("keeps generic mindmap-shaped JSON on the ordinary Markdown path", () => {
|
||||
const content = [
|
||||
"```json",
|
||||
'{"type":"mindmap","nodes":[{"id":"root","parentId":null,"label":"主题"}]}',
|
||||
"```",
|
||||
].join("\n");
|
||||
assert.deepEqual(splitVisualizationContent(content), [{ type: "markdown", content }]);
|
||||
});
|
||||
|
||||
test("keeps unfinished generic json fences on the ordinary Markdown path", () => {
|
||||
const content = '```json\n{"markers":[';
|
||||
assert.deepEqual(splitVisualizationContent(content), [{ type: "markdown", content }]);
|
||||
});
|
||||
|
||||
test("rejects invalid map coordinates", () => {
|
||||
const result = parseVisualizationSpec(
|
||||
"map-json",
|
||||
JSON.stringify({ markers: [{ name: "错误地点", coordinate: [200, 100] }] }),
|
||||
);
|
||||
assert.equal(result.ok, false);
|
||||
});
|
||||
|
||||
test("flow and chart fences stay on the ordinary Markdown path", () => {
|
||||
const content = "```flow-json\n{}\n```\n```chart-json\n{}\n```";
|
||||
assert.deepEqual(splitVisualizationContent(content), [{ type: "markdown", content }]);
|
||||
});
|
||||
|
||||
test("keeps an unfinished map fence for streaming fallback", () => {
|
||||
const segments = splitVisualizationContent("说明\n```map-json\n{\"title\":\"地点\"");
|
||||
const visual = segments.find((segment) => segment.type === "visualization");
|
||||
assert.ok(visual && visual.type === "visualization");
|
||||
assert.equal(visual.complete, false);
|
||||
});
|
||||
|
||||
test("shows unfinished dedicated mindmap JSON before mounting the renderer", () => {
|
||||
const content = "说明\n```mindmap-json\n{\"title\":\"分析导图\"";
|
||||
assert.deepEqual(splitVisualizationContent(content), [{ type: "markdown", content }]);
|
||||
});
|
||||
@@ -0,0 +1,271 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const VISUALIZATION_LANGUAGES = ["map-json", "mindmap-json"] as const;
|
||||
|
||||
export type VisualizationLanguage = (typeof VISUALIZATION_LANGUAGES)[number];
|
||||
|
||||
const safeText = z.string().trim().min(1).max(500);
|
||||
const optionalText = z.string().trim().max(2_000).optional();
|
||||
|
||||
const longitude = z.number().finite().min(-180).max(180);
|
||||
const latitude = z.number().finite().min(-90).max(90);
|
||||
const coordinateSchema = z.tuple([longitude, latitude]);
|
||||
|
||||
const mapSchema = z.object({
|
||||
type: z.literal("map").default("map"),
|
||||
title: z.string().trim().max(200).default("地图"),
|
||||
center: coordinateSchema.optional(),
|
||||
zoom: z.number().finite().min(1).max(20).optional(),
|
||||
markers: z
|
||||
.array(
|
||||
z.object({
|
||||
name: safeText,
|
||||
coordinate: coordinateSchema,
|
||||
description: optionalText,
|
||||
}),
|
||||
)
|
||||
.max(500)
|
||||
.default([]),
|
||||
lines: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string().trim().max(120).optional(),
|
||||
coordinates: z.array(coordinateSchema).min(2).max(2_000),
|
||||
}),
|
||||
)
|
||||
.max(100)
|
||||
.default([]),
|
||||
polygons: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string().trim().max(120).optional(),
|
||||
coordinates: z.array(coordinateSchema).min(3).max(2_000),
|
||||
}),
|
||||
)
|
||||
.max(100)
|
||||
.default([]),
|
||||
});
|
||||
|
||||
export type MapSpec = z.infer<typeof mapSchema>;
|
||||
|
||||
export const MINDMAP_NODE_KINDS = [
|
||||
"topic",
|
||||
"dimension",
|
||||
"finding",
|
||||
"evidence",
|
||||
"risk",
|
||||
"action",
|
||||
] as const;
|
||||
|
||||
export type MindmapNodeKind = (typeof MINDMAP_NODE_KINDS)[number];
|
||||
|
||||
const mindmapNodeSchema = z.object({
|
||||
id: z.string().trim().min(1).max(80),
|
||||
parentId: z.string().trim().min(1).max(80).nullable(),
|
||||
label: z.string().trim().min(1).max(120),
|
||||
kind: z.enum(MINDMAP_NODE_KINDS).default("finding"),
|
||||
detail: z.string().trim().max(1_200).optional(),
|
||||
sourceRefs: z.array(z.string().trim().min(1).max(160)).max(12).optional(),
|
||||
});
|
||||
|
||||
const mindmapSchema = z.object({
|
||||
type: z.literal("mindmap").default("mindmap"),
|
||||
title: z.string().trim().max(200).default("分析思维导图"),
|
||||
summary: z.string().trim().max(800).optional(),
|
||||
rootId: z.string().trim().min(1).max(80).optional(),
|
||||
// A mind map is an overview, not a transcription of every paragraph in a
|
||||
// report. Keeping this small is also important because every node becomes a
|
||||
// rendered React Flow element in the message list and full-screen canvas.
|
||||
nodes: z.array(mindmapNodeSchema).min(1).max(36),
|
||||
});
|
||||
|
||||
export type MindmapSpec = z.infer<typeof mindmapSchema>;
|
||||
type MindmapNode = MindmapSpec["nodes"][number];
|
||||
export type VisualizationSpec = MapSpec | MindmapSpec;
|
||||
|
||||
export type VisualizationParseResult =
|
||||
| { ok: true; spec: VisualizationSpec }
|
||||
| { ok: false; error: string };
|
||||
|
||||
function normalizeLanguageType(
|
||||
language: VisualizationLanguage,
|
||||
value: unknown,
|
||||
): unknown {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
|
||||
const record = { ...(value as Record<string, unknown>) };
|
||||
if (language === "map-json") record.type = "map";
|
||||
if (language === "mindmap-json") record.type = "mindmap";
|
||||
return record;
|
||||
}
|
||||
|
||||
function formatZodError(error: z.ZodError): string {
|
||||
const issue = error.issues[0];
|
||||
if (!issue) return "可视化数据格式不正确";
|
||||
const path = issue.path.length > 0 ? `${issue.path.join(".")}:` : "";
|
||||
return `${path}${issue.message}`;
|
||||
}
|
||||
|
||||
function validateMindmap(spec: MindmapSpec): string | null {
|
||||
const nodesById = new Map<string, MindmapSpec["nodes"][number]>();
|
||||
for (const node of spec.nodes) {
|
||||
if (nodesById.has(node.id)) return `节点 id 重复:${node.id}`;
|
||||
nodesById.set(node.id, node);
|
||||
}
|
||||
|
||||
const roots = spec.nodes.filter((node) => node.parentId === null);
|
||||
if (roots.length !== 1) return "思维导图必须且只能有一个根节点";
|
||||
const root = roots[0]!;
|
||||
if (spec.rootId && spec.rootId !== root.id) return "rootId 必须指向唯一的根节点";
|
||||
|
||||
for (const node of spec.nodes) {
|
||||
if (node.parentId === null) continue;
|
||||
if (node.parentId === node.id) return `节点不能以自身为父节点:${node.id}`;
|
||||
if (!nodesById.has(node.parentId)) return `节点 ${node.id} 的父节点不存在:${node.parentId}`;
|
||||
}
|
||||
|
||||
const children = new Map<string, MindmapNode[]>();
|
||||
for (const node of spec.nodes) {
|
||||
if (node.parentId === null) continue;
|
||||
const siblings = children.get(node.parentId) ?? [];
|
||||
siblings.push(node);
|
||||
children.set(node.parentId, siblings);
|
||||
}
|
||||
for (const [parentId, siblings] of children) {
|
||||
const limit = parentId === root.id ? 6 : 3;
|
||||
if (siblings.length > limit) {
|
||||
const parentLabel = nodesById.get(parentId)?.label ?? parentId;
|
||||
return parentId === root.id
|
||||
? `中心主题最多保留 6 个一级维度,当前“${parentLabel}”下有 ${siblings.length} 个`
|
||||
: `节点“${parentLabel}”最多保留 3 个子节点,当前有 ${siblings.length} 个;请合并同类信息`;
|
||||
}
|
||||
}
|
||||
|
||||
for (const node of spec.nodes) {
|
||||
let depth = 0;
|
||||
let cursor: MindmapSpec["nodes"][number] | undefined = node;
|
||||
const visited = new Set<string>();
|
||||
while (cursor && cursor.parentId !== null) {
|
||||
if (visited.has(cursor.id)) return "思维导图不能包含循环父子关系";
|
||||
visited.add(cursor.id);
|
||||
cursor = nodesById.get(cursor.parentId);
|
||||
depth += 1;
|
||||
if (depth > 3) return "思维导图最多支持 4 层层级";
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function mindmapShapeError(value: unknown): string | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
const nodes = (value as Record<string, unknown>).nodes;
|
||||
if (Array.isArray(nodes) && nodes.length > 36) {
|
||||
return `思维导图节点过多(${nodes.length} 个);请将同类信息合并后控制在 36 个以内`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseVisualizationSpec(
|
||||
language: VisualizationLanguage,
|
||||
source: string,
|
||||
): VisualizationParseResult {
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = JSON.parse(source);
|
||||
} catch {
|
||||
return { ok: false, error: "地图数据尚未完整或不是有效 JSON" };
|
||||
}
|
||||
|
||||
const normalized = normalizeLanguageType(language, raw);
|
||||
if (!normalized || typeof normalized !== "object" || Array.isArray(normalized)) {
|
||||
return { ok: false, error: "地图数据必须是 JSON 对象" };
|
||||
}
|
||||
|
||||
if (language === "mindmap-json") {
|
||||
const error = mindmapShapeError(normalized);
|
||||
if (error) return { ok: false, error };
|
||||
}
|
||||
|
||||
const schema = language === "map-json" ? mapSchema : mindmapSchema;
|
||||
const parsed = schema.safeParse(normalized);
|
||||
if (!parsed.success) return { ok: false, error: formatZodError(parsed.error) };
|
||||
if (language === "mindmap-json") {
|
||||
const error = validateMindmap(parsed.data as MindmapSpec);
|
||||
if (error) return { ok: false, error };
|
||||
}
|
||||
return { ok: true, spec: parsed.data };
|
||||
}
|
||||
|
||||
export type VisualizationSegment =
|
||||
| { type: "markdown"; content: string }
|
||||
| {
|
||||
type: "visualization";
|
||||
language: VisualizationLanguage;
|
||||
source: string;
|
||||
complete: boolean;
|
||||
};
|
||||
|
||||
// `mindmap-json` is the contract. `mindmap` is retained as a compatibility
|
||||
// alias because existing model turns have already emitted that fence name.
|
||||
const FENCE_PATTERN = /(^|\n)```(map-json|mindmap-json|mindmap|json)[ \t]*\r?\n/gi;
|
||||
|
||||
function isMapJsonSource(source: string): boolean {
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = JSON.parse(source);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return false;
|
||||
const record = raw as Record<string, unknown>;
|
||||
const hasMapField = ["center", "zoom", "markers", "lines", "polygons"].some(
|
||||
(field) => Object.prototype.hasOwnProperty.call(record, field),
|
||||
);
|
||||
return hasMapField && parseVisualizationSpec("map-json", source).ok;
|
||||
}
|
||||
|
||||
/** Split only our dedicated fences. With no match, the caller keeps the exact legacy renderer path. */
|
||||
export function splitVisualizationContent(content: string): VisualizationSegment[] {
|
||||
const segments: VisualizationSegment[] = [];
|
||||
let cursor = 0;
|
||||
FENCE_PATTERN.lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = FENCE_PATTERN.exec(content)) !== null) {
|
||||
const blockStart = match.index + match[1].length;
|
||||
if (blockStart > cursor) {
|
||||
segments.push({ type: "markdown", content: content.slice(cursor, blockStart) });
|
||||
}
|
||||
const sourceStart = FENCE_PATTERN.lastIndex;
|
||||
const closeIndex = content.indexOf("```", sourceStart);
|
||||
const complete = closeIndex >= 0;
|
||||
const source = content.slice(sourceStart, complete ? closeIndex : content.length).trim();
|
||||
const matchedLanguage = match[2].toLowerCase();
|
||||
const isMindmapFence = matchedLanguage === "mindmap-json" || matchedLanguage === "mindmap";
|
||||
// Dedicated mind-map JSON is shown as an ordinary streaming code block
|
||||
// until the closing fence arrives. That lets people see the model writing
|
||||
// and validate the raw structure before the expensive renderer mounts.
|
||||
if (isMindmapFence && !complete) {
|
||||
return [{ type: "markdown", content }];
|
||||
}
|
||||
if (matchedLanguage === "json" && (!complete || !isMapJsonSource(source))) {
|
||||
if (!complete) break;
|
||||
FENCE_PATTERN.lastIndex = closeIndex + 3;
|
||||
continue;
|
||||
}
|
||||
segments.push({
|
||||
type: "visualization",
|
||||
language: isMindmapFence ? "mindmap-json" : "map-json",
|
||||
source,
|
||||
complete,
|
||||
});
|
||||
cursor = complete ? closeIndex + 3 : content.length;
|
||||
FENCE_PATTERN.lastIndex = cursor;
|
||||
if (!complete) break;
|
||||
}
|
||||
|
||||
if (cursor < content.length) {
|
||||
segments.push({ type: "markdown", content: content.slice(cursor) });
|
||||
}
|
||||
return segments.length > 0 ? segments : [{ type: "markdown", content }];
|
||||
}
|
||||
@@ -24,7 +24,10 @@ import {
|
||||
applyCompactMenuOverrides,
|
||||
type CompactMenuItem,
|
||||
} from "@/core/page-layout/compact-menu-overrides";
|
||||
import { type MenuItem } from "@/core/page-layout/sidebar-menu";
|
||||
import {
|
||||
MARKDOWN_DOCX_MENU_ITEM,
|
||||
type MenuItem,
|
||||
} from "@/core/page-layout/sidebar-menu";
|
||||
import { useCompactMenuOverrides } from "@/strategy-components/api/compact-menu-overrides";
|
||||
|
||||
// 普通导航项:默认中性色,激活态与「新对话」一致——主色浅底 + 主色文字/图标
|
||||
@@ -95,13 +98,6 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
|
||||
path: "/page/workspace/skills",
|
||||
match: (pathname) => pathname.startsWith("/page/workspace/skills"),
|
||||
},
|
||||
{
|
||||
id: "workspace-memory",
|
||||
label: "记忆",
|
||||
icon: BrainIcon,
|
||||
path: "/page/workspace/memory",
|
||||
match: (pathname) => pathname.startsWith("/page/workspace/memory"),
|
||||
},
|
||||
{
|
||||
id: "workspace-ai-writing",
|
||||
label: "AI写作",
|
||||
@@ -149,7 +145,13 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
|
||||
label: "知识库管理",
|
||||
icon: BookOpen,
|
||||
path: "/page/workspace/knowledge",
|
||||
match: (pathname) => pathname.startsWith("/page/workspace/knowledge"),
|
||||
match: (pathname) =>
|
||||
pathname.startsWith("/page/workspace/knowledge") &&
|
||||
!pathname.startsWith(MARKDOWN_DOCX_MENU_ITEM.path),
|
||||
},
|
||||
{
|
||||
...MARKDOWN_DOCX_MENU_ITEM,
|
||||
match: (pathname) => pathname.startsWith(MARKDOWN_DOCX_MENU_ITEM.path),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -160,7 +162,8 @@ export function useWorkspaceCompactNavItems(): {
|
||||
settingsItems: EffectiveWorkspaceCompactNavItem[];
|
||||
toolboxItems: EffectiveWorkspaceCompactNavItem[];
|
||||
} {
|
||||
const { data: overrides = [] } = useCompactMenuOverrides();
|
||||
const { data: overrideConfig } = useCompactMenuOverrides();
|
||||
const overrides = overrideConfig?.overrides ?? [];
|
||||
return useMemo(() => {
|
||||
const applied = applyCompactMenuOverrides(WORKSPACE_COMPACT_NAV_ITEMS, overrides);
|
||||
return {
|
||||
|
||||
@@ -223,7 +223,7 @@ export function WorkspaceNavMenu() {
|
||||
{isAdmin ? (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
navigate("/page/strategy/admin/user-leaderboard")
|
||||
navigate("/page/workspace/admin/user-leaderboard")
|
||||
}
|
||||
>
|
||||
<TrophyIcon />
|
||||
@@ -273,16 +273,15 @@ export function WorkspaceNavMenu() {
|
||||
模型配置
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{/* 与全量模式左下角「设置和更多」下拉里的菜单管理同入口(管理员):
|
||||
简洁模式没有全局侧边栏,这里放一份保证两种模式都能进菜单管理。 */}
|
||||
{isAdmin && (
|
||||
{/* 与全量模式左下角「设置和更多」下拉里的菜单管理同入口:
|
||||
简洁模式没有全局侧边栏,走工作区地址带上 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
() =>
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -5,4 +5,5 @@
|
||||
export * from "./api";
|
||||
export * from "./file-validation";
|
||||
export * from "./hooks";
|
||||
export * from "./policy";
|
||||
export * from "./prompt-input-files";
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
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 {"&":"&","<":"<",">":">",'\"':""","'":"'"}[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" : "";
|
||||
}
|
||||
@@ -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";
|
||||
/** 不收集素材,直接用通用知识写作:勾选后跳过整个素材检索流程(含素材确认暂停点),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user