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,
|
CollapsibleTrigger,
|
||||||
} from "@/components/ui/collapsible";
|
} from "@/components/ui/collapsible";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Sheet,
|
||||||
DialogContent,
|
SheetContent,
|
||||||
DialogDescription,
|
SheetDescription,
|
||||||
DialogHeader,
|
SheetHeader,
|
||||||
DialogTitle,
|
SheetTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/sheet";
|
||||||
import {
|
import {
|
||||||
buildWriteFileVirtualUrl,
|
buildWriteFileVirtualUrl,
|
||||||
hasCompleteArtifactExtension,
|
hasCompleteArtifactExtension,
|
||||||
@@ -82,7 +82,7 @@ import { Tooltip } from "../tooltip";
|
|||||||
import { SourceCard } from "../skill-display-sources";
|
import { SourceCard } from "../skill-display-sources";
|
||||||
|
|
||||||
import { MarkdownContent } from "./markdown-content";
|
import { MarkdownContent } from "./markdown-content";
|
||||||
import { SingleRoundRagCitationDialog } from "./message-list-item";
|
import { SingleRoundRagCitationDrawer } from "./message-list-item";
|
||||||
|
|
||||||
export function MessageGroup({
|
export function MessageGroup({
|
||||||
className,
|
className,
|
||||||
@@ -271,7 +271,7 @@ export function MessageGroup({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</ChainOfThought>
|
</ChainOfThought>
|
||||||
<SingleRoundRagCitationDialog
|
<SingleRoundRagCitationDrawer
|
||||||
source={selectedCitation}
|
source={selectedCitation}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) setSelectedCitation(null);
|
if (!open) setSelectedCitation(null);
|
||||||
@@ -1010,23 +1010,35 @@ function SearchResultCard({ item }: { item: SearchResultItem }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Sheet open={open} onOpenChange={setOpen}>
|
||||||
<DialogContent className="max-w-2xl">
|
<SheetContent
|
||||||
<DialogHeader>
|
side="right"
|
||||||
<DialogTitle className="pr-6 text-base leading-relaxed">
|
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 ?? "检索结果"}
|
{item.title ?? "检索结果"}
|
||||||
</DialogTitle>
|
</SheetTitle>
|
||||||
{(sourceLabel || date) && (
|
{(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 && <span>{sourceLabel}</span>}
|
||||||
{sourceLabel && date && <span>·</span>}
|
{sourceLabel && date && <span>·</span>}
|
||||||
{date && <span>{date}</span>}
|
{date && <span>{date}</span>}
|
||||||
</DialogDescription>
|
</SheetDescription>
|
||||||
)}
|
)}
|
||||||
</DialogHeader>
|
{!sourceLabel && !date && (
|
||||||
{content && (
|
<SheetDescription className="sr-only">检索结果详情</SheetDescription>
|
||||||
<div className="text-foreground max-h-[60vh] overflow-y-auto whitespace-pre-wrap text-sm leading-relaxed">
|
)}
|
||||||
|
</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}
|
{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{item.url && (
|
{item.url && (
|
||||||
@@ -1034,8 +1046,10 @@ function SearchResultCard({ item }: { item: SearchResultItem }) {
|
|||||||
{item.url}
|
{item.url}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</DialogContent>
|
</div>
|
||||||
</Dialog>
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import type { Message } from '@langchain/langgraph-sdk';
|
import type { Message } from '@langchain/langgraph-sdk';
|
||||||
import {
|
import {
|
||||||
|
ArrowLeftIcon,
|
||||||
BookMarkedIcon,
|
BookMarkedIcon,
|
||||||
ChevronDownIcon,
|
ChevronRightIcon,
|
||||||
ClockIcon,
|
ClockIcon,
|
||||||
ExternalLinkIcon,
|
ExternalLinkIcon,
|
||||||
FileIcon,
|
FileIcon,
|
||||||
@@ -37,17 +38,12 @@ import { Task, TaskTrigger } from '@/components/ai-elements/task';
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Sheet,
|
||||||
DialogContent,
|
SheetContent,
|
||||||
DialogDescription,
|
SheetDescription,
|
||||||
DialogHeader,
|
SheetHeader,
|
||||||
DialogTitle,
|
SheetTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/sheet';
|
||||||
import {
|
|
||||||
Collapsible,
|
|
||||||
CollapsibleContent,
|
|
||||||
CollapsibleTrigger,
|
|
||||||
} from '@/components/ui/collapsible';
|
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import {
|
import {
|
||||||
UploadedFilePreviewDialog,
|
UploadedFilePreviewDialog,
|
||||||
@@ -58,6 +54,7 @@ import { useChatAskTimes } from '@/core/chat-timestamps';
|
|||||||
import { getBackendBaseURL } from '@/core/config';
|
import { getBackendBaseURL } from '@/core/config';
|
||||||
import { readRuntimeEnv } from '@/env';
|
import { readRuntimeEnv } from '@/env';
|
||||||
import { useI18n } from '@/core/i18n/hooks';
|
import { useI18n } from '@/core/i18n/hooks';
|
||||||
|
import { stripKnowledgeRetrievalDiagnostics } from '@/core/llmwiki/answer-display';
|
||||||
import {
|
import {
|
||||||
extractContentFromMessage,
|
extractContentFromMessage,
|
||||||
extractReasoningContentFromMessage,
|
extractReasoningContentFromMessage,
|
||||||
@@ -77,12 +74,15 @@ import {
|
|||||||
isMultiRoundRagMessage,
|
isMultiRoundRagMessage,
|
||||||
} from '@/core/threads/use-multi-round-rag';
|
} from '@/core/threads/use-multi-round-rag';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { AnswerFeedbackContext } from '@/strategy-components/api/answer-feedback';
|
||||||
|
|
||||||
import { CitationLink } from '../citations/citation-link';
|
import { CitationLink } from '../citations/citation-link';
|
||||||
import { CopyButton } from '../copy-button';
|
import { CopyButton } from '../copy-button';
|
||||||
import { Tooltip } from '../tooltip';
|
import { Tooltip } from '../tooltip';
|
||||||
import { SkillDisplayBlock } from '../skill-display-block';
|
import { SkillDisplayBlock } from '../skill-display-block';
|
||||||
import { ThreadContext } from './context';
|
import { ThreadContext } from './context';
|
||||||
|
import { AnswerFeedbackControl } from './answer-feedback-control';
|
||||||
|
import { isEligibleAnswerFeedbackMessage } from './answer-feedback-eligibility';
|
||||||
import { DeleteMessageButton } from './delete-message-button';
|
import { DeleteMessageButton } from './delete-message-button';
|
||||||
import { DownloadWordButton } from './download-word-button';
|
import { DownloadWordButton } from './download-word-button';
|
||||||
import { MarkdownContent, type MarkdownContentProps } from './markdown-content';
|
import { MarkdownContent, type MarkdownContentProps } from './markdown-content';
|
||||||
@@ -168,6 +168,8 @@ export function MessageListItem({
|
|||||||
onEditSave,
|
onEditSave,
|
||||||
onMessageDeleted,
|
onMessageDeleted,
|
||||||
turnTime,
|
turnTime,
|
||||||
|
answerFeedbackEnabled = false,
|
||||||
|
answerFeedbackContext,
|
||||||
}: {
|
}: {
|
||||||
className?: string;
|
className?: string;
|
||||||
fitRowToContent?: boolean;
|
fitRowToContent?: boolean;
|
||||||
@@ -194,6 +196,10 @@ export function MessageListItem({
|
|||||||
* wall clock at render time and appear to "drift" to now on every reload.
|
* wall clock at render time and appear to "drift" to now on every reload.
|
||||||
*/
|
*/
|
||||||
turnTime?: string;
|
turnTime?: string;
|
||||||
|
/** Opt-in feedback affordance for completed, persisted assistant answers. */
|
||||||
|
answerFeedbackEnabled?: boolean;
|
||||||
|
/** Authoritative run/agent mapping hydrated in one conversation batch. */
|
||||||
|
answerFeedbackContext?: AnswerFeedbackContext;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const isHuman = message.type === 'human';
|
const isHuman = message.type === 'human';
|
||||||
@@ -218,6 +224,9 @@ export function MessageListItem({
|
|||||||
!!message.id &&
|
!!message.id &&
|
||||||
!message.id.startsWith('opt-') &&
|
!message.id.startsWith('opt-') &&
|
||||||
!!messageMarkdown;
|
!!messageMarkdown;
|
||||||
|
const canEvaluateAnswer =
|
||||||
|
answerFeedbackEnabled &&
|
||||||
|
isEligibleAnswerFeedbackMessage(message, Boolean(isLoading));
|
||||||
const confirmAssistantEdit = useCallback(async () => {
|
const confirmAssistantEdit = useCallback(async () => {
|
||||||
const value = assistantDraft.trim();
|
const value = assistantDraft.trim();
|
||||||
if (!value || !message.id || !onEditSave || assistantSaving) {
|
if (!value || !message.id || !onEditSave || assistantSaving) {
|
||||||
@@ -327,6 +336,13 @@ export function MessageListItem({
|
|||||||
>
|
>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<CopyButton clipboardData={messageMarkdown} />
|
<CopyButton clipboardData={messageMarkdown} />
|
||||||
|
{canEvaluateAnswer && message.id && answerFeedbackContext && (
|
||||||
|
<AnswerFeedbackControl
|
||||||
|
threadId={threadId}
|
||||||
|
messageId={message.id}
|
||||||
|
context={answerFeedbackContext}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{messageMarkdown && (
|
{messageMarkdown && (
|
||||||
<DownloadWordButton
|
<DownloadWordButton
|
||||||
markdown={messageMarkdown}
|
markdown={messageMarkdown}
|
||||||
@@ -692,13 +708,16 @@ function MessageContent_({
|
|||||||
? stripArtifactTag(stripUploadedFilesTag(rawContent))
|
? stripArtifactTag(stripUploadedFilesTag(rawContent))
|
||||||
: '';
|
: '';
|
||||||
}
|
}
|
||||||
|
const sanitizedContent = stripKnowledgeRetrievalDiagnostics(rawContent ?? '');
|
||||||
if (isMultiRoundRag) {
|
if (isMultiRoundRag) {
|
||||||
return stripIncompleteCitationMarker(rawContent ?? '');
|
return stripIncompleteCitationMarker(sanitizedContent);
|
||||||
}
|
}
|
||||||
const withCitations = applyInlineCitations(rawContent ?? '', displaySources);
|
const withCitations = applyInlineCitations(sanitizedContent, displaySources);
|
||||||
return stripIncompleteCitationMarker(withCitations);
|
return stripIncompleteCitationMarker(withCitations);
|
||||||
}, [rawContent, isHuman, isMultiRoundRag, displaySources]);
|
}, [rawContent, isHuman, isMultiRoundRag, displaySources]);
|
||||||
const markdownRehypePlugins = useMemo(
|
const markdownRehypePlugins = useMemo<
|
||||||
|
NonNullable<MarkdownContentProps['rehypePlugins']>
|
||||||
|
>(
|
||||||
() => [...rehypePlugins, [rehypeKatex, { output: 'html' as const }]],
|
() => [...rehypePlugins, [rehypeKatex, { output: 'html' as const }]],
|
||||||
[rehypePlugins],
|
[rehypePlugins],
|
||||||
);
|
);
|
||||||
@@ -808,10 +827,7 @@ function MessageContent_({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isSingleRoundRag && displaySources.length > 0 && (
|
{isSingleRoundRag && displaySources.length > 0 && (
|
||||||
<FrontendRagSourcesPanel
|
<FrontendRagSourcesPanel sources={displaySources} />
|
||||||
sources={displaySources}
|
|
||||||
onSelect={setSelectedCitation}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
<MessageTokenUsage
|
<MessageTokenUsage
|
||||||
enabled={tokenUsageEnabled}
|
enabled={tokenUsageEnabled}
|
||||||
@@ -823,7 +839,7 @@ function MessageContent_({
|
|||||||
) : null}
|
) : null}
|
||||||
</AIElementMessageContent>
|
</AIElementMessageContent>
|
||||||
{isSingleRoundRag && (
|
{isSingleRoundRag && (
|
||||||
<SingleRoundRagCitationDialog
|
<SingleRoundRagCitationDrawer
|
||||||
source={selectedCitation}
|
source={selectedCitation}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) setSelectedCitation(null);
|
if (!open) setSelectedCitation(null);
|
||||||
@@ -835,109 +851,173 @@ function MessageContent_({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 单轮 RAG 专属的「引用来源」折叠面板。
|
* 单轮 RAG 的参考文献入口。先打开右侧抽屉查看列表,
|
||||||
*
|
* 选中条目后在同一抽屉中阅读详情,避免跳出居中弹框打断问答。
|
||||||
* 多轮 RAG 的检索卡片已经画在思考过程里,正文下面不再重复一份来源列表。
|
|
||||||
*/
|
*/
|
||||||
function FrontendRagSourcesPanel({
|
function FrontendRagSourcesPanel({ sources }: { sources: RuntimeDisplaySource[] }) {
|
||||||
sources,
|
|
||||||
onSelect,
|
|
||||||
}: {
|
|
||||||
sources: RuntimeDisplaySource[];
|
|
||||||
onSelect: (source: RuntimeDisplaySource) => void;
|
|
||||||
}) {
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [selectedSource, setSelectedSource] = useState<RuntimeDisplaySource | null>(null);
|
||||||
if (sources.length === 0) return null;
|
if (sources.length === 0) return null;
|
||||||
|
|
||||||
|
const handleOpenChange = (nextOpen: boolean) => {
|
||||||
|
setOpen(nextOpen);
|
||||||
|
if (!nextOpen) setSelectedSource(null);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Collapsible open={open} onOpenChange={setOpen} className="mt-3 w-full max-w-5xl">
|
<>
|
||||||
<CollapsibleTrigger asChild>
|
|
||||||
<button
|
<button
|
||||||
type="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" />
|
<BookMarkedIcon className="size-3.5" />
|
||||||
<span className="font-medium">引用来源</span>
|
<span className="font-medium">参考文献</span>
|
||||||
<span className="text-muted-foreground/70">({sources.length})</span>
|
<span className="text-muted-foreground/70">({sources.length})</span>
|
||||||
<ChevronDownIcon
|
<span>查看</span>
|
||||||
className={cn(
|
<ChevronRightIcon className="size-3.5" />
|
||||||
'size-3.5 transition-transform duration-200',
|
|
||||||
!open && '-rotate-90',
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</button>
|
</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) => (
|
{sources.map((source, idx) => (
|
||||||
<button
|
<button
|
||||||
key={source.id ?? `${source.title}-${idx}`}
|
key={source.id ?? `${source.title}-${idx}`}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onSelect(source)}
|
onClick={() => setSelectedSource(source)}
|
||||||
className="bg-card hover:bg-accent block w-full cursor-pointer rounded-lg border p-3 text-left transition-colors"
|
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">
|
<div className="flex items-start gap-2.5">
|
||||||
<span className="bg-primary/10 text-primary flex size-5 shrink-0 items-center justify-center rounded-full text-xs font-semibold">
|
<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}
|
{source.index}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<div className="min-w-0 flex-1">
|
||||||
className="text-foreground line-clamp-1 min-w-0 flex-1 text-sm font-medium"
|
<div className="flex items-center gap-2">
|
||||||
title={source.title}
|
<span className="text-foreground line-clamp-2 min-w-0 flex-1 text-sm font-medium">
|
||||||
>
|
|
||||||
{source.title}
|
{source.title}
|
||||||
</span>
|
</span>
|
||||||
{source.type && (
|
{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}
|
{source.type}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{source.snippet && (
|
{(source.source || source.time) && (
|
||||||
<p className="text-muted-foreground mt-1.5 line-clamp-2 text-xs leading-relaxed">
|
<p className="text-muted-foreground mt-1 text-[11px]">
|
||||||
{source.snippet}
|
{[source.source, source.time].filter(Boolean).join(" · ")}
|
||||||
</p>
|
</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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</CollapsibleContent>
|
</div>
|
||||||
</Collapsible>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SingleRoundRagCitationDialog({
|
export function SingleRoundRagCitationDrawer({
|
||||||
source,
|
source,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: {
|
}: {
|
||||||
source: RuntimeDisplaySource | null;
|
source: RuntimeDisplaySource | null;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const sourceUrl = resolveSingleRoundRagSourceUrl(source);
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={!!source} onOpenChange={onOpenChange}>
|
<Sheet open={!!source} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="max-h-[min(42rem,calc(100vh-2rem))] max-w-2xl overflow-hidden p-5 sm:max-w-2xl">
|
<SheetContent
|
||||||
<DialogHeader className="pr-8">
|
side="right"
|
||||||
<DialogTitle className="leading-relaxed">
|
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 || "引用资料"}
|
{source?.title || "引用资料"}
|
||||||
</DialogTitle>
|
</SheetTitle>
|
||||||
<DialogDescription className="space-y-1 text-xs">
|
<SheetDescription className="space-y-1 text-xs">
|
||||||
<span className="block">来源:{source?.source || source?.type || "未知来源"}</span>
|
<span className="block">来源:{source?.source || source?.type || "未知来源"}</span>
|
||||||
{source?.time && <span className="block">时间:{source.time}</span>}
|
{source?.time && <span className="block">时间:{source.time}</span>}
|
||||||
</DialogDescription>
|
</SheetDescription>
|
||||||
</DialogHeader>
|
</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 && (
|
{sourceUrl && (
|
||||||
<a
|
<a
|
||||||
href={sourceUrl}
|
href={sourceUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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" />
|
<ExternalLinkIcon className="size-4" />
|
||||||
查看原文链接
|
查看原文链接
|
||||||
</a>
|
</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">
|
<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 || "暂无引用内容"}
|
{source.content || source.snippet || "暂无引用内容"}
|
||||||
|
</article>
|
||||||
|
{sourceUrl && (
|
||||||
|
<p className="text-muted-foreground border-t pt-3 text-xs break-all">
|
||||||
|
{sourceUrl}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1163,6 +1243,8 @@ function RichFileCard({
|
|||||||
<UploadedFilePreviewDialog
|
<UploadedFilePreviewDialog
|
||||||
filename={file.filename}
|
filename={file.filename}
|
||||||
fileUrl={fileUrl}
|
fileUrl={fileUrl}
|
||||||
|
threadId={threadId}
|
||||||
|
virtualPath={file.path}
|
||||||
open={previewOpen}
|
open={previewOpen}
|
||||||
onOpenChange={setPreviewOpen}
|
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 { isMultiRoundRagMessage } from "@/core/threads/use-multi-round-rag";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
|
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
|
||||||
|
import { useAnswerFeedbackContexts } from "@/strategy-components/api/answer-feedback";
|
||||||
|
|
||||||
import { ArtifactFileList } from "../artifacts/artifact-file-list";
|
import { ArtifactFileList } from "../artifacts/artifact-file-list";
|
||||||
import { getQuestionAnchorId } from "../question-navigator";
|
import { getQuestionAnchorId } from "../question-navigator";
|
||||||
import { StreamingIndicator } from "../streaming-indicator";
|
import { StreamingIndicator } from "../streaming-indicator";
|
||||||
|
|
||||||
import { ReferenceBatchesContext, ThreadContext } from "./context";
|
import { ReferenceBatchesContext, ThreadContext } from "./context";
|
||||||
|
import { isEligibleAnswerFeedbackMessage } from "./answer-feedback-eligibility";
|
||||||
import { MarkdownContent } from "./markdown-content";
|
import { MarkdownContent } from "./markdown-content";
|
||||||
import { DocumentRewriteTimeline } from "./document-rewrite-timeline";
|
import { DocumentRewriteTimeline } from "./document-rewrite-timeline";
|
||||||
import { MessageGroup } from "./message-group";
|
import { MessageGroup } from "./message-group";
|
||||||
@@ -112,6 +114,50 @@ function ScrollToBottomOnUserMessage({
|
|||||||
return null;
|
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({
|
export function MessageList({
|
||||||
className,
|
className,
|
||||||
contentClassName,
|
contentClassName,
|
||||||
@@ -122,6 +168,7 @@ export function MessageList({
|
|||||||
tokenUsageEnabled = false,
|
tokenUsageEnabled = false,
|
||||||
extraRehypePlugins,
|
extraRehypePlugins,
|
||||||
scrollButtonClassName,
|
scrollButtonClassName,
|
||||||
|
onAtBottomChange,
|
||||||
onClarificationSubmit,
|
onClarificationSubmit,
|
||||||
onEditResubmit,
|
onEditResubmit,
|
||||||
onEditSave,
|
onEditSave,
|
||||||
@@ -141,6 +188,7 @@ export function MessageList({
|
|||||||
onOpenArtifact,
|
onOpenArtifact,
|
||||||
onDownloadArtifact,
|
onDownloadArtifact,
|
||||||
afterDocumentRewriteItems,
|
afterDocumentRewriteItems,
|
||||||
|
answerFeedbackEnabled = false,
|
||||||
}: {
|
}: {
|
||||||
className?: string;
|
className?: string;
|
||||||
contentClassName?: string;
|
contentClassName?: string;
|
||||||
@@ -151,6 +199,8 @@ export function MessageList({
|
|||||||
tokenUsageEnabled?: boolean;
|
tokenUsageEnabled?: boolean;
|
||||||
/** 额外定位「回到最新」悬浮按钮——不同页面输入框高度不同,需上移让位。 */
|
/** 额外定位「回到最新」悬浮按钮——不同页面输入框高度不同,需上移让位。 */
|
||||||
scrollButtonClassName?: string;
|
scrollButtonClassName?: string;
|
||||||
|
/** Reports whether the message viewport is currently at its bottom edge. */
|
||||||
|
onAtBottomChange?: (isAtBottom: boolean) => void;
|
||||||
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
||||||
onClarificationSubmit?: (answer: string) => void | Promise<void>;
|
onClarificationSubmit?: (answer: string) => void | Promise<void>;
|
||||||
onEditResubmit?: (text: string) => void;
|
onEditResubmit?: (text: string) => void;
|
||||||
@@ -196,6 +246,12 @@ export function MessageList({
|
|||||||
onDownloadArtifact?: (filepath: string) => void | Promise<void>;
|
onDownloadArtifact?: (filepath: string) => void | Promise<void>;
|
||||||
/** Optional host content placed immediately after document-rewrite timelines. */
|
/** Optional host content placed immediately after document-rewrite timelines. */
|
||||||
afterDocumentRewriteItems?: React.ReactNode;
|
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 baseRehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
|
||||||
const rehypePlugins = useMemo(
|
const rehypePlugins = useMemo(
|
||||||
@@ -256,6 +312,39 @@ export function MessageList({
|
|||||||
},
|
},
|
||||||
[allMessages, externallyHiddenMessageIds, hiddenMessageIds],
|
[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 documentRewriteItems = documentRewritePresentation?.forThread(threadId) ?? [];
|
||||||
|
|
||||||
const askTimes = useChatAskTimes();
|
const askTimes = useChatAskTimes();
|
||||||
@@ -321,7 +410,8 @@ export function MessageList({
|
|||||||
const referenceModeEntryEnabled =
|
const referenceModeEntryEnabled =
|
||||||
citationDisplay?.reference_mode_enabled ?? false;
|
citationDisplay?.reference_mode_enabled ?? false;
|
||||||
const knowledgeReferencesEnabled =
|
const knowledgeReferencesEnabled =
|
||||||
threadContext?.knowledgeReferencesEnabled === true;
|
threadContext?.knowledgeReferencesEnabled === true ||
|
||||||
|
!!threadContext?.knowledgeReferences;
|
||||||
const ragModeEnabled =
|
const ragModeEnabled =
|
||||||
(referenceModeEntryEnabled && settings.display.ragModeEnabled) ||
|
(referenceModeEntryEnabled && settings.display.ragModeEnabled) ||
|
||||||
isThreadRagModeLocked(threadId) ||
|
isThreadRagModeLocked(threadId) ||
|
||||||
@@ -524,6 +614,7 @@ export function MessageList({
|
|||||||
className={cn("flex size-full flex-col justify-center", className)}
|
className={cn("flex size-full flex-col justify-center", className)}
|
||||||
>
|
>
|
||||||
<ScrollToBottomOnUserMessage threadId={threadId} messages={messages} />
|
<ScrollToBottomOnUserMessage threadId={threadId} messages={messages} />
|
||||||
|
<ConversationBottomObserver onAtBottomChange={onAtBottomChange} />
|
||||||
<ConversationContent
|
<ConversationContent
|
||||||
className={cn(
|
className={cn(
|
||||||
"mx-auto w-full max-w-(--container-width-lg)",
|
"mx-auto w-full max-w-(--container-width-lg)",
|
||||||
@@ -585,6 +676,12 @@ export function MessageList({
|
|||||||
allowDelete ? handleMessageDeleted : undefined
|
allowDelete ? handleMessageDeleted : undefined
|
||||||
}
|
}
|
||||||
turnTime={msg.id ? turnTimes.get(msg.id) : undefined}
|
turnTime={msg.id ? turnTimes.get(msg.id) : undefined}
|
||||||
|
answerFeedbackEnabled={answerFeedbackEnabled}
|
||||||
|
answerFeedbackContext={
|
||||||
|
msg.id && ratableAnswerMessageIds.has(msg.id)
|
||||||
|
? answerFeedbackContexts?.[msg.id]
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -957,6 +1054,7 @@ export type SetupWritingApprovalPayload = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type SetupReportStructureApprovalPayload = {
|
export type SetupReportStructureApprovalPayload = {
|
||||||
|
kind?: "report_structure" | "gongwen_template";
|
||||||
title?: string;
|
title?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
content?: string;
|
content?: string;
|
||||||
@@ -1376,6 +1474,7 @@ function SetupReportStructureApprovalCard({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const approvalPayload = payload;
|
const approvalPayload = payload;
|
||||||
|
const isGongwenTemplate = approvalPayload.kind === "gongwen_template";
|
||||||
const busy = disabled || submitting !== null;
|
const busy = disabled || submitting !== null;
|
||||||
const directions = approvalPayload.retrieval_directions ?? [];
|
const directions = approvalPayload.retrieval_directions ?? [];
|
||||||
const skills = approvalPayload.retrieval_skills ?? [];
|
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="border-border bg-card text-card-foreground w-full rounded-lg border p-4 shadow-sm">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-muted-foreground text-xs font-medium">报告结构建议</div>
|
<div className="text-muted-foreground text-xs font-medium">{isGongwenTemplate ? "公文模板结构建议" : "报告结构建议"}</div>
|
||||||
<div className="text-sm font-medium">确认后将应用到右侧表单,再点保存入库。</div>
|
<div className="text-sm font-medium">{isGongwenTemplate ? "确认后将应用到右侧模板表单,再保存到模板中心。" : "确认后将应用到右侧表单,再点保存入库。"}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{approvalPayload.title ? (
|
{approvalPayload.title ? (
|
||||||
@@ -1443,7 +1542,7 @@ function SetupReportStructureApprovalCard({
|
|||||||
|
|
||||||
{approvalPayload.content ? (
|
{approvalPayload.content ? (
|
||||||
<div className="space-y-1">
|
<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">
|
<pre className="bg-muted max-h-52 overflow-auto rounded-md p-3 text-xs whitespace-pre-wrap">
|
||||||
{approvalPayload.content}
|
{approvalPayload.content}
|
||||||
</pre>
|
</pre>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { DownloadIcon, Loader2 } from "lucide-react";
|
import { DownloadIcon, InfoIcon, Loader2 } from "lucide-react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -13,8 +13,10 @@ import {
|
|||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
|
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
|
||||||
import { apiFetch } from "@/core/api/fetch-client";
|
import { apiFetch } from "@/core/api/fetch-client";
|
||||||
|
import { getBackendBaseURL } from "@/core/config";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import { reasoningPlugins } from "@/core/streamdown";
|
import { reasoningPlugins } from "@/core/streamdown";
|
||||||
|
import { DEFAULT_UPLOAD_POLICY, useUploadPolicy } from "@/core/uploads";
|
||||||
|
|
||||||
const markdownClassName =
|
const markdownClassName =
|
||||||
"!h-auto !w-full max-w-none min-w-0 text-sm leading-7 " +
|
"!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";
|
return "binary";
|
||||||
}
|
}
|
||||||
|
|
||||||
function withReplacedExtension(url: string): string {
|
function withAppendedMarkdownExtension(url: string): string {
|
||||||
// Replace the path extension with .md, keeping any query string intact.
|
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 [base, query] = url.split("?");
|
||||||
const i = base.lastIndexOf(".");
|
const i = base.lastIndexOf(".");
|
||||||
const swapped = (i >= 0 ? base.slice(0, i) : base) + ".md";
|
const swapped = (i >= 0 ? base.slice(0, i) : base) + ".md";
|
||||||
return query ? `${swapped}?${query}` : swapped;
|
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
|
* Fetch an auth-gated artifact (the artifact route requires an
|
||||||
* `Authorization: Bearer` header, which a plain <img>/<iframe> cannot send —
|
* `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({
|
export function UploadedFilePreviewDialog({
|
||||||
filename,
|
filename,
|
||||||
fileUrl,
|
fileUrl,
|
||||||
|
threadId,
|
||||||
|
virtualPath,
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: {
|
}: {
|
||||||
filename: string;
|
filename: string;
|
||||||
/** Absolute artifact URL for the original uploaded file. */
|
/** Absolute artifact URL for the original uploaded file. */
|
||||||
fileUrl: string;
|
fileUrl: string;
|
||||||
|
/** Used by the bounded preview endpoint; optional for old/public messages. */
|
||||||
|
threadId?: string;
|
||||||
|
virtualPath?: string;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
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 kind = useMemo(() => kindOf(filename), [filename]);
|
||||||
const ext = useMemo(() => extOf(filename), [filename]);
|
const ext = useMemo(() => extOf(filename), [filename]);
|
||||||
|
|
||||||
@@ -141,11 +245,34 @@ export function UploadedFilePreviewDialog({
|
|||||||
useAuthedObjectUrl(fileUrl, blobEnabled);
|
useAuthedObjectUrl(fileUrl, blobEnabled);
|
||||||
|
|
||||||
// text / office → fetch text (office reads the converted sibling .md).
|
// text / office → fetch text (office reads the converted sibling .md).
|
||||||
const textUrl = useMemo(
|
const textUrls = useMemo(
|
||||||
() => (kind === "office" ? withReplacedExtension(fileUrl) : fileUrl),
|
() =>
|
||||||
|
kind === "office"
|
||||||
|
? Array.from(
|
||||||
|
new Set([
|
||||||
|
withAppendedMarkdownExtension(fileUrl),
|
||||||
|
withLegacyReplacedExtension(fileUrl),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
: [fileUrl],
|
||||||
[kind, 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 [text, setText] = useState<string | null>(null);
|
||||||
|
const [previewMeta, setPreviewMeta] = useState<PreviewMeta | null>(null);
|
||||||
const [textLoading, setTextLoading] = useState(false);
|
const [textLoading, setTextLoading] = useState(false);
|
||||||
const [textError, setTextError] = useState<string | null>(null);
|
const [textError, setTextError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -155,14 +282,71 @@ export function UploadedFilePreviewDialog({
|
|||||||
setTextLoading(true);
|
setTextLoading(true);
|
||||||
setTextError(null);
|
setTextError(null);
|
||||||
setText(null);
|
setText(null);
|
||||||
apiFetch(textUrl)
|
setPreviewMeta(null);
|
||||||
.then(async (res) => {
|
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
const load = async () => {
|
||||||
return res.text();
|
if (threadId && previewPaths.length > 0) {
|
||||||
})
|
for (const previewPath of previewPaths) {
|
||||||
.then((body) => {
|
try {
|
||||||
if (!cancelled) setText(body);
|
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) => {
|
.catch((err: unknown) => {
|
||||||
if (!cancelled)
|
if (!cancelled)
|
||||||
setTextError(err instanceof Error ? err.message : String(err));
|
setTextError(err instanceof Error ? err.message : String(err));
|
||||||
@@ -173,7 +357,7 @@ export function UploadedFilePreviewDialog({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [open, kind, textUrl]);
|
}, [open, kind, maxPreviewChars, previewPaths, textUrls, threadId]);
|
||||||
|
|
||||||
const renderAsMarkdown =
|
const renderAsMarkdown =
|
||||||
kind === "office" || ext === "md" || ext === "markdown";
|
kind === "office" || ext === "md" || ext === "markdown";
|
||||||
@@ -242,7 +426,28 @@ export function UploadedFilePreviewDialog({
|
|||||||
loadingView
|
loadingView
|
||||||
) : textError ? (
|
) : textError ? (
|
||||||
<BinaryFallback fileUrl={fileUrl} label={t.common.download} />
|
<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
|
<MarkdownContent
|
||||||
className={markdownClassName}
|
className={markdownClassName}
|
||||||
content={text ?? ""}
|
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">
|
<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 ?? ""}
|
{text ?? ""}
|
||||||
</pre>
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<BinaryFallback fileUrl={fileUrl} label={t.common.download} />
|
<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 {
|
import {
|
||||||
MY_PROJECT_CONVERSATION_LABELS,
|
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",
|
"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({
|
export function MyProjectThreadIcon({
|
||||||
type,
|
type,
|
||||||
className,
|
className,
|
||||||
@@ -21,31 +40,47 @@ export function MyProjectThreadIcon({
|
|||||||
type: MyProjectConversationType;
|
type: MyProjectConversationType;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
|
const Icon = ICONS[type];
|
||||||
|
const label = `我的项目 · ${MY_PROJECT_CONVERSATION_LABELS[type]}`;
|
||||||
return (
|
return (
|
||||||
<FolderKanban
|
<span
|
||||||
aria-label={`我的项目 · ${MY_PROJECT_CONVERSATION_LABELS[type]}`}
|
aria-label={label}
|
||||||
className={cn("size-3.5 shrink-0 text-violet-500", className)}
|
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({
|
export function MyProjectThreadBadge({
|
||||||
type,
|
type,
|
||||||
|
projectName,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
type: MyProjectConversationType;
|
type: MyProjectConversationType;
|
||||||
|
projectName?: string | null;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
|
const typeLabel = `${MY_PROJECT_CONVERSATION_LABELS[type]}项目`;
|
||||||
|
const ownerLabel = projectName?.trim() || "我的项目";
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
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],
|
BADGE_STYLES[type],
|
||||||
className,
|
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>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -48,6 +48,7 @@ import {
|
|||||||
SidebarMenuAction,
|
SidebarMenuAction,
|
||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
|
useSidebar,
|
||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
|
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
|
||||||
import {
|
import {
|
||||||
@@ -68,7 +69,8 @@ import {
|
|||||||
useToggleThreadPin,
|
useToggleThreadPin,
|
||||||
} from "@/core/threads/hooks";
|
} from "@/core/threads/hooks";
|
||||||
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
|
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 {
|
import {
|
||||||
isBootstrapThread,
|
isBootstrapThread,
|
||||||
isHiddenFromRecentChats,
|
isHiddenFromRecentChats,
|
||||||
@@ -83,6 +85,11 @@ import { env } from "@/env";
|
|||||||
import { desensitize } from "@/lib/desensitize";
|
import { desensitize } from "@/lib/desensitize";
|
||||||
import { isIMEComposing } from "@/lib/ime";
|
import { isIMEComposing } from "@/lib/ime";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { strategyProjectWorkbenchPath } from "@/strategy-selection/project-routes";
|
||||||
|
import {
|
||||||
|
mergeRecentWorkspaceItems,
|
||||||
|
useRecentStrategyProjects,
|
||||||
|
} from "@/strategy-selection/recent-projects";
|
||||||
|
|
||||||
export function RecentChatList({
|
export function RecentChatList({
|
||||||
compact = false,
|
compact = false,
|
||||||
@@ -98,6 +105,7 @@ export function RecentChatList({
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const { isMobile, setOpenMobile } = useSidebar();
|
||||||
const [internalCollapsed, setInternalCollapsed] = useState(
|
const [internalCollapsed, setInternalCollapsed] = useState(
|
||||||
() => localStorage.getItem("sidebar:recent-chats-collapsed") === "true",
|
() => localStorage.getItem("sidebar:recent-chats-collapsed") === "true",
|
||||||
);
|
);
|
||||||
@@ -116,6 +124,7 @@ export function RecentChatList({
|
|||||||
agent_id?: string;
|
agent_id?: string;
|
||||||
}>();
|
}>();
|
||||||
const { data: threads = [] } = useThreads();
|
const { data: threads = [] } = useThreads();
|
||||||
|
const { data: recentStrategyProjects = [] } = useRecentStrategyProjects();
|
||||||
const { mutate: deleteThread } = useDeleteThread();
|
const { mutate: deleteThread } = useDeleteThread();
|
||||||
const { mutate: renameThread } = useRenameThread();
|
const { mutate: renameThread } = useRenameThread();
|
||||||
const { toggle: togglePin } = useToggleThreadPin();
|
const { toggle: togglePin } = useToggleThreadPin();
|
||||||
@@ -139,15 +148,15 @@ export function RecentChatList({
|
|||||||
});
|
});
|
||||||
if (threadIndex > -1) {
|
if (threadIndex > -1) {
|
||||||
if (threads[threadIndex + 1]) {
|
if (threads[threadIndex + 1]) {
|
||||||
nextThreadPath = listPathOfThread(threads[threadIndex + 1]!);
|
nextThreadPath = listPathOfThread(threads[threadIndex + 1]!, pathname);
|
||||||
} else if (threads[threadIndex - 1]) {
|
} else if (threads[threadIndex - 1]) {
|
||||||
nextThreadPath = listPathOfThread(threads[threadIndex - 1]!);
|
nextThreadPath = listPathOfThread(threads[threadIndex - 1]!, pathname);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
void router.push(nextThreadPath);
|
void router.push(nextThreadPath);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[agentIdFromPath, deleteThread, router, threadIdFromPath, threads],
|
[agentIdFromPath, deleteThread, pathname, router, threadIdFromPath, threads],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRenameClick = useCallback(
|
const handleRenameClick = useCallback(
|
||||||
@@ -203,12 +212,16 @@ export function RecentChatList({
|
|||||||
const visibleThreads = sortThreadsPinnedFirst(
|
const visibleThreads = sortThreadsPinnedFirst(
|
||||||
threads.filter((thread) => !isHiddenFromRecentChats(thread)),
|
threads.filter((thread) => !isHiddenFromRecentChats(thread)),
|
||||||
);
|
);
|
||||||
|
const projectThreadNames = useProjectThreadNames(visibleThreads);
|
||||||
|
const recentItems = mergeRecentWorkspaceItems(
|
||||||
|
visibleThreads,
|
||||||
|
recentStrategyProjects,
|
||||||
|
);
|
||||||
|
|
||||||
if (visibleThreads.length === 0) {
|
if (recentItems.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const shownThreads =
|
const shownItems = limit != null ? recentItems.slice(0, limit) : recentItems;
|
||||||
limit != null ? visibleThreads.slice(0, limit) : visibleThreads;
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SidebarGroup
|
<SidebarGroup
|
||||||
@@ -230,7 +243,7 @@ export function RecentChatList({
|
|||||||
</span>
|
</span>
|
||||||
{compact && (
|
{compact && (
|
||||||
<span className="text-muted-foreground bg-muted rounded-full px-1.5 py-0.5 text-[10px] font-normal tracking-normal tabular-nums">
|
<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>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
@@ -247,33 +260,84 @@ export function RecentChatList({
|
|||||||
>
|
>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
<div className="flex w-full flex-col gap-0.5">
|
<div className="flex w-full flex-col gap-0.5">
|
||||||
{shownThreads.map((thread) => {
|
{shownItems.map((item) => {
|
||||||
const href = listPathOfThread(thread);
|
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 isActive = href === pathname;
|
||||||
const isBoot = isBootstrapThread(thread);
|
const isBoot = isBootstrapThread(thread);
|
||||||
const projectType = myProjectConversationTypeOfThread(thread);
|
const projectInfo = myProjectConversationInfoOfThread(
|
||||||
|
thread,
|
||||||
|
projectThreadNames,
|
||||||
|
);
|
||||||
const pinned = isThreadPinned(thread);
|
const pinned = isThreadPinned(thread);
|
||||||
const favorited = isThreadFavorite(thread);
|
const favorited = isThreadFavorite(thread);
|
||||||
return (
|
return (
|
||||||
<SidebarMenuItem
|
<SidebarMenuItem
|
||||||
key={thread.thread_id}
|
key={`thread:${thread.thread_id}`}
|
||||||
className="group/side-menu-item"
|
className="group/side-menu-item"
|
||||||
>
|
>
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
isActive={isActive}
|
isActive={isActive}
|
||||||
asChild
|
asChild
|
||||||
className={
|
className={cn(
|
||||||
"rounded-lg h-7 px-3 text-[13px] text-muted-foreground hover:text-foreground hover:bg-sidebar-accent/60 " +
|
"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)] " +
|
"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" +
|
"dark:data-[active=true]:bg-slate-700/80 dark:data-[active=true]:text-slate-100 dark:data-[active=true]:shadow-none",
|
||||||
(compact
|
compact &&
|
||||||
? " h-9 gap-2 rounded-md px-2.5 data-[active=true]:border data-[active=true]:border-border"
|
"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
|
<Link
|
||||||
className="flex w-full items-center gap-1 whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
|
className="flex w-full items-center gap-1 whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
|
||||||
href={href}
|
href={href}
|
||||||
|
onClick={() => { if (isMobile) setOpenMobile(false); }}
|
||||||
>
|
>
|
||||||
{isBoot ? (
|
{isBoot ? (
|
||||||
<Bot
|
<Bot
|
||||||
@@ -285,8 +349,8 @@ export function RecentChatList({
|
|||||||
}
|
}
|
||||||
aria-label="智能体创建会话"
|
aria-label="智能体创建会话"
|
||||||
/>
|
/>
|
||||||
) : projectType ? (
|
) : projectInfo ? (
|
||||||
<MyProjectThreadIcon type={projectType} />
|
<MyProjectThreadIcon type={projectInfo.type} />
|
||||||
) : (
|
) : (
|
||||||
compact && (
|
compact && (
|
||||||
<MessageSquare
|
<MessageSquare
|
||||||
@@ -302,15 +366,23 @@ export function RecentChatList({
|
|||||||
{pinned && (
|
{pinned && (
|
||||||
<Pin className="size-3 shrink-0 text-muted-foreground/70" />
|
<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))}
|
{desensitize(titleOfThread(thread, t.pages.newChat))}
|
||||||
</span>
|
</span>
|
||||||
{projectType && (
|
{projectInfo && (
|
||||||
<MyProjectThreadBadge
|
<MyProjectThreadBadge
|
||||||
className="mr-3"
|
className="mr-3 max-w-[calc(100%_-_0.75rem)]"
|
||||||
type={projectType}
|
projectName={projectInfo.projectName}
|
||||||
|
type={projectInfo.type}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
|
{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";
|
"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 { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -13,8 +13,9 @@ import { ScrollArea } from "@/components/ui/scroll-area";
|
|||||||
import { AboutSettingsPage } from "@/components/workspace/settings/about-settings-page";
|
import { AboutSettingsPage } from "@/components/workspace/settings/about-settings-page";
|
||||||
import { AccountSettingsPage } from "@/components/workspace/settings/account-settings-page";
|
import { AccountSettingsPage } from "@/components/workspace/settings/account-settings-page";
|
||||||
import { AppearanceSettingsPage } from "@/components/workspace/settings/appearance-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 { ToolSettingsPage } from "@/components/workspace/settings/tool-settings-page";
|
||||||
|
import { UploadSettingsPage } from "@/components/workspace/settings/upload-settings-page";
|
||||||
import { getStoredAuth } from "@/core/auth";
|
import { getStoredAuth } from "@/core/auth";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -24,6 +25,7 @@ type SettingsSection =
|
|||||||
| "account"
|
| "account"
|
||||||
| "appearance"
|
| "appearance"
|
||||||
| "memory"
|
| "memory"
|
||||||
|
| "uploads"
|
||||||
| "tools"
|
| "tools"
|
||||||
| "encoder"
|
| "encoder"
|
||||||
| "about";
|
| "about";
|
||||||
@@ -61,15 +63,15 @@ export function SettingsDialog(props: SettingsDialogProps) {
|
|||||||
// },
|
// },
|
||||||
// { id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
|
// { id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
|
||||||
// { id: "skills", label: t.settings.sections.skills, icon: SparklesIcon },
|
// { id: "skills", label: t.settings.sections.skills, icon: SparklesIcon },
|
||||||
// 记忆已移至侧边栏入口 (/page/workspace/memory)
|
...(isAdmin
|
||||||
// {
|
? [
|
||||||
// id: "memory",
|
{ id: "memory", label: "问答记忆", icon: BrainIcon },
|
||||||
// label: t.settings.sections.memory,
|
{ id: "uploads", label: "上传设置", icon: UploadCloudIcon },
|
||||||
// icon: BrainIcon,
|
]
|
||||||
// },
|
: []),
|
||||||
{ id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
|
{ id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
|
||||||
...(isAdmin
|
...(isAdmin
|
||||||
? [{ id: "encoder", label: "本地编码器", icon: CpuIcon }]
|
? [{ id: "encoder", label: "Wiki 镜像编码器", icon: CpuIcon }]
|
||||||
: []),
|
: []),
|
||||||
{
|
{
|
||||||
id: "account",
|
id: "account",
|
||||||
@@ -132,7 +134,12 @@ export function SettingsDialog(props: SettingsDialogProps) {
|
|||||||
{activeSection === "appearance" && (
|
{activeSection === "appearance" && (
|
||||||
<AppearanceSettingsPage onClose={() => props.onOpenChange?.(false)} />
|
<AppearanceSettingsPage onClose={() => props.onOpenChange?.(false)} />
|
||||||
)}
|
)}
|
||||||
{activeSection === "memory" && <MemorySettingsPage />}
|
{activeSection === "memory" && isAdmin && (
|
||||||
|
<MemoryRuntimeSettingsPage />
|
||||||
|
)}
|
||||||
|
{activeSection === "uploads" && isAdmin && (
|
||||||
|
<UploadSettingsPage />
|
||||||
|
)}
|
||||||
{activeSection === "tools" && <ToolSettingsPage />}
|
{activeSection === "tools" && <ToolSettingsPage />}
|
||||||
{activeSection === "encoder" && isAdmin && <LocalEncoderSettingsCard />}
|
{activeSection === "encoder" && isAdmin && <LocalEncoderSettingsCard />}
|
||||||
{activeSection === "about" && <AboutSettingsPage />}
|
{activeSection === "about" && <AboutSettingsPage />}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import {
|
|||||||
LockIcon,
|
LockIcon,
|
||||||
PinIcon,
|
PinIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
|
Power,
|
||||||
|
PowerOff,
|
||||||
RefreshCwIcon,
|
RefreshCwIcon,
|
||||||
SparklesIcon,
|
SparklesIcon,
|
||||||
TagsIcon,
|
TagsIcon,
|
||||||
@@ -75,6 +77,7 @@ import {
|
|||||||
useDeleteBuiltinSkill,
|
useDeleteBuiltinSkill,
|
||||||
useDeleteCustomSkill,
|
useDeleteCustomSkill,
|
||||||
useDuplicateSkill,
|
useDuplicateSkill,
|
||||||
|
useEnableSkill,
|
||||||
useGenerateMissingSkillNameZh,
|
useGenerateMissingSkillNameZh,
|
||||||
useGenerateSkillNameZh,
|
useGenerateSkillNameZh,
|
||||||
useInstallSkillUpload,
|
useInstallSkillUpload,
|
||||||
@@ -87,11 +90,23 @@ import {
|
|||||||
useSkills,
|
useSkills,
|
||||||
useValidateSkillUpload,
|
useValidateSkillUpload,
|
||||||
} from "@/core/skills/hooks";
|
} from "@/core/skills/hooks";
|
||||||
|
import {
|
||||||
|
canToggleSkillAvailability,
|
||||||
|
isSkillOffline,
|
||||||
|
type SkillAvailabilityControl,
|
||||||
|
} from "@/core/skill-availability";
|
||||||
import {
|
import {
|
||||||
usePublishSquares,
|
usePublishSquares,
|
||||||
useSkillCompressionSettings,
|
useSkillCompressionSettings,
|
||||||
useViewMode,
|
useViewMode,
|
||||||
} from "@/core/system-settings/hooks";
|
} 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 { Skill } from "@/core/skills/type";
|
||||||
import type { SkillKnowledgeBinding } from "@/strategy-components/api/skill-knowledge";
|
import type { SkillKnowledgeBinding } from "@/strategy-components/api/skill-knowledge";
|
||||||
import { formatDateTime } from "@/core/utils/datetime";
|
import { formatDateTime } from "@/core/utils/datetime";
|
||||||
@@ -105,18 +120,25 @@ import { SkillStateSheet } from "./skill-state-sheet";
|
|||||||
export function SkillSettingsPage({
|
export function SkillSettingsPage({
|
||||||
onClose,
|
onClose,
|
||||||
embedded = false,
|
embedded = false,
|
||||||
|
restrictWxTypes = false,
|
||||||
}: {
|
}: {
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
embedded?: boolean;
|
embedded?: boolean;
|
||||||
|
/** wx 侧栏入口:类型 Tab 只保留 我的 / 默认广场 / 南京-db。 */
|
||||||
|
restrictWxTypes?: boolean;
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
if (embedded) return <SkillSettingsList onClose={onClose} />;
|
if (embedded) {
|
||||||
|
return (
|
||||||
|
<SkillSettingsList onClose={onClose} restrictWxTypes={restrictWxTypes} />
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
title={t.settings.skills.title}
|
title={t.settings.skills.title}
|
||||||
description={t.settings.skills.description}
|
description={t.settings.skills.description}
|
||||||
>
|
>
|
||||||
<SkillSettingsList onClose={onClose} />
|
<SkillSettingsList onClose={onClose} restrictWxTypes={restrictWxTypes} />
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -165,7 +187,13 @@ const rescanErrorToastDarkOptions = {
|
|||||||
icon: <AlertCircle className="size-4 shrink-0 text-[#F5222D]" />,
|
icon: <AlertCircle className="size-4 shrink-0 text-[#F5222D]" />,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
function SkillSettingsList({
|
||||||
|
onClose,
|
||||||
|
restrictWxTypes = false,
|
||||||
|
}: {
|
||||||
|
onClose?: () => void;
|
||||||
|
restrictWxTypes?: boolean;
|
||||||
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -187,6 +215,10 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
|||||||
// Each configured publish square (发布广场) becomes its own browse tab; the
|
// Each configured publish square (发布广场) becomes its own browse tab; the
|
||||||
// backend always returns at least one (the built-in default).
|
// backend always returns at least one (the built-in default).
|
||||||
const squareTabs = publishSquares?.squares ?? [];
|
const squareTabs = publishSquares?.squares ?? [];
|
||||||
|
const defaultSquareTabValue = wxRestrictedDefaultSquareTabValue(
|
||||||
|
publishSquares?.default_square_id,
|
||||||
|
);
|
||||||
|
const nanjingTabValue = wxRestrictedNanjingTabValue(squareTabs);
|
||||||
const reconcileMutation = useReconcileCustomSkills();
|
const reconcileMutation = useReconcileCustomSkills();
|
||||||
const fillNamesMutation = useGenerateMissingSkillNameZh();
|
const fillNamesMutation = useGenerateMissingSkillNameZh();
|
||||||
// Optional model override for the admin "一键补充中文名" action (null = default).
|
// Optional model override for the admin "一键补充中文名" action (null = default).
|
||||||
@@ -253,6 +285,9 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
|||||||
const isSquareTab = filter !== "mine";
|
const isSquareTab = filter !== "mine";
|
||||||
const scopedSkills = filter === "mine" ? activeMine : activeAll;
|
const scopedSkills = filter === "mine" ? activeMine : activeAll;
|
||||||
const filteredSkills = useMemo(() => {
|
const filteredSkills = useMemo(() => {
|
||||||
|
if (restrictWxTypes && isWxRestrictedPlaceholderTab(filter)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
let list = publisher
|
let list = publisher
|
||||||
? scopedSkills.filter((s) => s.owner_user_id === publisher.id)
|
? scopedSkills.filter((s) => s.owner_user_id === publisher.id)
|
||||||
: scopedSkills;
|
: scopedSkills;
|
||||||
@@ -266,7 +301,14 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
return list;
|
return list;
|
||||||
}, [scopedSkills, publisher, filter, isSquareTab, publishSquares?.default_square_id]);
|
}, [
|
||||||
|
scopedSkills,
|
||||||
|
publisher,
|
||||||
|
filter,
|
||||||
|
isSquareTab,
|
||||||
|
publishSquares?.default_square_id,
|
||||||
|
restrictWxTypes,
|
||||||
|
]);
|
||||||
const orderedSkills = useMemo(
|
const orderedSkills = useMemo(
|
||||||
() =>
|
() =>
|
||||||
filteredSkills
|
filteredSkills
|
||||||
@@ -296,6 +338,30 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
|||||||
// If the active square tab disappears (admin removed that square), fall back
|
// If the active square tab disappears (admin removed that square), fall back
|
||||||
// to "我的" so the page never points at a dead tab.
|
// to "我的" so the page never points at a dead tab.
|
||||||
useEffect(() => {
|
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 (
|
if (
|
||||||
filter !== "mine" &&
|
filter !== "mine" &&
|
||||||
squareTabs.length > 0 &&
|
squareTabs.length > 0 &&
|
||||||
@@ -303,7 +369,7 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
|||||||
) {
|
) {
|
||||||
setFilter("mine");
|
setFilter("mine");
|
||||||
}
|
}
|
||||||
}, [filter, squareTabs]);
|
}, [filter, squareTabs, restrictWxTypes, defaultSquareTabValue, nanjingTabValue]);
|
||||||
const pagedSkills = useMemo(
|
const pagedSkills = useMemo(
|
||||||
() => orderedSkills.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
|
() => orderedSkills.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
|
||||||
[orderedSkills, page],
|
[orderedSkills, page],
|
||||||
@@ -362,12 +428,22 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
|||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Tabs value={filter} onValueChange={setFilter}>
|
<Tabs value={filter} onValueChange={setFilter}>
|
||||||
<TabsList variant="line" className="flex-wrap">
|
<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>
|
<TabsTrigger value="mine">{t.settings.skills.tabMine}</TabsTrigger>
|
||||||
{squareTabs.map((sq) => (
|
{squareTabs.map((sq) => (
|
||||||
<TabsTrigger key={sq.id} value={sq.id} title={sq.description || sq.name}>
|
<TabsTrigger key={sq.id} value={sq.id} title={sq.description || sq.name}>
|
||||||
{sq.name}
|
{sq.name}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
))}
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
<Button
|
<Button
|
||||||
@@ -549,6 +625,7 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
|||||||
skill={skill}
|
skill={skill}
|
||||||
currentUserId={currentUserId}
|
currentUserId={currentUserId}
|
||||||
isAdmin={isAdmin}
|
isAdmin={isAdmin}
|
||||||
|
availabilityControl="owner"
|
||||||
layout={viewMode === "card" ? "card" : "row"}
|
layout={viewMode === "card" ? "card" : "row"}
|
||||||
onPublisherClick={setPublisher}
|
onPublisherClick={setPublisher}
|
||||||
/>
|
/>
|
||||||
@@ -607,6 +684,7 @@ export function SkillRow({
|
|||||||
skill,
|
skill,
|
||||||
currentUserId,
|
currentUserId,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
|
availabilityControl,
|
||||||
layout = "row",
|
layout = "row",
|
||||||
onPublisherClick,
|
onPublisherClick,
|
||||||
knowledgeBindings = [],
|
knowledgeBindings = [],
|
||||||
@@ -614,6 +692,8 @@ export function SkillRow({
|
|||||||
skill: Skill;
|
skill: Skill;
|
||||||
currentUserId: string | null;
|
currentUserId: string | null;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
|
/** Which permission boundary exposes the global online/offline control. */
|
||||||
|
availabilityControl?: SkillAvailabilityControl;
|
||||||
/** "row" = full-width horizontal item (default); "card" = grid card cell. */
|
/** "row" = full-width horizontal item (default); "card" = grid card cell. */
|
||||||
layout?: "row" | "card";
|
layout?: "row" | "card";
|
||||||
/** Click the 发布者 line to filter the list to that publisher. Receives the
|
/** Click the 发布者 line to filter the list to that publisher. Receives the
|
||||||
@@ -629,6 +709,7 @@ export function SkillRow({
|
|||||||
const setPinned = useSetSkillPinned();
|
const setPinned = useSetSkillPinned();
|
||||||
const setFeatured = useSetSkillFeatured();
|
const setFeatured = useSetSkillFeatured();
|
||||||
const setAlwaysOn = useSetSkillAlwaysOn();
|
const setAlwaysOn = useSetSkillAlwaysOn();
|
||||||
|
const setEnabled = useEnableSkill();
|
||||||
const { data: compression } = useSkillCompressionSettings();
|
const { data: compression } = useSkillCompressionSettings();
|
||||||
const deleteCustom = useDeleteCustomSkill();
|
const deleteCustom = useDeleteCustomSkill();
|
||||||
const deleteBuiltin = useDeleteBuiltinSkill();
|
const deleteBuiltin = useDeleteBuiltinSkill();
|
||||||
@@ -639,6 +720,7 @@ export function SkillRow({
|
|||||||
const [duplicateOpen, setDuplicateOpen] = useState(false);
|
const [duplicateOpen, setDuplicateOpen] = useState(false);
|
||||||
const [duplicateName, setDuplicateName] = useState("");
|
const [duplicateName, setDuplicateName] = useState("");
|
||||||
const [publishPickerOpen, setPublishPickerOpen] = useState(false);
|
const [publishPickerOpen, setPublishPickerOpen] = useState(false);
|
||||||
|
const [confirmOfflineOpen, setConfirmOfflineOpen] = useState(false);
|
||||||
|
|
||||||
const isCustom = skill.category === "custom";
|
const isCustom = skill.category === "custom";
|
||||||
// Built-in flag falls back to "no DB owner" so legacy responses without
|
// 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 canTogglePublish = canManage && !isBuiltin;
|
||||||
// 常驻(免压缩):内置技能仅管理员可设;自定义技能其所有者或管理员可设(与后端一致)。
|
// 常驻(免压缩):内置技能仅管理员可设;自定义技能其所有者或管理员可设(与后端一致)。
|
||||||
const canSetAlwaysOn = isAdmin || isOwned;
|
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) {
|
async function handleToggleAlwaysOn(next: boolean) {
|
||||||
try {
|
try {
|
||||||
@@ -883,6 +986,16 @@ export function SkillRow({
|
|||||||
{knowledgeBindings.length > 3 && (
|
{knowledgeBindings.length > 3 && (
|
||||||
<Badge variant="secondary" className="text-xs">+{knowledgeBindings.length - 3}</Badge>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -941,6 +1054,43 @@ export function SkillRow({
|
|||||||
|
|
||||||
const actionButtons = (
|
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
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -1060,7 +1210,12 @@ export function SkillRow({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{isCard ? (
|
{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
|
<div
|
||||||
className="min-w-0 flex-1 cursor-pointer"
|
className="min-w-0 flex-1 cursor-pointer"
|
||||||
onClick={() => setDetailOpen(true)}
|
onClick={() => setDetailOpen(true)}
|
||||||
@@ -1076,7 +1231,13 @@ export function SkillRow({
|
|||||||
</div>
|
</div>
|
||||||
</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
|
<ItemContent
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
onClick={() => setDetailOpen(true)}
|
onClick={() => setDetailOpen(true)}
|
||||||
@@ -1157,6 +1318,37 @@ export function SkillRow({
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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
|
<TagPickerDialog
|
||||||
open={tagPickerOpen}
|
open={tagPickerOpen}
|
||||||
onOpenChange={setTagPickerOpen}
|
onOpenChange={setTagPickerOpen}
|
||||||
@@ -1372,6 +1564,9 @@ function InstallSkillDialog({
|
|||||||
const canOverwrite = !!validation && !validationError && validation.can_overwrite;
|
const canOverwrite = !!validation && !validationError && validation.can_overwrite;
|
||||||
const ownCustomCanOverwrite = !!isCustomConflict && !!validation?.owned_by_me && canOverwrite;
|
const ownCustomCanOverwrite = !!isCustomConflict && !!validation?.owned_by_me && canOverwrite;
|
||||||
const otherCustomConflict = !!isCustomConflict && !!validation?.owned_by_other;
|
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
|
const customConflictMessage = !validation
|
||||||
? ""
|
? ""
|
||||||
: otherCustomConflict
|
: otherCustomConflict
|
||||||
@@ -1386,6 +1581,8 @@ function InstallSkillDialog({
|
|||||||
? dialog.conflictOwnedByMe
|
? dialog.conflictOwnedByMe
|
||||||
: dialog.conflictOwnedByMeWrongTarget
|
: dialog.conflictOwnedByMeWrongTarget
|
||||||
).replace("{name}", validation.skill_name)
|
).replace("{name}", validation.skill_name)
|
||||||
|
: unclaimedCustomConflict
|
||||||
|
? dialog.conflictUnclaimed.replace("{name}", validation.skill_name)
|
||||||
: dialog.conflictCustom.replace("{name}", validation.skill_name);
|
: dialog.conflictCustom.replace("{name}", validation.skill_name);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1572,7 +1769,7 @@ function InstallSkillDialog({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"mt-1 flex items-start gap-2 text-xs",
|
"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" />
|
<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 { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Sheet,
|
||||||
DialogContent,
|
SheetContent,
|
||||||
DialogDescription,
|
SheetDescription,
|
||||||
DialogHeader,
|
SheetHeader,
|
||||||
DialogTitle,
|
SheetTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/sheet";
|
||||||
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
|
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
|
||||||
import { resolveWeKnoraSourceTarget } from "@/core/llmwiki/source-links";
|
import { resolveWeKnoraSourceTarget } from "@/core/llmwiki/source-links";
|
||||||
import { useLocalSettings } from "@/core/settings";
|
import { useLocalSettings } from "@/core/settings";
|
||||||
@@ -79,12 +79,16 @@ export function SourceCard({
|
|||||||
? source.raw.summary
|
? source.raw.summary
|
||||||
: "";
|
: "";
|
||||||
const snippet = plainTextReferencePreview(summary || content).slice(0, 280);
|
const snippet = plainTextReferencePreview(summary || content).slice(0, 280);
|
||||||
const score =
|
const scoreSemantics = typeof source.raw?.rank_score_semantics === "string"
|
||||||
typeof source.score === "number"
|
? source.raw.rank_score_semantics
|
||||||
? source.score <= 1
|
: "";
|
||||||
? `${Math.round(source.score * 100)}%`
|
const scoreMeta = typeof source.score !== "number"
|
||||||
: String(source.score)
|
? null
|
||||||
: 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 = [
|
const meta = [
|
||||||
source.source,
|
source.source,
|
||||||
isWikiSource ? "检索对象:Wiki 文档" : null,
|
isWikiSource ? "检索对象:Wiki 文档" : null,
|
||||||
@@ -92,7 +96,7 @@ export function SourceCard({
|
|||||||
source.author ? `作者 ${source.author}` : null,
|
source.author ? `作者 ${source.author}` : null,
|
||||||
!isWikiSource && source.type && source.type !== "result" ? source.type : null,
|
!isWikiSource && source.type && source.type !== "result" ? source.type : null,
|
||||||
source.time,
|
source.time,
|
||||||
score ? `相关度 ${score}` : null,
|
scoreMeta,
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
const isCitation = source.mode === "citation";
|
const isCitation = source.mode === "citation";
|
||||||
const showReferenceNumber = isCitation;
|
const showReferenceNumber = isCitation;
|
||||||
@@ -183,9 +187,12 @@ export function SourceCard({
|
|||||||
onOpenChange={setDetailOpen}
|
onOpenChange={setDetailOpen}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!previewTarget && <Dialog open={relatedOpen} onOpenChange={setRelatedOpen}>
|
{!previewTarget && <Sheet open={relatedOpen} onOpenChange={setRelatedOpen}>
|
||||||
<DialogContent className="max-h-[90vh] w-[calc(100vw-2rem)] max-w-3xl overflow-hidden">
|
<SheetContent
|
||||||
<DialogHeader>
|
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="flex items-start justify-between gap-3 pr-8">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
@@ -194,16 +201,19 @@ export function SourceCard({
|
|||||||
{source.index}
|
{source.index}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<DialogTitle className="text-base leading-relaxed">
|
<SheetTitle className="text-base leading-relaxed">
|
||||||
{source.title}
|
{source.title}
|
||||||
</DialogTitle>
|
</SheetTitle>
|
||||||
</div>
|
</div>
|
||||||
{meta.length > 0 && (
|
{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) => (
|
{meta.map((item, index) => (
|
||||||
<span key={`${item}-${index}`}>{item}</span>
|
<span key={`${item}-${index}`}>{item}</span>
|
||||||
))}
|
))}
|
||||||
</DialogDescription>
|
</SheetDescription>
|
||||||
|
)}
|
||||||
|
{meta.length === 0 && (
|
||||||
|
<SheetDescription className="sr-only">参考文献详情</SheetDescription>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{source.url && !previewTarget && (
|
{source.url && !previewTarget && (
|
||||||
@@ -219,9 +229,10 @@ export function SourceCard({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 ? (
|
{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 && (
|
{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">
|
<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" />
|
<BookOpenTextIcon className="size-4" />
|
||||||
@@ -237,15 +248,20 @@ export function SourceCard({
|
|||||||
rehypePlugins={reasoningPlugins.rehypePlugins}
|
rehypePlugins={reasoningPlugins.rehypePlugins}
|
||||||
/>
|
/>
|
||||||
</article>
|
</article>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex min-h-64 items-center justify-center rounded-xl border border-dashed bg-background text-sm text-muted-foreground">
|
||||||
|
暂无可展示的引用内容
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
)}
|
||||||
{source.url && !previewTarget && (
|
{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}
|
{source.url}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</DialogContent>
|
</div>
|
||||||
</Dialog>}
|
</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,
|
applyCompactMenuOverrides,
|
||||||
type CompactMenuItem,
|
type CompactMenuItem,
|
||||||
} from "@/core/page-layout/compact-menu-overrides";
|
} 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";
|
import { useCompactMenuOverrides } from "@/strategy-components/api/compact-menu-overrides";
|
||||||
|
|
||||||
// 普通导航项:默认中性色,激活态与「新对话」一致——主色浅底 + 主色文字/图标
|
// 普通导航项:默认中性色,激活态与「新对话」一致——主色浅底 + 主色文字/图标
|
||||||
@@ -95,13 +98,6 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
|
|||||||
path: "/page/workspace/skills",
|
path: "/page/workspace/skills",
|
||||||
match: (pathname) => pathname.startsWith("/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",
|
id: "workspace-ai-writing",
|
||||||
label: "AI写作",
|
label: "AI写作",
|
||||||
@@ -149,7 +145,13 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
|
|||||||
label: "知识库管理",
|
label: "知识库管理",
|
||||||
icon: BookOpen,
|
icon: BookOpen,
|
||||||
path: "/page/workspace/knowledge",
|
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[];
|
settingsItems: EffectiveWorkspaceCompactNavItem[];
|
||||||
toolboxItems: EffectiveWorkspaceCompactNavItem[];
|
toolboxItems: EffectiveWorkspaceCompactNavItem[];
|
||||||
} {
|
} {
|
||||||
const { data: overrides = [] } = useCompactMenuOverrides();
|
const { data: overrideConfig } = useCompactMenuOverrides();
|
||||||
|
const overrides = overrideConfig?.overrides ?? [];
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
const applied = applyCompactMenuOverrides(WORKSPACE_COMPACT_NAV_ITEMS, overrides);
|
const applied = applyCompactMenuOverrides(WORKSPACE_COMPACT_NAV_ITEMS, overrides);
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -223,7 +223,7 @@ export function WorkspaceNavMenu() {
|
|||||||
{isAdmin ? (
|
{isAdmin ? (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
navigate("/page/strategy/admin/user-leaderboard")
|
navigate("/page/workspace/admin/user-leaderboard")
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<TrophyIcon />
|
<TrophyIcon />
|
||||||
@@ -273,16 +273,15 @@ export function WorkspaceNavMenu() {
|
|||||||
模型配置
|
模型配置
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
{/* 与全量模式左下角「设置和更多」下拉里的菜单管理同入口(管理员):
|
{/* 与全量模式左下角「设置和更多」下拉里的菜单管理同入口:
|
||||||
简洁模式没有全局侧边栏,这里放一份保证两种模式都能进菜单管理。 */}
|
简洁模式没有全局侧边栏,走工作区地址带上 WorkspaceSidebar;
|
||||||
{isAdmin && (
|
/page/strategy/admin/menu-management 保留给全量模式侧边栏。 */}
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => navigate("/page/strategy/admin/menu-management")}
|
onClick={() => navigate("/page/workspace/admin/menu-management")}
|
||||||
>
|
>
|
||||||
<ListTreeIcon />
|
<ListTreeIcon />
|
||||||
菜单管理
|
{isAdmin ? "默认菜单管理" : "我的菜单"}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => navigate("/page/workspace/admin/tags")}
|
onClick={() => navigate("/page/workspace/admin/tags")}
|
||||||
>
|
>
|
||||||
@@ -328,7 +327,7 @@ export function WorkspaceNavMenu() {
|
|||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
navigate("/page/strategy/admin/fixed-questions")
|
navigate("/page/workspace/admin/fixed-questions")
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<MessageSquareTextIcon />
|
<MessageSquareTextIcon />
|
||||||
|
|||||||
@@ -21,11 +21,14 @@ import { WorkspaceNavMenu } from "./workspace-nav-menu";
|
|||||||
export function WorkspaceSidebar({
|
export function WorkspaceSidebar({
|
||||||
compactChrome = false,
|
compactChrome = false,
|
||||||
forceCollapsed = false,
|
forceCollapsed = false,
|
||||||
|
wxCommonAgents = false,
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof Sidebar> & {
|
}: React.ComponentProps<typeof Sidebar> & {
|
||||||
compactChrome?: boolean;
|
compactChrome?: boolean;
|
||||||
forceCollapsed?: boolean;
|
forceCollapsed?: boolean;
|
||||||
|
/** Enables wx's shared Agent Gallery category selection. */
|
||||||
|
wxCommonAgents?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { open: isSidebarOpen } = useSidebar();
|
const { open: isSidebarOpen } = useSidebar();
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
@@ -35,6 +38,7 @@ export function WorkspaceSidebar({
|
|||||||
// 仅在通用问答(/page/workspace/chats*)下启用三块式布局;点击常用智能体后是
|
// 仅在通用问答(/page/workspace/chats*)下启用三块式布局;点击常用智能体后是
|
||||||
// 「原地嵌入」(停留在 /chats/new?agent=...,仅右侧问答区换成智能体对话),左侧栏不变。
|
// 「原地嵌入」(停留在 /chats/new?agent=...,仅右侧问答区换成智能体对话),左侧栏不变。
|
||||||
const isGeneralQa =
|
const isGeneralQa =
|
||||||
|
wxCommonAgents ||
|
||||||
pathname.startsWith("/page/workspace/chats") ||
|
pathname.startsWith("/page/workspace/chats") ||
|
||||||
pathname.startsWith("/page/strategy/chats");
|
pathname.startsWith("/page/strategy/chats");
|
||||||
|
|
||||||
@@ -84,7 +88,7 @@ export function WorkspaceSidebar({
|
|||||||
{isGeneralQa ? (
|
{isGeneralQa ? (
|
||||||
// 全量版通用问答(/page/workspace/chats*):新建对话侧栏独立成
|
// 全量版通用问答(/page/workspace/chats*):新建对话侧栏独立成
|
||||||
// GeneralQaSidebar(按 0616 样式参考图重画),与其余 workspace 页解耦。
|
// GeneralQaSidebar(按 0616 样式参考图重画),与其余 workspace 页解耦。
|
||||||
<GeneralQaSidebar />
|
<GeneralQaSidebar wxCommonAgents={wxCommonAgents} />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<WorkspaceNavChatList showCollapseTrigger compact />
|
<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,
|
PaginatedAgents,
|
||||||
UpdateAgentRequest,
|
UpdateAgentRequest,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
import { mergeConversationAgentOptions } from "./conversation-options";
|
||||||
|
|
||||||
/** A cached agent listing is either a plain ``Agent[]`` (legacy ``useAgents``)
|
/** A cached agent listing is either a plain ``Agent[]`` (legacy ``useAgents``)
|
||||||
* or the paginated ``{ agents, total, ... }`` envelope. Mutators patch both. */
|
* 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>) {
|
function invalidateAgentQueries(queryClient: ReturnType<typeof useQueryClient>) {
|
||||||
void queryClient.invalidateQueries({ queryKey: ["agents"] });
|
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 { apiFetch } from "../api";
|
||||||
|
import { getBackendBaseURL } from "../config";
|
||||||
import { downloadMarkdownAsDocx } from "../scheduled-tasks/markdown-docx";
|
import { downloadMarkdownAsDocx } from "../scheduled-tasks/markdown-docx";
|
||||||
|
import { prepareGeneratedHtml } from "../utils/legacy-html-preview";
|
||||||
import { getFileName } from "../utils/files";
|
import { getFileName } from "../utils/files";
|
||||||
|
|
||||||
import { urlOfArtifact } from "./utils";
|
import { urlOfArtifact } from "./utils";
|
||||||
@@ -60,12 +62,23 @@ export async function downloadArtifactFile({
|
|||||||
const filename =
|
const filename =
|
||||||
filenameFromContentDisposition(cd) ?? getFileName(filepath);
|
filenameFromContentDisposition(cd) ?? getFileName(filepath);
|
||||||
|
|
||||||
const blob = await res.blob();
|
|
||||||
const contentType = res.headers.get("Content-Type")?.split(";")[0]?.trim();
|
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
|
contentType && blob.type !== contentType
|
||||||
? new Blob([await blob.arrayBuffer()], { type: contentType })
|
? new Blob([await blob.arrayBuffer()], { type: contentType })
|
||||||
: blob;
|
: blob;
|
||||||
|
}
|
||||||
const objectUrl = URL.createObjectURL(downloadBlob);
|
const objectUrl = URL.createObjectURL(downloadBlob);
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
a.href = objectUrl;
|
a.href = objectUrl;
|
||||||
@@ -95,6 +108,54 @@ export async function downloadArtifactMarkdownAsWord({
|
|||||||
await downloadMarkdownAsDocx(await res.text(), fileName, title);
|
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({
|
export async function openArtifactInNewTab({
|
||||||
filepath,
|
filepath,
|
||||||
threadId,
|
threadId,
|
||||||
@@ -111,7 +172,16 @@ export async function openArtifactInNewTab({
|
|||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(await errorMessageFromResponse(res));
|
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 objectUrl = URL.createObjectURL(blob);
|
||||||
const w = window.open(objectUrl, "_blank", "noopener,noreferrer");
|
const w = window.open(objectUrl, "_blank", "noopener,noreferrer");
|
||||||
if (w) w.opener = null;
|
if (w) w.opener = null;
|
||||||
|
|||||||
@@ -13,7 +13,11 @@ import {
|
|||||||
isSandboxStreamPerfEnabled,
|
isSandboxStreamPerfEnabled,
|
||||||
} from "./sandbox-stream-perf";
|
} from "./sandbox-stream-perf";
|
||||||
import { isArtifactApiPath } from "./streaming-write-preview";
|
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";
|
import { updateArtifactContent } from "./api";
|
||||||
|
|
||||||
function hasToolResult(
|
function hasToolResult(
|
||||||
@@ -135,6 +139,12 @@ export function useArtifactContent({
|
|||||||
: undefined;
|
: undefined;
|
||||||
const writeFileInFlight =
|
const writeFileInFlight =
|
||||||
useWriteFileStreaming && isWriteFileInFlight(thread, filepath);
|
useWriteFileStreaming && isWriteFileInFlight(thread, filepath);
|
||||||
|
const completedArtifactMutationKey = useMemo(() => {
|
||||||
|
if (useWriteFileStreaming || !isArtifactApiPath(filepath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return latestCompletedArtifactMutationKey(thread, filepath);
|
||||||
|
}, [filepath, thread, useWriteFileStreaming]);
|
||||||
const displayedWriteFileContent = useThrottledWriteFileDisplay({
|
const displayedWriteFileContent = useThrottledWriteFileDisplay({
|
||||||
filepath,
|
filepath,
|
||||||
liveContent: liveWriteFileContent,
|
liveContent: liveWriteFileContent,
|
||||||
@@ -150,7 +160,14 @@ export function useArtifactContent({
|
|||||||
}, [filepath, thread, useWriteFileStreaming]);
|
}, [filepath, thread, useWriteFileStreaming]);
|
||||||
|
|
||||||
const { data, isLoading, error, refetch } = useQuery({
|
const { data, isLoading, error, refetch } = useQuery({
|
||||||
queryKey: ["artifact", filepath, threadId, isMock, publicShareCode],
|
queryKey: [
|
||||||
|
"artifact",
|
||||||
|
filepath,
|
||||||
|
threadId,
|
||||||
|
isMock,
|
||||||
|
publicShareCode,
|
||||||
|
completedArtifactMutationKey,
|
||||||
|
],
|
||||||
queryFn: () => {
|
queryFn: () => {
|
||||||
return loadArtifactContent({ filepath, threadId, isMock, publicShareCode });
|
return loadArtifactContent({ filepath, threadId, isMock, publicShareCode });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,7 +9,15 @@ export type ArtifactLibraryKind =
|
|||||||
| "archive"
|
| "archive"
|
||||||
| "other";
|
| "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 {
|
export interface ArtifactLibraryItem {
|
||||||
threadId: string;
|
threadId: string;
|
||||||
@@ -18,6 +26,10 @@ export interface ArtifactLibraryItem {
|
|||||||
agentName: string | null;
|
agentName: string | null;
|
||||||
threadType: string | null;
|
threadType: string | null;
|
||||||
sourceType: Exclude<ArtifactLibrarySource, "all">;
|
sourceType: Exclude<ArtifactLibrarySource, "all">;
|
||||||
|
strategyProjectId: string | null;
|
||||||
|
strategyName: string | null;
|
||||||
|
strategySourceKind: ArtifactStrategySourceKind | null;
|
||||||
|
strategyStale: boolean;
|
||||||
path: string;
|
path: string;
|
||||||
name: string;
|
name: string;
|
||||||
sizeBytes: number;
|
sizeBytes: number;
|
||||||
@@ -82,6 +94,10 @@ export async function fetchArtifactLibrary({
|
|||||||
agent_name?: string | null;
|
agent_name?: string | null;
|
||||||
thread_type?: string | null;
|
thread_type?: string | null;
|
||||||
source_type?: Exclude<ArtifactLibrarySource, "all">;
|
source_type?: Exclude<ArtifactLibrarySource, "all">;
|
||||||
|
strategy_project_id?: string | null;
|
||||||
|
strategy_name?: string | null;
|
||||||
|
strategy_source_kind?: ArtifactStrategySourceKind | null;
|
||||||
|
strategy_stale?: boolean;
|
||||||
path: string;
|
path: string;
|
||||||
name: string;
|
name: string;
|
||||||
size_bytes: number;
|
size_bytes: number;
|
||||||
@@ -102,6 +118,10 @@ export async function fetchArtifactLibrary({
|
|||||||
agentName: item.agent_name ?? item.agent_id ?? null,
|
agentName: item.agent_name ?? item.agent_id ?? null,
|
||||||
threadType: item.thread_type ?? null,
|
threadType: item.thread_type ?? null,
|
||||||
sourceType: item.source_type ?? (item.agent_id ? "agent" : "general_qa"),
|
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,
|
path: item.path,
|
||||||
name: item.name,
|
name: item.name,
|
||||||
sizeBytes: item.size_bytes,
|
sizeBytes: item.size_bytes,
|
||||||
|
|||||||
@@ -3,10 +3,142 @@ import test from "node:test";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
isWriteFileInFlight,
|
isWriteFileInFlight,
|
||||||
|
latestCompletedArtifactMutationKey,
|
||||||
latestInFlightWriteFilePreview,
|
latestInFlightWriteFilePreview,
|
||||||
|
latestPreparedHtmlArtifact,
|
||||||
resolveWriteFilePath,
|
resolveWriteFilePath,
|
||||||
} from "./write-file-in-flight";
|
} 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", () => {
|
test("latestInFlightWriteFilePreview opens a write_file that is not the last tool call", () => {
|
||||||
const preview = latestInFlightWriteFilePreview([
|
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) {
|
function hasToolResult(thread: WriteFileThreadLike, toolCallId?: string) {
|
||||||
if (!toolCallId) {
|
if (!toolCallId) {
|
||||||
return false;
|
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. */
|
/** True only while THIS file's write_file call has not yet returned a tool result. */
|
||||||
export function isWriteFileInFlight(
|
export function isWriteFileInFlight(
|
||||||
thread: WriteFileThreadLike,
|
thread: WriteFileThreadLike,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"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 { useLocation, useNavigate } from "react-router-dom";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -125,5 +125,9 @@ export function AuthSyncWatcher({
|
|||||||
};
|
};
|
||||||
}, [disabled, location.pathname, navigate, queryClient]);
|
}, [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",
|
badgePublishedTitle: "Visible to all users",
|
||||||
badgeUnpublished: "Unpublished",
|
badgeUnpublished: "Unpublished",
|
||||||
badgeUnpublishedTitle: "Not published yet — only visible to its owner",
|
badgeUnpublishedTitle: "Not published yet — only visible to its owner",
|
||||||
|
badgeOffline: "Offline",
|
||||||
|
badgeOfflineTitle: "Retained in the system but unavailable to models",
|
||||||
badgeStale: "Stale",
|
badgeStale: "Stale",
|
||||||
badgeStaleTitle: "Idle for a long time — flagged by the curator",
|
badgeStaleTitle: "Idle for a long time — flagged by the curator",
|
||||||
badgeArchived: "Archived",
|
badgeArchived: "Archived",
|
||||||
@@ -514,6 +516,13 @@ export const enUS: Translations = {
|
|||||||
unpublishAction: "Unpublish",
|
unpublishAction: "Unpublish",
|
||||||
publishSuccess: "Skill published",
|
publishSuccess: "Skill published",
|
||||||
unpublishSuccess: "Skill unpublished",
|
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)",
|
pinAction: "Pin (prevent auto-archive)",
|
||||||
unpinAction: "Unpin",
|
unpinAction: "Unpin",
|
||||||
pinSuccess: "Pinned",
|
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.",
|
"Skill '{name}' was uploaded by user '{owner}' and cannot be replaced. Rename the skill before uploading.",
|
||||||
conflictCustom:
|
conflictCustom:
|
||||||
"A custom skill named '{name}' already exists, but its uploader could not be identified. Rename it to avoid overwriting someone else's skill.",
|
"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:
|
conflictPublic:
|
||||||
"A built-in public skill named '{name}' already exists and cannot be overwritten.",
|
"A built-in public skill named '{name}' already exists and cannot be overwritten.",
|
||||||
conflictPublicAdminHint:
|
conflictPublicAdminHint:
|
||||||
|
|||||||
@@ -425,6 +425,8 @@ export interface Translations {
|
|||||||
badgePublishedTitle: string;
|
badgePublishedTitle: string;
|
||||||
badgeUnpublished: string;
|
badgeUnpublished: string;
|
||||||
badgeUnpublishedTitle: string;
|
badgeUnpublishedTitle: string;
|
||||||
|
badgeOffline: string;
|
||||||
|
badgeOfflineTitle: string;
|
||||||
badgeStale: string;
|
badgeStale: string;
|
||||||
badgeStaleTitle: string;
|
badgeStaleTitle: string;
|
||||||
badgeArchived: string;
|
badgeArchived: string;
|
||||||
@@ -447,6 +449,12 @@ export interface Translations {
|
|||||||
unpublishAction: string;
|
unpublishAction: string;
|
||||||
publishSuccess: string;
|
publishSuccess: string;
|
||||||
unpublishSuccess: string;
|
unpublishSuccess: string;
|
||||||
|
onlineAction: string;
|
||||||
|
offlineAction: string;
|
||||||
|
onlineSuccess: string;
|
||||||
|
offlineSuccess: string;
|
||||||
|
offlineConfirmTitle: string;
|
||||||
|
offlineConfirmDescription: string;
|
||||||
pinAction: string;
|
pinAction: string;
|
||||||
unpinAction: string;
|
unpinAction: string;
|
||||||
pinSuccess: string;
|
pinSuccess: string;
|
||||||
@@ -531,6 +539,7 @@ export interface Translations {
|
|||||||
conflictOwnedByMeWrongTarget: string;
|
conflictOwnedByMeWrongTarget: string;
|
||||||
conflictOwnedByOther: string;
|
conflictOwnedByOther: string;
|
||||||
conflictCustom: string;
|
conflictCustom: string;
|
||||||
|
conflictUnclaimed: string;
|
||||||
conflictPublic: string;
|
conflictPublic: string;
|
||||||
conflictPublicAdminHint: string;
|
conflictPublicAdminHint: string;
|
||||||
installSuccess: string;
|
installSuccess: string;
|
||||||
|
|||||||
@@ -472,6 +472,8 @@ export const zhCN: Translations = {
|
|||||||
badgePublishedTitle: "该技能已对所有用户可见",
|
badgePublishedTitle: "该技能已对所有用户可见",
|
||||||
badgeUnpublished: "未发布",
|
badgeUnpublished: "未发布",
|
||||||
badgeUnpublishedTitle: "该技能尚未发布,仅所有者可见",
|
badgeUnpublishedTitle: "该技能尚未发布,仅所有者可见",
|
||||||
|
badgeOffline: "已下线",
|
||||||
|
badgeOfflineTitle: "技能仍保留在系统中,但不会提供给模型调用",
|
||||||
badgeStale: "过时",
|
badgeStale: "过时",
|
||||||
badgeStaleTitle: "长期未使用,已被策展程序标记",
|
badgeStaleTitle: "长期未使用,已被策展程序标记",
|
||||||
badgeArchived: "归档",
|
badgeArchived: "归档",
|
||||||
@@ -494,6 +496,13 @@ export const zhCN: Translations = {
|
|||||||
unpublishAction: "取消发布",
|
unpublishAction: "取消发布",
|
||||||
publishSuccess: "已发布",
|
publishSuccess: "已发布",
|
||||||
unpublishSuccess: "已取消发布",
|
unpublishSuccess: "已取消发布",
|
||||||
|
onlineAction: "上线",
|
||||||
|
offlineAction: "下线",
|
||||||
|
onlineSuccess: "技能已上线,模型可以再次调用",
|
||||||
|
offlineSuccess: "技能已下线,模型将不再调用",
|
||||||
|
offlineConfirmTitle: "确认下线技能?",
|
||||||
|
offlineConfirmDescription:
|
||||||
|
"下线技能“{name}”吗?技能文件和配置会保留,但模型无法调用它,新配置也不能选择它。之后可随时重新上线。",
|
||||||
pinAction: "钉选(防止被自动归档)",
|
pinAction: "钉选(防止被自动归档)",
|
||||||
unpinAction: "取消钉选",
|
unpinAction: "取消钉选",
|
||||||
pinSuccess: "已钉选",
|
pinSuccess: "已钉选",
|
||||||
@@ -582,6 +591,8 @@ export const zhCN: Translations = {
|
|||||||
"技能 “{name}” 已由用户 “{owner}” 上传,不能覆盖,请修改技能名称后再上传。",
|
"技能 “{name}” 已由用户 “{owner}” 上传,不能覆盖,请修改技能名称后再上传。",
|
||||||
conflictCustom:
|
conflictCustom:
|
||||||
"已存在同名自定义技能 “{name}”,但无法确认上传用户,为避免误覆盖请修改技能名称。",
|
"已存在同名自定义技能 “{name}”,但无法确认上传用户,为避免误覆盖请修改技能名称。",
|
||||||
|
conflictUnclaimed:
|
||||||
|
"服务器上存在同名技能 “{name}” 的残留副本(无归属用户,列表中不可见、无法管理)。点击「替换」将清除残留副本并安装您上传的版本。",
|
||||||
conflictPublic: `存在同名内置技能 '{name}',无法覆盖。`,
|
conflictPublic: `存在同名内置技能 '{name}',无法覆盖。`,
|
||||||
conflictPublicAdminHint: `存在同名内置技能 '{name}'。点击「重复上传并覆盖」可替换。`,
|
conflictPublicAdminHint: `存在同名内置技能 '{name}'。点击「重复上传并覆盖」可替换。`,
|
||||||
installSuccess: `技能 '{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"]);
|
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", () => {
|
test("a lone deep-research progress bar still opens its own group", () => {
|
||||||
const types = groupTypes([
|
const types = groupTypes([
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -370,6 +370,12 @@ export function extractReasoningContentFromMessage(message: Message) {
|
|||||||
) {
|
) {
|
||||||
return message.additional_kwargs.reasoning_content as string | null;
|
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)) {
|
if (Array.isArray(message.content)) {
|
||||||
const part = message.content[0];
|
const part = message.content[0];
|
||||||
if (part && "thinking" in part) {
|
if (part && "thinking" in part) {
|
||||||
@@ -387,6 +393,7 @@ export function removeReasoningContentFromMessage(message: Message) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
delete message.additional_kwargs.reasoning_content;
|
delete message.additional_kwargs.reasoning_content;
|
||||||
|
delete message.additional_kwargs.reasoning;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function extractURLFromImageURLContent(
|
export function extractURLFromImageURLContent(
|
||||||
@@ -424,6 +431,9 @@ export function hasReasoning(message: Message) {
|
|||||||
if (typeof message.additional_kwargs?.reasoning_content === "string") {
|
if (typeof message.additional_kwargs?.reasoning_content === "string") {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (typeof message.additional_kwargs?.reasoning === "string") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (Array.isArray(message.content)) {
|
if (Array.isArray(message.content)) {
|
||||||
const part = message.content[0];
|
const part = message.content[0];
|
||||||
// Compatible with the Anthropic gateway
|
// Compatible with the Anthropic gateway
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export interface ManagedModel {
|
|||||||
base_url: string | null;
|
base_url: string | null;
|
||||||
request_timeout: number | null;
|
request_timeout: number | null;
|
||||||
max_retries: number | null;
|
max_retries: number | null;
|
||||||
|
max_input_tokens: number | null;
|
||||||
max_tokens: number | null;
|
max_tokens: number | null;
|
||||||
temperature: number | null;
|
temperature: number | null;
|
||||||
stream_usage: boolean | null;
|
stream_usage: boolean | null;
|
||||||
@@ -44,6 +45,7 @@ export interface ModelSavePayload {
|
|||||||
base_url?: string | null;
|
base_url?: string | null;
|
||||||
request_timeout?: number | null;
|
request_timeout?: number | null;
|
||||||
max_retries?: number | null;
|
max_retries?: number | null;
|
||||||
|
max_input_tokens?: number | null;
|
||||||
max_tokens?: number | null;
|
max_tokens?: number | null;
|
||||||
temperature?: number | null;
|
temperature?: number | null;
|
||||||
stream_usage?: boolean | null;
|
stream_usage?: boolean | null;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export interface Model {
|
|||||||
supports_thinking?: boolean;
|
supports_thinking?: boolean;
|
||||||
supports_reasoning_effort?: boolean;
|
supports_reasoning_effort?: boolean;
|
||||||
/** Execution profiles handled by the frontend instead of DeerFlow. */
|
/** 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 {
|
export interface TokenUsageSettings {
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ test("matches only workspace chats_iframe routes", () => {
|
|||||||
assert.equal(isChatsIframeWorkspacePath("/landing"), false);
|
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("/landing"), "light");
|
||||||
assert.equal(
|
assert.equal(
|
||||||
resolveForcedThemeForPath("/page/workspace/chats_iframe/new"),
|
resolveForcedThemeForPath("/page/workspace/chats_iframe/new"),
|
||||||
@@ -41,6 +41,14 @@ test("forces light theme for landing and chats_iframe routes only", () => {
|
|||||||
"light",
|
"light",
|
||||||
);
|
);
|
||||||
assert.equal(resolveForcedThemeForPath("/page/workspace/chats/new"), undefined);
|
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", () => {
|
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 CHATS_IFRAME_PATH = "/page/workspace/chats_iframe/new";
|
||||||
const IFRAME_LOGIN_SOURCE = "iframe";
|
const IFRAME_LOGIN_SOURCE = "iframe";
|
||||||
const STANDARD_CHATS_PATH = "/page/workspace/chats";
|
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 {
|
function toSearchParams(search: string): URLSearchParams {
|
||||||
return new URLSearchParams(search.startsWith("?") ? search : `?${search}`);
|
return new URLSearchParams(search.startsWith("?") ? search : `?${search}`);
|
||||||
@@ -328,6 +330,10 @@ export function resolveForcedThemeForPath(
|
|||||||
return "light";
|
return "light";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pathname.startsWith(WX_FULL_CHAT_PATH_PREFIX)) {
|
||||||
|
return "dark";
|
||||||
|
}
|
||||||
|
|
||||||
const isLoginPath = pathname === "/login" || pathname.startsWith("/login/");
|
const isLoginPath = pathname === "/login" || pathname.startsWith("/login/");
|
||||||
if (
|
if (
|
||||||
isChatsIframeWorkspacePath(pathname) ||
|
isChatsIframeWorkspacePath(pathname) ||
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import test from "node:test";
|
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";
|
import type { MenuItem } from "./sidebar-menu";
|
||||||
|
|
||||||
const I = (() => null) as unknown as MenuItem["icon"];
|
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]?.id, "c");
|
||||||
assert.equal(applied.toolboxItems[0]?.label, "技能工具");
|
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 {
|
export interface CompactMenuEditorItem extends CompactMenuItem {
|
||||||
defaultLabel: string;
|
defaultLabel: string;
|
||||||
defaultIndex: number;
|
defaultIndex: number;
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
flattenMenu,
|
flattenMenu,
|
||||||
isDescendant,
|
isDescendant,
|
||||||
mergeFlatNodes,
|
mergeFlatNodes,
|
||||||
|
preserveUnknownMenuOverrides,
|
||||||
|
snapshotOverrides,
|
||||||
wouldExceedDepth,
|
wouldExceedDepth,
|
||||||
type MenuOverride,
|
type MenuOverride,
|
||||||
} from "./menu-overrides";
|
} from "./menu-overrides";
|
||||||
@@ -117,3 +119,31 @@ test("套覆盖再 diff 可往返", () => {
|
|||||||
assert.equal(diff.length, 1);
|
assert.equal(diff.length, 1);
|
||||||
assert.equal(diff[0]!.label, "X");
|
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;
|
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;
|
path?: string;
|
||||||
/** 额外视为「当前页属于本菜单项」的路由(不在侧栏展示,仅用于高亮)。 */
|
/** 额外视为「当前页属于本菜单项」的路由(不在侧栏展示,仅用于高亮)。 */
|
||||||
aliasPaths?: string[];
|
aliasPaths?: string[];
|
||||||
|
/** 从当前菜单项自身的 path / aliasPaths 前缀匹配中排除的路由。 */
|
||||||
|
excludePaths?: string[];
|
||||||
/** 非 admin 用户使用的路由(菜单对普通用户可见时生效,与简洁模式公开排行榜一致) */
|
/** 非 admin 用户使用的路由(菜单对普通用户可见时生效,与简洁模式公开排行榜一致) */
|
||||||
userPath?: string;
|
userPath?: string;
|
||||||
adminOnly?: boolean;
|
adminOnly?: boolean;
|
||||||
children?: MenuItem[];
|
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[] = [
|
export const sidebarMenuItems: MenuItem[] = [
|
||||||
{
|
{
|
||||||
id: "qa-center",
|
id: "qa-center",
|
||||||
@@ -266,12 +277,7 @@ export const sidebarMenuItems: MenuItem[] = [
|
|||||||
},
|
},
|
||||||
{ id: "mgmt-skills", label: "技能管理", icon: Sparkles, path: "/page/strategy/skills" },
|
{ id: "mgmt-skills", label: "技能管理", icon: Sparkles, path: "/page/strategy/skills" },
|
||||||
{ id: "mgmt-memory", label: "记忆管理", icon: Brain, path: "/page/strategy/memory" },
|
{ id: "mgmt-memory", label: "记忆管理", icon: Brain, path: "/page/strategy/memory" },
|
||||||
{
|
MARKDOWN_DOCX_MENU_ITEM,
|
||||||
id: "resource-markdown-docx-editor",
|
|
||||||
label: "md转word",
|
|
||||||
icon: FileText,
|
|
||||||
path: "/page/workspace/knowledge/markdown-editor",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: "mgmt-tasks",
|
id: "mgmt-tasks",
|
||||||
label: "定时任务",
|
label: "定时任务",
|
||||||
@@ -338,6 +344,7 @@ export const sidebarMenuItems: MenuItem[] = [
|
|||||||
label: "知识库管理",
|
label: "知识库管理",
|
||||||
icon: BookOpen,
|
icon: BookOpen,
|
||||||
path: "/page/workspace/knowledge",
|
path: "/page/workspace/knowledge",
|
||||||
|
excludePaths: [MARKDOWN_DOCX_MENU_PATH],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "resource-knowledge-base",
|
id: "resource-knowledge-base",
|
||||||
@@ -373,6 +380,9 @@ function routeMatchesPathname(pathname: string, route?: string): boolean {
|
|||||||
|
|
||||||
/** 菜单项自身 path / aliasPaths 是否匹配当前路由。 */
|
/** 菜单项自身 path / aliasPaths 是否匹配当前路由。 */
|
||||||
export function menuItemMatchesPath(item: MenuItem, pathname: string): boolean {
|
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;
|
if (routeMatchesPathname(pathname, item.path)) return true;
|
||||||
return item.aliasPaths?.some((p) => routeMatchesPathname(pathname, p)) ?? false;
|
return item.aliasPaths?.some((p) => routeMatchesPathname(pathname, p)) ?? false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,7 +89,8 @@ function useDesensitizeLabel() {
|
|||||||
*/
|
*/
|
||||||
export function useSidebarMenuItems(isAdmin: boolean): MenuItem[] {
|
export function useSidebarMenuItems(isAdmin: boolean): MenuItem[] {
|
||||||
const { data: iframeApps = [] } = useLightApps({ appType: "iframe", enabledOnly: true });
|
const { data: iframeApps = [] } = useLightApps({ appType: "iframe", enabledOnly: true });
|
||||||
const { data: overrides = [] } = useMenuOverrides();
|
const { data: overrideConfig } = useMenuOverrides();
|
||||||
|
const overrides = overrideConfig?.overrides ?? [];
|
||||||
const desensitizeLabel = useDesensitizeLabel();
|
const desensitizeLabel = useDesensitizeLabel();
|
||||||
return useMemo(
|
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>;
|
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(
|
const response = await apiFetch(
|
||||||
`${getBackendBaseURL()}/api/skills/${skillName}`,
|
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}`,
|
||||||
{
|
{
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: {
|
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(
|
export async function setSkillAlwaysOn(
|
||||||
|
|||||||
@@ -159,17 +159,31 @@ export function useSetSkillDisplay() {
|
|||||||
export function useEnableSkill() {
|
export function useEnableSkill() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async ({
|
mutationFn: ({
|
||||||
skillName,
|
skillName,
|
||||||
enabled,
|
enabled,
|
||||||
}: {
|
}: {
|
||||||
skillName: string;
|
skillName: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
}) => {
|
}) => enableSkill(skillName, enabled),
|
||||||
await enableSkill(skillName, enabled);
|
onSuccess: (data, { skillName }) => {
|
||||||
},
|
// The availability endpoint intentionally returns the filesystem skill
|
||||||
onSuccess: () => {
|
// 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: ["skills"] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["skill", skillName] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Skill } from "./type";
|
import type { Skill } from "./type";
|
||||||
|
import { isSkillOffline } from "../skill-availability";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 智能体技能白名单的候选池过滤。
|
* 智能体技能白名单的候选池过滤。
|
||||||
@@ -12,7 +13,9 @@ export function isAgentSkillCandidate(
|
|||||||
currentUserId: string | null,
|
currentUserId: string | null,
|
||||||
isAdmin = false,
|
isAdmin = false,
|
||||||
): boolean {
|
): 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 (isAdmin) return true;
|
||||||
if ((skill.state ?? "active") !== "active") return false;
|
if ((skill.state ?? "active") !== "active") return false;
|
||||||
const builtin = skill.builtin ?? !skill.owner_user_id;
|
const builtin = skill.builtin ?? !skill.owner_user_id;
|
||||||
|
|||||||
@@ -314,6 +314,42 @@ export interface PublishSquaresUpdate {
|
|||||||
default_square_id?: string;
|
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> {
|
export async function loadPublishSquares(): Promise<PublishSquaresSettings> {
|
||||||
const response = await apiFetch(
|
const response = await apiFetch(
|
||||||
`${getBackendBaseURL()}/api/system-settings/publish-squares`,
|
`${getBackendBaseURL()}/api/system-settings/publish-squares`,
|
||||||
@@ -601,6 +637,48 @@ export interface ModelConcurrencyUpdate {
|
|||||||
user_model_limits?: Record<string, number>;
|
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> {
|
export async function loadSkillCompressionSettings(): Promise<SkillCompressionSettings> {
|
||||||
const response = await apiFetch(
|
const response = await apiFetch(
|
||||||
`${getBackendBaseURL()}/api/system-settings/skill-compression`,
|
`${getBackendBaseURL()}/api/system-settings/skill-compression`,
|
||||||
|
|||||||
@@ -8,27 +8,32 @@ import {
|
|||||||
loadCitationDisplaySettings,
|
loadCitationDisplaySettings,
|
||||||
loadLeaderboardSettings,
|
loadLeaderboardSettings,
|
||||||
loadModelConcurrencySettings,
|
loadModelConcurrencySettings,
|
||||||
|
loadMemoryRuntimeSettings,
|
||||||
loadPluginManagementSettings,
|
loadPluginManagementSettings,
|
||||||
loadPrivateSquareStatus,
|
loadPrivateSquareStatus,
|
||||||
loadPromptPrefixSettings,
|
loadPromptPrefixSettings,
|
||||||
loadPublishSquares,
|
loadPublishSquares,
|
||||||
loadSkillCompressionSettings,
|
loadSkillCompressionSettings,
|
||||||
loadStrategyProjectSettings,
|
loadStrategyProjectSettings,
|
||||||
|
loadWxCommonAgents,
|
||||||
updateAgentModuleConfig,
|
updateAgentModuleConfig,
|
||||||
updateAppearanceSettings,
|
updateAppearanceSettings,
|
||||||
updateCitationDisplaySettings,
|
updateCitationDisplaySettings,
|
||||||
updateLeaderboardSettings,
|
updateLeaderboardSettings,
|
||||||
updateModelConcurrencySettings,
|
updateModelConcurrencySettings,
|
||||||
|
updateMemoryRuntimeSettings,
|
||||||
updatePluginManagementSettings,
|
updatePluginManagementSettings,
|
||||||
updatePrivateSquareSettings,
|
updatePrivateSquareSettings,
|
||||||
updatePromptPrefixSettings,
|
updatePromptPrefixSettings,
|
||||||
updatePublishSquares,
|
updatePublishSquares,
|
||||||
updateSkillCompressionSettings,
|
updateSkillCompressionSettings,
|
||||||
updateStrategyProjectSettings,
|
updateStrategyProjectSettings,
|
||||||
|
updateWxCommonAgents,
|
||||||
type AppearanceUpdate,
|
type AppearanceUpdate,
|
||||||
type CitationDisplayUpdate,
|
type CitationDisplayUpdate,
|
||||||
type LeaderboardSettingsUpdate,
|
type LeaderboardSettingsUpdate,
|
||||||
type ModelConcurrencyUpdate,
|
type ModelConcurrencyUpdate,
|
||||||
|
type MemoryRuntimeSettings,
|
||||||
type PluginManagementUpdate,
|
type PluginManagementUpdate,
|
||||||
type PrivateSquareUpdate,
|
type PrivateSquareUpdate,
|
||||||
type PromptPrefixUpdate,
|
type PromptPrefixUpdate,
|
||||||
@@ -47,6 +52,7 @@ const CITATION_DISPLAY_KEY = ["systemSettings", "citationDisplay"] as const;
|
|||||||
const LEADERBOARD_KEY = ["systemSettings", "leaderboard"] as const;
|
const LEADERBOARD_KEY = ["systemSettings", "leaderboard"] as const;
|
||||||
const SKILL_COMPRESSION_KEY = ["systemSettings", "skillCompression"] as const;
|
const SKILL_COMPRESSION_KEY = ["systemSettings", "skillCompression"] as const;
|
||||||
const MODEL_CONCURRENCY_KEY = ["systemSettings", "modelConcurrency"] 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 PLUGIN_MANAGEMENT_KEY = ["systemSettings", "pluginManagement"] as const;
|
||||||
const STRATEGY_PROJECTS_KEY = ["systemSettings", "strategyProjects"] 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 configured publish squares (发布广场). Readable by any logged-in user —
|
||||||
* the gallery sub-tabs and the publish-flow square picker both need it. */
|
* 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({
|
return useQuery({
|
||||||
queryKey: PUBLISH_SQUARES_KEY,
|
queryKey: PUBLISH_SQUARES_KEY,
|
||||||
queryFn: () => loadPublishSquares(),
|
queryFn: () => loadPublishSquares(),
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
retry: false,
|
retry: false,
|
||||||
|
enabled,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,7 +234,9 @@ export function useLayoutMode(): [LayoutMode, (mode: LayoutMode) => void] {
|
|||||||
const agentModuleKey = (key: string) =>
|
const agentModuleKey = (key: string) =>
|
||||||
["systemSettings", "agentModule", key] as const;
|
["systemSettings", "agentModule", key] as const;
|
||||||
|
|
||||||
export function useAgentModuleConfig(key: string) {
|
export function useAgentModuleConfig(
|
||||||
|
key: string,
|
||||||
|
) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: agentModuleKey(key),
|
queryKey: agentModuleKey(key),
|
||||||
queryFn: () => loadAgentModuleConfig(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) {
|
export function useUpdateAgentModuleConfig(key: string) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Context compression about to run a (blocking) summarization LLM call —
|
// Background compaction stays silent, including events from older backends.
|
||||||
// surface a transient hint so the wait before the answer streams isn't a
|
|
||||||
// silent dead air. Emitted by DeerFlowSummarizationMiddleware.
|
|
||||||
if (
|
if (
|
||||||
typeof event === "object" &&
|
typeof event === "object" &&
|
||||||
event !== null &&
|
event !== null &&
|
||||||
"type" in event &&
|
"type" in event &&
|
||||||
event.type === "context_compacting" &&
|
event.type === "context_compacting"
|
||||||
"message" in event &&
|
|
||||||
typeof event.message === "string" &&
|
|
||||||
event.message.trim()
|
|
||||||
) {
|
) {
|
||||||
const e = event as { type: "context_compacting"; message: string };
|
return;
|
||||||
toast(e.message);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError(error) {
|
onError(error) {
|
||||||
@@ -887,6 +881,19 @@ export function useThreadStream({
|
|||||||
// made every click look like a crash and could loop indefinitely when a
|
// made every click look like a crash and could loop indefinitely when a
|
||||||
// different tab's cookie kept overriding this tab's Bearer token.
|
// different tab's cookie kept overriding this tab's Bearer token.
|
||||||
if (isThreadNotAccessibleError(message)) {
|
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();
|
const userLabel = buildCurrentUserLabel();
|
||||||
toast.info(
|
toast.info(
|
||||||
`当前登录用户为 ${userLabel},原对话不存在或无权访问,已为你打开新对话。`,
|
`当前登录用户为 ${userLabel},原对话不存在或无权访问,已为你打开新对话。`,
|
||||||
@@ -1579,6 +1586,7 @@ export function useThreadStream({
|
|||||||
context: {
|
context: {
|
||||||
...mergedContext,
|
...mergedContext,
|
||||||
thinking_enabled: mergedContext.mode !== "flash",
|
thinking_enabled: mergedContext.mode !== "flash",
|
||||||
|
thinking_force_disabled: mergedContext.mode === "flash",
|
||||||
is_plan_mode:
|
is_plan_mode:
|
||||||
mergedContext.mode === "pro" || mergedContext.mode === "ultra",
|
mergedContext.mode === "pro" || mergedContext.mode === "ultra",
|
||||||
subagent_enabled: mergedContext.mode === "ultra",
|
subagent_enabled: mergedContext.mode === "ultra",
|
||||||
@@ -1591,8 +1599,6 @@ export function useThreadStream({
|
|||||||
: mergedContext.mode === "thinking"
|
: mergedContext.mode === "thinking"
|
||||||
? "low"
|
? "low"
|
||||||
: undefined),
|
: undefined),
|
||||||
memory_injection_enabled:
|
|
||||||
mergedContext.memory_injection_enabled ?? true,
|
|
||||||
rag_mode_enabled: ragModeEnabled,
|
rag_mode_enabled: ragModeEnabled,
|
||||||
summarization_enabled: contextCompressionEnabled,
|
summarization_enabled: contextCompressionEnabled,
|
||||||
thread_id: threadId,
|
thread_id: threadId,
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import assert from "node:assert/strict";
|
|||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
analysisProjectIdOfThread,
|
||||||
|
myProjectConversationInfoOfThread,
|
||||||
myProjectConversationTypeFromSearchParams,
|
myProjectConversationTypeFromSearchParams,
|
||||||
myProjectConversationTypeOfThread,
|
myProjectConversationTypeOfThread,
|
||||||
myProjectThreadMetadata,
|
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", () => {
|
test("metadata wins over the compatibility context fallback", () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
myProjectConversationTypeOfThread({
|
myProjectConversationTypeOfThread({
|
||||||
@@ -44,3 +73,26 @@ test("new-chat route marker round-trips all supported project types", () => {
|
|||||||
assert.equal(myProjectConversationTypeFromSearchParams(params), type);
|
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_SOURCE_KEY = "project_source" as const;
|
||||||
export const MY_PROJECT_THREAD_TYPE_KEY = "project_type" 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_SOURCE_KEY = "projectSource" as const;
|
||||||
export const MY_PROJECT_QUERY_TYPE_KEY = "projectType" 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 MyProjectConversationType = "qa" | "strategy" | "solution";
|
||||||
|
|
||||||
|
export type MyProjectConversationInfo = {
|
||||||
|
type: MyProjectConversationType;
|
||||||
|
projectId: string | null;
|
||||||
|
projectName: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export const MY_PROJECT_CONVERSATION_LABELS: Record<
|
export const MY_PROJECT_CONVERSATION_LABELS: Record<
|
||||||
MyProjectConversationType,
|
MyProjectConversationType,
|
||||||
string
|
string
|
||||||
@@ -30,34 +40,119 @@ const MY_PROJECT_CONVERSATION_TYPES = new Set<MyProjectConversationType>([
|
|||||||
"solution",
|
"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,
|
value: Record<string, unknown> | null | undefined,
|
||||||
): MyProjectConversationType | null {
|
): MyProjectConversationInfo | null {
|
||||||
if (value?.[MY_PROJECT_THREAD_SOURCE_KEY] !== MY_PROJECT_THREAD_SOURCE) {
|
if (value?.[MY_PROJECT_THREAD_SOURCE_KEY] !== MY_PROJECT_THREAD_SOURCE) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const type = value[MY_PROJECT_THREAD_TYPE_KEY];
|
const type = value[MY_PROJECT_THREAD_TYPE_KEY];
|
||||||
return typeof type === "string" &&
|
if (
|
||||||
MY_PROJECT_CONVERSATION_TYPES.has(type as MyProjectConversationType)
|
typeof type !== "string" ||
|
||||||
? (type as MyProjectConversationType)
|
!MY_PROJECT_CONVERSATION_TYPES.has(type as MyProjectConversationType)
|
||||||
: null;
|
) {
|
||||||
|
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(
|
export function myProjectThreadMetadata(
|
||||||
type: MyProjectConversationType,
|
type: MyProjectConversationType,
|
||||||
|
project?: { id?: string | null; name?: string | null },
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
[MY_PROJECT_THREAD_SOURCE_KEY]: MY_PROJECT_THREAD_SOURCE,
|
[MY_PROJECT_THREAD_SOURCE_KEY]: MY_PROJECT_THREAD_SOURCE,
|
||||||
[MY_PROJECT_THREAD_TYPE_KEY]: type,
|
[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: {
|
export function myProjectConversationTypeOfThread(thread: {
|
||||||
metadata?: Record<string, unknown> | null;
|
metadata?: Record<string, unknown> | null;
|
||||||
context?: Record<string, unknown> | null;
|
context?: Record<string, unknown> | null;
|
||||||
}): MyProjectConversationType | 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: {
|
export function myProjectConversationTypeFromSearchParams(params: {
|
||||||
@@ -77,6 +172,7 @@ export function myProjectConversationTypeFromSearchParams(params: {
|
|||||||
export function withMyProjectConversationMarker(
|
export function withMyProjectConversationMarker(
|
||||||
path: string,
|
path: string,
|
||||||
type: MyProjectConversationType,
|
type: MyProjectConversationType,
|
||||||
|
project?: { id?: string | null; name?: string | null },
|
||||||
): string {
|
): string {
|
||||||
const hashIndex = path.indexOf("#");
|
const hashIndex = path.indexOf("#");
|
||||||
const hash = hashIndex >= 0 ? path.slice(hashIndex) : "";
|
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_SOURCE_KEY, MY_PROJECT_THREAD_SOURCE);
|
||||||
params.set(MY_PROJECT_QUERY_TYPE_KEY, type);
|
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();
|
const search = params.toString();
|
||||||
return `${pathname}${search ? `?${search}` : ""}${hash}`;
|
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;
|
thread_id: string;
|
||||||
model_name: string | undefined;
|
model_name: string | undefined;
|
||||||
thinking_enabled: boolean;
|
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;
|
is_plan_mode: boolean;
|
||||||
subagent_enabled: boolean;
|
subagent_enabled: boolean;
|
||||||
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
||||||
writing_mode?: boolean;
|
writing_mode?: boolean;
|
||||||
writing_artifact_path?: string;
|
writing_artifact_path?: string;
|
||||||
memory_injection_enabled?: boolean;
|
|
||||||
rag_mode_enabled?: boolean;
|
rag_mode_enabled?: boolean;
|
||||||
/** Per-request switch for context compression (上下文压缩). Default off. */
|
/** Per-request switch for context compression (上下文压缩). Default off. */
|
||||||
summarization_enabled?: boolean;
|
summarization_enabled?: boolean;
|
||||||
@@ -29,8 +30,8 @@ export interface AgentThreadContext extends Record<string, unknown> {
|
|||||||
llmwiki_knowledge_base_ids?: string[];
|
llmwiki_knowledge_base_ids?: string[];
|
||||||
/** Assistant knowledge-base ids selected for this conversation. */
|
/** Assistant knowledge-base ids selected for this conversation. */
|
||||||
assistant_knowledge_base_ids?: string[];
|
assistant_knowledge_base_ids?: string[];
|
||||||
/** Direct RAG modes handled in the frontend; these requests never enter Lead Agent. */
|
/** Frontend-only execution paths that never enter Lead Agent (RAG / 极速直连). */
|
||||||
execution_profile?: "single_round_rag" | "multi_round_rag";
|
execution_profile?: "single_round_rag" | "multi_round_rag" | "direct";
|
||||||
agent_id?: string;
|
agent_id?: string;
|
||||||
/** @deprecated kept for reading legacy thread metadata; prefer agent_id */
|
/** @deprecated kept for reading legacy thread metadata; prefer agent_id */
|
||||||
agent_name?: string;
|
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);
|
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", () => {
|
test("progress tool calls reveal stages stepwise and mark interruption", () => {
|
||||||
const active = streamOf({
|
const active = streamOf({
|
||||||
jobId: "j6",
|
jobId: "j6",
|
||||||
|
|||||||
@@ -116,6 +116,13 @@ export function clearResearchReportStream(threadId: string) {
|
|||||||
export function finalizeResearchReportStream(threadId: string) {
|
export function finalizeResearchReportStream(threadId: string) {
|
||||||
const store = getStore(threadId);
|
const store = getStore(threadId);
|
||||||
if (store.state?.status !== "active") return;
|
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 ?? "已中断" };
|
store.state = { ...store.state, status: "error", error: store.state.error ?? "已中断" };
|
||||||
emitNow(store);
|
emitNow(store);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,10 +45,17 @@ export function pathOfThread(
|
|||||||
base = "/page/workspace",
|
base = "/page/workspace",
|
||||||
) {
|
) {
|
||||||
const threadId = typeof thread === "string" ? thread : thread.thread_id;
|
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 =
|
const agentRef =
|
||||||
typeof thread === "string"
|
typeof thread === "string"
|
||||||
? pickAgentRef(context, null)
|
? pickAgentRef(context, null)
|
||||||
: agentRefOfThread(thread) ?? pickAgentRef(context, null);
|
: (agentRefOfThread(thread) ?? pickAgentRef(context, null));
|
||||||
|
|
||||||
return agentRef
|
return agentRef
|
||||||
? `${base}/agents/${encodeURIComponent(agentRef)}/chats/${threadId}`
|
? `${base}/agents/${encodeURIComponent(agentRef)}/chats/${threadId}`
|
||||||
@@ -120,7 +127,9 @@ export function isSetupWizardThread(thread: {
|
|||||||
metadata?: Record<string, unknown> | null;
|
metadata?: Record<string, unknown> | null;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
const threadType = thread.metadata?.thread_type;
|
const threadType = thread.metadata?.thread_type;
|
||||||
return threadType === "writing_setup" || threadType === "report_structure_setup";
|
return (
|
||||||
|
threadType === "writing_setup" || threadType === "report_structure_setup"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 侧栏 / 全部对话应隐藏的系统 / 向导会话(bootstrap 除外,仍会展示)。 */
|
/** 侧栏 / 全部对话应隐藏的系统 / 向导会话(bootstrap 除外,仍会展示)。 */
|
||||||
@@ -130,7 +139,10 @@ export function isHiddenFromRecentChats(thread: {
|
|||||||
const metadata = thread.metadata;
|
const metadata = thread.metadata;
|
||||||
if (metadata?.system === true) return true;
|
if (metadata?.system === true) return true;
|
||||||
const threadType = metadata?.thread_type;
|
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 过滤时,不要再套一层「最近对话」隐藏。 */
|
/** 调用方显式按 thread_type / system 过滤时,不要再套一层「最近对话」隐藏。 */
|
||||||
@@ -151,18 +163,28 @@ export function bootstrapResumePath(threadId: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 列表项点击目标:bootstrap 会话回创建页恢复,其余走普通问答路由。 */
|
/** 列表项点击目标: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)
|
return isBootstrapThread(thread)
|
||||||
? bootstrapResumePath(thread.thread_id)
|
? bootstrapResumePath(thread.thread_id)
|
||||||
: pathOfThread(thread);
|
: pathOfThread(thread);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isThreadPinned(thread: AgentThread): boolean {
|
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 {
|
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];
|
return [...pinned, ...rest];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sanitizeThreadTitleForDisplay(title: string | null | undefined) {
|
export function sanitizeThreadTitleForDisplay(
|
||||||
|
title: string | null | undefined,
|
||||||
|
) {
|
||||||
if (!title) return "Untitled";
|
if (!title) return "Untitled";
|
||||||
|
|
||||||
const cleaned = title
|
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", () => {
|
test("a later user turn releases report.md live edits", () => {
|
||||||
const followUp = [
|
const followUp = [
|
||||||
{ type: "human", content: "写一篇论文" },
|
{ type: "human", content: "写一篇论文" },
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
/** Only the writing-mode deliverable counts as a follow-up edit target. */
|
/** 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";
|
const OUTPUTS_PREFIX = "/mnt/user-data/outputs";
|
||||||
|
|
||||||
export function normalizeWritingPath(path: string | null | undefined): string {
|
export function normalizeWritingPath(path: string | null | undefined): string {
|
||||||
@@ -105,6 +107,11 @@ export function shouldAutoOpenWritingArtifact(
|
|||||||
if (!path) {
|
if (!path) {
|
||||||
return false;
|
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) {
|
if (!writingMode) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,12 @@
|
|||||||
|
|
||||||
import { getBackendBaseURL } from "../config";
|
import { getBackendBaseURL } from "../config";
|
||||||
import { apiFetch } from "../api";
|
import { apiFetch } from "../api";
|
||||||
|
import { assertChatUploadFiles } from "./file-validation";
|
||||||
|
import {
|
||||||
|
cloneDefaultUploadPolicy,
|
||||||
|
normalizeUploadPolicy,
|
||||||
|
type UploadPolicy,
|
||||||
|
} from "./policy";
|
||||||
|
|
||||||
export interface UploadedFileInfo {
|
export interface UploadedFileInfo {
|
||||||
filename: string;
|
filename: string;
|
||||||
@@ -34,8 +40,73 @@ async function readErrorDetail(
|
|||||||
response: Response,
|
response: Response,
|
||||||
fallback: string,
|
fallback: string,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const error = await response.json().catch(() => ({ detail: fallback }));
|
const error = (await response
|
||||||
return error.detail ?? fallback;
|
.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,
|
threadId: string,
|
||||||
files: File[],
|
files: File[],
|
||||||
): Promise<UploadResponse> {
|
): 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();
|
const formData = new FormData();
|
||||||
|
|
||||||
files.forEach((file) => {
|
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([
|
const MACOS_APP_BUNDLE_CONTENT_TYPES = new Set([
|
||||||
"",
|
"",
|
||||||
"application/octet-stream",
|
"application/octet-stream",
|
||||||
@@ -32,3 +38,146 @@ export function splitUnsupportedUploadFiles(fileList: File[] | FileList) {
|
|||||||
message: rejected.length > 0 ? MACOS_APP_BUNDLE_UPLOAD_MESSAGE : undefined,
|
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 {
|
import {
|
||||||
deleteUploadedFile,
|
deleteUploadedFile,
|
||||||
|
getUploadPolicy,
|
||||||
listUploadedFiles,
|
listUploadedFiles,
|
||||||
|
resetUploadPolicy,
|
||||||
|
updateUploadPolicy,
|
||||||
uploadFiles,
|
uploadFiles,
|
||||||
type UploadedFileInfo,
|
type UploadedFileInfo,
|
||||||
type UploadResponse,
|
type UploadResponse,
|
||||||
} from "./api";
|
} 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
|
* Hook to upload files
|
||||||
|
|||||||
@@ -5,4 +5,5 @@
|
|||||||
export * from "./api";
|
export * from "./api";
|
||||||
export * from "./file-validation";
|
export * from "./file-validation";
|
||||||
export * from "./hooks";
|
export * from "./hooks";
|
||||||
|
export * from "./policy";
|
||||||
export * from "./prompt-input-files";
|
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();
|
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([
|
const IMAGE_EXTENSIONS = new Set([
|
||||||
"png",
|
"png",
|
||||||
"jpg",
|
"jpg",
|
||||||
|
|||||||
@@ -46,3 +46,26 @@ test("prepareGeneratedHtml injects the ES5 chart size shim and anchor handler",
|
|||||||
assert.match(out, /getElementsByTagName\("canvas"\)/);
|
assert.match(out, /getElementsByTagName\("canvas"\)/);
|
||||||
assert.doesNotMatch(out, /\?\.|=>|`/);
|
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;
|
const MAX_PASSES = 12;
|
||||||
|
|
||||||
export function prepareGeneratedHtml(html: string): string {
|
export type GeneratedHtmlRuntimeAssets = {
|
||||||
return injectInPageAnchorHandler(injectLegacyChartShim(downlevelModernColors(html ?? "")));
|
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 {
|
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}`;
|
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 objectUrl = URL.createObjectURL(blob);
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
a.href = objectUrl;
|
a.href = objectUrl;
|
||||||
|
|||||||
@@ -116,6 +116,14 @@ export interface EightBfTaskProcessReportReceipt {
|
|||||||
id: string;
|
id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 外部任务系统要求 8BF 会商页提交的过程产物携带 eightTask=1,
|
||||||
|
* 用于与岗位会商等其它入口的产物区分来源。
|
||||||
|
*/
|
||||||
|
function processReportBody(input: EightBfTaskProcessReportInput): string {
|
||||||
|
return JSON.stringify({ ...input, eightTask: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 当前 DeerFlow 的外部业务请求适配器沿用任务系统的默认约定:使用
|
* 当前 DeerFlow 的外部业务请求适配器沿用任务系统的默认约定:使用
|
||||||
* `Authorization: Bearer admin`,而不是 DeerFlow 登录接口返回的 token。
|
* `Authorization: Bearer admin`,而不是 DeerFlow 登录接口返回的 token。
|
||||||
@@ -157,7 +165,10 @@ async function externalTaskError(response: Response, fallback: string): Promise<
|
|||||||
}
|
}
|
||||||
|
|
||||||
function findProcessReportId(payload: unknown): string {
|
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 source = payload as Record<string, unknown>;
|
||||||
const direct = firstText(
|
const direct = firstText(
|
||||||
source.id,
|
source.id,
|
||||||
@@ -170,9 +181,10 @@ function findProcessReportId(payload: unknown): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create the external task system's process report in its unsubmitted state.
|
* Create the external task system's process report **and submit it in one
|
||||||
* The local gateway stores the returned id so a refresh can later submit the
|
* call**(submitStatus=1). Response shape is `{code:200, data:<id>, msg}` —
|
||||||
* same report instead of creating a duplicate one.
|
* `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(
|
export async function createEightBfTaskProcessReport(
|
||||||
input: EightBfTaskProcessReportInput,
|
input: EightBfTaskProcessReportInput,
|
||||||
@@ -180,7 +192,7 @@ export async function createEightBfTaskProcessReport(
|
|||||||
const response = await fetchExternalTaskSystem(PROCESS_REPORT_ADD_PATH, {
|
const response = await fetchExternalTaskSystem(PROCESS_REPORT_ADD_PATH, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(input),
|
body: processReportBody(input),
|
||||||
});
|
});
|
||||||
if (!response.ok) throw await externalTaskError(response, "过程产物登记失败");
|
if (!response.ok) throw await externalTaskError(response, "过程产物登记失败");
|
||||||
const id = findProcessReportId(await response.json().catch(() => null));
|
const id = findProcessReportId(await response.json().catch(() => null));
|
||||||
@@ -188,7 +200,7 @@ export async function createEightBfTaskProcessReport(
|
|||||||
return { id };
|
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(
|
export async function updateEightBfTaskProcessReport(
|
||||||
reportId: string,
|
reportId: string,
|
||||||
input: EightBfTaskProcessReportInput,
|
input: EightBfTaskProcessReportInput,
|
||||||
@@ -198,7 +210,7 @@ export async function updateEightBfTaskProcessReport(
|
|||||||
{
|
{
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(input),
|
body: processReportBody(input),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (!response.ok) throw await externalTaskError(response, "过程产物提交失败");
|
if (!response.ok) throw await externalTaskError(response, "过程产物提交失败");
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
} from "@/position-roundtable/lib/positions";
|
} from "@/position-roundtable/lib/positions";
|
||||||
import type { DraftStep3SummarySnapshot } from "@/roundtable-planning/api/drafts";
|
import type { DraftStep3SummarySnapshot } from "@/roundtable-planning/api/drafts";
|
||||||
import { EIGHTBF_INTENT_AGENT_ID } from "@/roundtable-planning/api/intent";
|
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 type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent";
|
||||||
import "@/position-roundtable/styles/intent-understanding.css";
|
import "@/position-roundtable/styles/intent-understanding.css";
|
||||||
import type { Step1PanelProps } from "@/roundtable-planning/components/Step1Panel";
|
import type { Step1PanelProps } from "@/roundtable-planning/components/Step1Panel";
|
||||||
@@ -46,6 +47,8 @@ export interface EightBfStep1PanelProps extends Step1PanelProps {
|
|||||||
chain: BusinessChain | null;
|
chain: BusinessChain | null;
|
||||||
canConfigureChain: boolean;
|
canConfigureChain: boolean;
|
||||||
onConfigureChain: () => void;
|
onConfigureChain: () => void;
|
||||||
|
/** 业务链被本面板更新(产物 position 等)后回调,页面据此刷新链配置。 */
|
||||||
|
onChainUpdated?: (chain: BusinessChain) => void;
|
||||||
selectedModel: string;
|
selectedModel: string;
|
||||||
modelOptions: Array<{ label: string; value: string }>;
|
modelOptions: Array<{ label: string; value: string }>;
|
||||||
onSelectedModelChange: (model: string) => void;
|
onSelectedModelChange: (model: string) => void;
|
||||||
@@ -84,6 +87,7 @@ export function EightBfStep1Panel({
|
|||||||
chain,
|
chain,
|
||||||
canConfigureChain,
|
canConfigureChain,
|
||||||
onConfigureChain,
|
onConfigureChain,
|
||||||
|
onChainUpdated,
|
||||||
selectedModel,
|
selectedModel,
|
||||||
modelOptions,
|
modelOptions,
|
||||||
onSelectedModelChange,
|
onSelectedModelChange,
|
||||||
@@ -153,6 +157,7 @@ export function EightBfStep1Panel({
|
|||||||
chain={chain}
|
chain={chain}
|
||||||
canConfigureChain={canConfigureChain}
|
canConfigureChain={canConfigureChain}
|
||||||
onConfigureChain={onConfigureChain}
|
onConfigureChain={onConfigureChain}
|
||||||
|
onChainUpdated={onChainUpdated}
|
||||||
onCollapse={() => setRightPanelOpen(false)}
|
onCollapse={() => setRightPanelOpen(false)}
|
||||||
step2Dialogues={step2Dialogues}
|
step2Dialogues={step2Dialogues}
|
||||||
step2ThreadIds={step2ThreadIds}
|
step2ThreadIds={step2ThreadIds}
|
||||||
@@ -299,6 +304,7 @@ function EightBfAgentWorkspace({
|
|||||||
chain,
|
chain,
|
||||||
canConfigureChain,
|
canConfigureChain,
|
||||||
onConfigureChain,
|
onConfigureChain,
|
||||||
|
onChainUpdated,
|
||||||
onCollapse,
|
onCollapse,
|
||||||
step2Dialogues,
|
step2Dialogues,
|
||||||
step2ThreadIds,
|
step2ThreadIds,
|
||||||
@@ -310,6 +316,7 @@ function EightBfAgentWorkspace({
|
|||||||
chain: BusinessChain | null;
|
chain: BusinessChain | null;
|
||||||
canConfigureChain: boolean;
|
canConfigureChain: boolean;
|
||||||
onConfigureChain: () => void;
|
onConfigureChain: () => void;
|
||||||
|
onChainUpdated?: (chain: BusinessChain) => void;
|
||||||
onCollapse: () => void;
|
onCollapse: () => void;
|
||||||
step2Dialogues: unknown[];
|
step2Dialogues: unknown[];
|
||||||
step2ThreadIds: ThreadIdMap | null;
|
step2ThreadIds: ThreadIdMap | null;
|
||||||
@@ -448,6 +455,13 @@ function EightBfAgentWorkspace({
|
|||||||
jobResponsibilities={selectedSeat?.job_responsibilities ?? ""}
|
jobResponsibilities={selectedSeat?.job_responsibilities ?? ""}
|
||||||
skillsIcon={<img src={doubleArrowDecoration} alt="" aria-hidden="true" />}
|
skillsIcon={<img src={doubleArrowDecoration} alt="" aria-hidden="true" />}
|
||||||
skillsTitle="技能列表"
|
skillsTitle="技能列表"
|
||||||
|
belowSkillsSlot={chain && selectedSeat ? (
|
||||||
|
<SeatArtifactPositionEditor
|
||||||
|
chain={chain}
|
||||||
|
seat={selectedSeat}
|
||||||
|
onUpdated={onChainUpdated}
|
||||||
|
/>
|
||||||
|
) : undefined}
|
||||||
showCommonQuestions
|
showCommonQuestions
|
||||||
panelTitle="智能体配置"
|
panelTitle="智能体配置"
|
||||||
embedded
|
embedded
|
||||||
@@ -612,3 +626,82 @@ function arrayValue(...values: unknown[]): string[] {
|
|||||||
}
|
}
|
||||||
return [];
|
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),
|
() => buildEightBfTaskContext(task, taskDictionaries),
|
||||||
[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>(
|
const taskArtifactSubmissionConfig = useMemo<TaskArtifactSubmissionConfig | undefined>(
|
||||||
() => taskId ? {
|
() => taskId ? {
|
||||||
taskId,
|
taskId,
|
||||||
position: 0,
|
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,
|
createProcessReport: createEightBfTaskProcessReport,
|
||||||
updateProcessReport: updateEightBfTaskProcessReport,
|
updateProcessReport: updateEightBfTaskProcessReport,
|
||||||
} : undefined,
|
} : undefined,
|
||||||
[taskId],
|
[taskId, seatArtifactPositions],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -199,6 +235,12 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
|
|||||||
if (canConfigureChain) setIsSettingsOpen(true);
|
if (canConfigureChain) setIsSettingsOpen(true);
|
||||||
}, [canConfigureChain]);
|
}, [canConfigureChain]);
|
||||||
|
|
||||||
|
// 「智能体配置」面板里保存产物 position 后,把最新链数据写回页面状态,
|
||||||
|
// 让 seatArtifactPositions / 提交产物取值即时生效(无需刷新页面)。
|
||||||
|
const handleChainUpdated = useCallback((updated: BusinessChain) => {
|
||||||
|
setChains((prev) => prev.map((chain) => (chain.id === updated.id ? updated : chain)));
|
||||||
|
}, []);
|
||||||
|
|
||||||
const toolbarSlot = (
|
const toolbarSlot = (
|
||||||
<>
|
<>
|
||||||
{canConfigureChain && isSettingsOpen && (
|
{canConfigureChain && isSettingsOpen && (
|
||||||
@@ -293,6 +335,7 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
|
|||||||
chain={configuredChain}
|
chain={configuredChain}
|
||||||
canConfigureChain={canConfigureChain}
|
canConfigureChain={canConfigureChain}
|
||||||
onConfigureChain={openChainSettings}
|
onConfigureChain={openChainSettings}
|
||||||
|
onChainUpdated={handleChainUpdated}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
renderStep1LeftSidebar={({ intentReady, onCollapse, submittedSeatKeys, onSeatClick }) => (
|
renderStep1LeftSidebar={({ intentReady, onCollapse, submittedSeatKeys, onSeatClick }) => (
|
||||||
@@ -343,6 +386,8 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
|
|||||||
}
|
}
|
||||||
rootClassName="eightbf-roundtable"
|
rootClassName="eightbf-roundtable"
|
||||||
taskArtifactSubmissionConfig={taskArtifactSubmissionConfig}
|
taskArtifactSubmissionConfig={taskArtifactSubmissionConfig}
|
||||||
|
// 会商「智能体配置」抽屉里只读展示各席位在业务链上配置的产物提交 position。
|
||||||
|
seatArtifactPositions={seatArtifactPositions}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ const MAX_INTENT_LENGTH = 500;
|
|||||||
const MAX_SAMPLE_TEXT_LENGTH = 120000;
|
const MAX_SAMPLE_TEXT_LENGTH = 120000;
|
||||||
|
|
||||||
const MATERIAL_SOURCE_OPTIONS: Array<{ value: MaterialSource; label: string; desc: string }> = [
|
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: 'knowledge_base', label: '知识库检索', desc: '直连内网知识库检索接口' },
|
||||||
{ value: 'notebook', label: '我的空间', desc: '从空间中检索素材' },
|
{ value: 'notebook', label: '我的空间', desc: '从空间中检索素材' },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ interface MaterialConfirmCardProps {
|
|||||||
type MaterialSource = NonNullable<UserInterventionPayload['materialSource']>;
|
type MaterialSource = NonNullable<UserInterventionPayload['materialSource']>;
|
||||||
|
|
||||||
const MATERIAL_SOURCE_OPTIONS: Array<{ value: MaterialSource; label: string; desc: string }> = [
|
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: 'knowledge_base', label: '知识库检索', desc: '直连内网知识库接口' },
|
||||||
{ value: 'notebook', label: '我的空间', desc: '检索当前笔记空间' },
|
{ value: 'notebook', label: '我的空间', desc: '检索当前笔记空间' },
|
||||||
];
|
];
|
||||||
@@ -218,7 +218,7 @@ function MaterialConfirmCard({
|
|||||||
|
|
||||||
async function handleReSearch() {
|
async function handleReSearch() {
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
// 带输入 = 后端在素材收集专家配置的技能中匹配检索、新素材追加到列表;
|
// 带输入 = 后端从全部启用技能中匹配检索、新素材追加到列表;
|
||||||
// 无输入 = 重新生成检索词、覆盖式重检
|
// 无输入 = 重新生成检索词、覆盖式重检
|
||||||
await onSubmit({
|
await onSubmit({
|
||||||
action: 're_search',
|
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_KEY = 'ai-writing';
|
||||||
const AI_WRITING_AGENT_MODULE_TITLE = 'AI 写作智能体';
|
const AI_WRITING_AGENT_MODULE_TITLE = 'AI 写作智能体';
|
||||||
const AI_WRITING_LAST_AGENT_KEY = `agent-module:${AI_WRITING_AGENT_MODULE_KEY}:last-agent`;
|
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)
|
// Match the original canvas page proportions (chat=35, artifact=65)
|
||||||
const DEFAULT_LAYOUT = { [WRITING_ID]: 35, [DRAFT_ID]: 65 };
|
const DEFAULT_LAYOUT = { [WRITING_ID]: 35, [DRAFT_ID]: 65 };
|
||||||
|
|||||||
@@ -257,7 +257,7 @@ export interface UserInterventionPayload {
|
|||||||
approvedMaterialIds?: string[];
|
approvedMaterialIds?: string[];
|
||||||
/** @deprecated 旧版多关键词追加。新版统一用 userQuery 单输入框 */
|
/** @deprecated 旧版多关键词追加。新版统一用 userQuery 单输入框 */
|
||||||
extraKeywords?: string[];
|
extraKeywords?: string[];
|
||||||
/** PAUSE-1 素材确认:用户单输入框写的自然语言(想补充检索的内容)。后端在素材收集专家配置的技能中匹配最相关的技能检索,新素材**追加**到列表。 */
|
/** PAUSE-1 素材确认:用户单输入框写的自然语言(想补充检索的内容)。后端从全部启用技能中匹配最相关的技能检索,新素材**追加**到列表。 */
|
||||||
userQuery?: string;
|
userQuery?: string;
|
||||||
/** PAUSE-1 素材确认:本次补充/重新检索使用的检索方式。 */
|
/** PAUSE-1 素材确认:本次补充/重新检索使用的检索方式。 */
|
||||||
materialSource?: "general" | "knowledge_base" | "notebook";
|
materialSource?: "general" | "knowledge_base" | "notebook";
|
||||||
@@ -298,7 +298,7 @@ export interface AIWritingRequest {
|
|||||||
writingMode: "strict" | "loose";
|
writingMode: "strict" | "loose";
|
||||||
/** 是否在成稿里加参考文献(正文 [n] 角标 + 文末「参考文献」列表,像论文)。默认 true。 */
|
/** 是否在成稿里加参考文献(正文 [n] 角标 + 文末「参考文献」列表,像论文)。默认 true。 */
|
||||||
addReferences?: boolean;
|
addReferences?: boolean;
|
||||||
/** 检索类型:general=通用检索(素材收集专家按配置技能检索,默认知识库检索技能)/
|
/** 检索类型:general=通用检索(素材收集专家从全部启用技能自由挑选检索,内置知识库检索保底)/
|
||||||
* knowledge_base=知识库检索(直连内网知识库 ES 接口,不走技能编排)/ notebook=我的空间 */
|
* knowledge_base=知识库检索(直连内网知识库 ES 接口,不走技能编排)/ notebook=我的空间 */
|
||||||
materialSource: "general" | "knowledge_base" | "notebook";
|
materialSource: "general" | "knowledge_base" | "notebook";
|
||||||
/** 不收集素材,直接用通用知识写作:勾选后跳过整个素材检索流程(含素材确认暂停点),
|
/** 不收集素材,直接用通用知识写作:勾选后跳过整个素材检索流程(含素材确认暂停点),
|
||||||
|
|||||||
@@ -49,7 +49,8 @@ export default function AdminSkillsPage() {
|
|||||||
const currentUserId = storedAuth?.user_id ?? null;
|
const currentUserId = storedAuth?.user_id ?? null;
|
||||||
|
|
||||||
// Hide archived/stale entries here — they remain accessible from the regular
|
// 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 filteredSkills = useMemo(() => {
|
||||||
const needle = search.trim().toLowerCase();
|
const needle = search.trim().toLowerCase();
|
||||||
return skills
|
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">
|
<div className="flex items-start justify-between gap-4 border-b px-6 py-4">
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
管理员视图:查看、编辑、删除所有用户的技能(包括内置技能与未发布的自定义技能)。
|
管理员视图:查看、编辑、上线/下线或删除所有用户的技能(包括内置技能与未发布的自定义技能)。
|
||||||
</p>
|
</p>
|
||||||
<div className="relative w-64 shrink-0">
|
<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" />
|
<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}
|
skill={skill}
|
||||||
currentUserId={currentUserId}
|
currentUserId={currentUserId}
|
||||||
isAdmin
|
isAdmin
|
||||||
|
availabilityControl="admin"
|
||||||
knowledgeBindings={(knowledgeBindings.data ?? []).filter(
|
knowledgeBindings={(knowledgeBindings.data ?? []).filter(
|
||||||
(binding) => binding.skill_name === skill.name,
|
(binding) => binding.skill_name === skill.name,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1206,7 +1206,7 @@ export default function AdminUserLeaderboardPage() {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
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"
|
className="h-9 w-9 px-0"
|
||||||
title="排行榜设置"
|
title="排行榜设置"
|
||||||
aria-label="排行榜设置"
|
aria-label="排行榜设置"
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export default function AdminUserLeaderboardSettingsPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<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" />
|
<ArrowLeft className="mr-1 size-4" />
|
||||||
返回排行榜
|
返回排行榜
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -13,11 +13,14 @@ import { useLocation } from "react-router-dom";
|
|||||||
import { useRouteBase } from "@/core/route-base";
|
import { useRouteBase } from "@/core/route-base";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { AgentChatSidebar } from "@/components/workspace/agents/agent-chat-sidebar";
|
import { AgentChatSidebar } from "@/components/workspace/agents/agent-chat-sidebar";
|
||||||
import { AgentDetailPanel } from "@/components/workspace/agents/agent-detail-panel";
|
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 { AgentQuestionChips } from "@/components/workspace/agents/agent-question-chips";
|
||||||
import {
|
import {
|
||||||
AgentGuide,
|
AgentGuide,
|
||||||
@@ -40,14 +43,17 @@ import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicato
|
|||||||
import { Tooltip } from "@/components/workspace/tooltip";
|
import { Tooltip } from "@/components/workspace/tooltip";
|
||||||
import { normalizeAgentQuestions, useAgent } from "@/core/agents";
|
import { normalizeAgentQuestions, useAgent } from "@/core/agents";
|
||||||
import { getAPIClient } from "@/core/api";
|
import { getAPIClient } from "@/core/api";
|
||||||
|
import { getStoredAuth } from "@/core/auth";
|
||||||
import { useTheme } from "@/contexts/ThemeContext";
|
import { useTheme } from "@/contexts/ThemeContext";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import { queueLlmWikiConversationDeposit } from "@/core/llmwiki/conversation-deposit";
|
import { queueLlmWikiConversationDeposit } from "@/core/llmwiki/conversation-deposit";
|
||||||
|
import { createKnowledgeWikiReferenceSelection } from "@/core/llmwiki/knowledge-reference";
|
||||||
import { useModels } from "@/core/models/hooks";
|
import { useModels } from "@/core/models/hooks";
|
||||||
import { useNotification } from "@/core/notification/hooks";
|
import { useNotification } from "@/core/notification/hooks";
|
||||||
import { saveLastAgentId, useThreadSettings } from "@/core/settings";
|
import { saveLastAgentId, useThreadSettings } from "@/core/settings";
|
||||||
import { stopActiveThreadBeforeClarificationReply, stopThreadRun, useThreadStream } from "@/core/threads/hooks";
|
import { stopActiveThreadBeforeClarificationReply, stopThreadRun, useThreadStream } from "@/core/threads/hooks";
|
||||||
import { useResearchWritingFeed } from "@/core/threads/use-research-writing-feed";
|
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 { clearResearchReportStream } from "@/core/threads/use-research-report-stream";
|
||||||
import { textOfMessage } from "@/core/threads/utils";
|
import { textOfMessage } from "@/core/threads/utils";
|
||||||
import { resolveWritingModeReportPath } from "@/core/threads/writing-mode";
|
import { resolveWritingModeReportPath } from "@/core/threads/writing-mode";
|
||||||
@@ -260,7 +266,9 @@ export default function AgentChatPage({
|
|||||||
const routeBase = useRouteBase();
|
const routeBase = useRouteBase();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { baseMode } = useTheme();
|
const { baseMode } = useTheme();
|
||||||
|
const mayViewAgentFeedbackScore = canViewAgentFeedbackScore(getStoredAuth());
|
||||||
const [showFollowups, setShowFollowups] = useState(false);
|
const [showFollowups, setShowFollowups] = useState(false);
|
||||||
|
const [isConversationAtBottom, setIsConversationAtBottom] = useState(true);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
// 独立智能体页的左列是 AgentChatSidebar,而不是外层 WorkspaceSidebar;
|
// 独立智能体页的左列是 AgentChatSidebar,而不是外层 WorkspaceSidebar;
|
||||||
// 由本页维护其收展状态,避免顶部按钮切到了不可见的另一层侧栏。
|
// 由本页维护其收展状态,避免顶部按钮切到了不可见的另一层侧栏。
|
||||||
@@ -373,6 +381,7 @@ export default function AgentChatPage({
|
|||||||
const [selectedAssistantKnowledgeBaseIds, setSelectedAssistantKnowledgeBaseIds] = useState<string[]>(
|
const [selectedAssistantKnowledgeBaseIds, setSelectedAssistantKnowledgeBaseIds] = useState<string[]>(
|
||||||
() => pendingAssistantKnowledgeSelection.current ?? [],
|
() => pendingAssistantKnowledgeSelection.current ?? [],
|
||||||
);
|
);
|
||||||
|
const [knowledgeSuggestedQuestions, setKnowledgeSuggestedQuestions] = useState<string[]>([]);
|
||||||
const [knowledgeSelectionReady, setKnowledgeSelectionReady] = useState(
|
const [knowledgeSelectionReady, setKnowledgeSelectionReady] = useState(
|
||||||
() => pendingKnowledgeSelection.current !== null || pendingAssistantKnowledgeSelection.current !== null,
|
() => pendingKnowledgeSelection.current !== null || pendingAssistantKnowledgeSelection.current !== null,
|
||||||
);
|
);
|
||||||
@@ -462,6 +471,31 @@ export default function AgentChatPage({
|
|||||||
});
|
});
|
||||||
}, [isNewThread, threadId]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (isNewThread) {
|
if (isNewThread) {
|
||||||
setHasStartedConversation(hasPendingForThisAgent());
|
setHasStartedConversation(hasPendingForThisAgent());
|
||||||
@@ -560,9 +594,26 @@ export default function AgentChatPage({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const directChat = useDirectChatMessages(threadId);
|
||||||
|
const directChatSelected =
|
||||||
|
isDirectChatProfile(settings.context) && isTurboModeVisible();
|
||||||
|
|
||||||
// 写作模式深度研究进度:注入 displayThread 尾部(进度步骤条 + report.md
|
// 写作模式深度研究进度:注入 displayThread 尾部(进度步骤条 + report.md
|
||||||
// 流式 stub),成功且真实产物落地后自动撤出。
|
// 流式 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「入库」协助卡 ──────────────────────────────────────────────────
|
// ── agentfx「入库」协助卡 ──────────────────────────────────────────────────
|
||||||
// 任务工作区(taskBind)里,内置研判智能体写出的 task-reports.json 产物落地且
|
// 任务工作区(taskBind)里,内置研判智能体写出的 task-reports.json 产物落地且
|
||||||
@@ -743,6 +794,63 @@ export default function AgentChatPage({
|
|||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
(message: PromptInputMessage, extraContext?: Record<string, unknown>) => {
|
(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);
|
setHasStartedConversation(true);
|
||||||
// Remember this agent so the new-chat landing screen pre-selects it next
|
// Remember this agent so the new-chat landing screen pre-selects it next
|
||||||
// time (covers both direct agent chats and the ChatPage handoff replay).
|
// time (covers both direct agent chats and the ChatPage handoff replay).
|
||||||
@@ -767,7 +875,24 @@ export default function AgentChatPage({
|
|||||||
...pageReportThemeContext,
|
...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(老对话要停止)。
|
// 任务流程:切换 / 新建会话时,停掉「上一会话服务端在跑」的 run(老对话要停止)。
|
||||||
@@ -801,6 +926,7 @@ export default function AgentChatPage({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pendingMessageHandled.current) return;
|
if (pendingMessageHandled.current) return;
|
||||||
if (!agent_id) return;
|
if (!agent_id) return;
|
||||||
|
if (taskCtxOn && isNewThread && !knowledgeSelectionReady) return;
|
||||||
let pending: { agentId?: string; threadId?: string; text?: string } | null = null;
|
let pending: { agentId?: string; threadId?: string; text?: string } | null = null;
|
||||||
try {
|
try {
|
||||||
const raw = sessionStorage.getItem("pendingAgentMessage");
|
const raw = sessionStorage.getItem("pendingAgentMessage");
|
||||||
@@ -817,13 +943,15 @@ export default function AgentChatPage({
|
|||||||
pendingMessageHandled.current = true;
|
pendingMessageHandled.current = true;
|
||||||
sessionStorage.removeItem("pendingAgentMessage");
|
sessionStorage.removeItem("pendingAgentMessage");
|
||||||
handleSubmit({ text: pending.text, files: [] });
|
handleSubmit({ text: pending.text, files: [] });
|
||||||
}, [agent_id, threadId, isNewThread, handleSubmit]);
|
}, [agent_id, threadId, isNewThread, handleSubmit, knowledgeSelectionReady, taskCtxOn]);
|
||||||
|
|
||||||
// 纯绑定任务深链(3qfx/xdfx):用户主动「新建对话」进 landing 后,由 header 右上角「开始回答」
|
// 纯绑定任务深链(3qfx/xdfx):用户主动「新建对话」进 landing 后,由 header 右上角「开始回答」
|
||||||
// 按钮手动触发发出开场信息(见下方 header)。手动场景不自动发,避免新建对话竞态导致的重复发送。
|
// 按钮手动触发发出开场信息(见下方 header)。手动场景不自动发,避免新建对话竞态导致的重复发送。
|
||||||
const handleTaskStart = useCallback(() => {
|
const handleTaskStart = useCallback(() => {
|
||||||
if (taskStartText) handleSubmit({ text: taskStartText, files: [] });
|
if (knowledgeSelectionReady && taskStartText) {
|
||||||
}, [handleSubmit, taskStartText]);
|
handleSubmit({ text: taskStartText, files: [] });
|
||||||
|
}
|
||||||
|
}, [handleSubmit, knowledgeSelectionReady, taskStartText]);
|
||||||
|
|
||||||
// 首次进入且该任务无历史(taskAutoStart):3qfx/xdfx 直接自动发出开场信息,无需手动点「开始回答」。
|
// 首次进入且该任务无历史(taskAutoStart):3qfx/xdfx 直接自动发出开场信息,无需手动点「开始回答」。
|
||||||
// autoStartedRef 一次性、不随 threadId 抖动重置,故只发一次(首次进入是干净 mount,无上一会话之忧)。
|
// autoStartedRef 一次性、不随 threadId 抖动重置,故只发一次(首次进入是干净 mount,无上一会话之忧)。
|
||||||
@@ -832,10 +960,11 @@ export default function AgentChatPage({
|
|||||||
if (!taskAutoStart) return;
|
if (!taskAutoStart) return;
|
||||||
if (!taskStartText) return;
|
if (!taskStartText) return;
|
||||||
if (!isNewThread) return;
|
if (!isNewThread) return;
|
||||||
|
if (!knowledgeSelectionReady) return;
|
||||||
if (autoStartedRef.current) return;
|
if (autoStartedRef.current) return;
|
||||||
autoStartedRef.current = true;
|
autoStartedRef.current = true;
|
||||||
handleSubmit({ text: taskStartText, files: [] });
|
handleSubmit({ text: taskStartText, files: [] });
|
||||||
}, [taskAutoStart, taskStartText, isNewThread, handleSubmit]);
|
}, [taskAutoStart, taskStartText, isNewThread, knowledgeSelectionReady, handleSubmit]);
|
||||||
|
|
||||||
const handleClarificationSubmit = useCallback(
|
const handleClarificationSubmit = useCallback(
|
||||||
async (answer: string) => {
|
async (answer: string) => {
|
||||||
@@ -852,8 +981,12 @@ export default function AgentChatPage({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleStop = useCallback(async () => {
|
const handleStop = useCallback(async () => {
|
||||||
|
if (directChat.isLoading) {
|
||||||
|
directChat.stop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
await thread.stop();
|
await thread.stop();
|
||||||
}, [thread]);
|
}, [directChat, thread]);
|
||||||
|
|
||||||
const isLandingState = isNewThread && !hasStartedConversation;
|
const isLandingState = isNewThread && !hasStartedConversation;
|
||||||
// 任务闸门面板:仅 rwfx(taskGate)在新建对话且尚未开始时,于问答页顶部展示目的树 + 覆盖确认。
|
// 任务闸门面板:仅 rwfx(taskGate)在新建对话且尚未开始时,于问答页顶部展示目的树 + 覆盖确认。
|
||||||
@@ -867,16 +1000,33 @@ export default function AgentChatPage({
|
|||||||
|
|
||||||
const status = thread.error
|
const status = thread.error
|
||||||
? "error"
|
? "error"
|
||||||
: thread.isLoading
|
: thread.isLoading || directChat.isLoading
|
||||||
? "streaming"
|
? "streaming"
|
||||||
: "ready";
|
: "ready";
|
||||||
|
const knowledgeReferences = useMemo(
|
||||||
|
() =>
|
||||||
|
knowledgeSelectionReady
|
||||||
|
? createKnowledgeWikiReferenceSelection(
|
||||||
|
selectedKnowledgeBaseIds,
|
||||||
|
selectedAssistantKnowledgeBaseIds,
|
||||||
|
)
|
||||||
|
: undefined,
|
||||||
|
[
|
||||||
|
knowledgeSelectionReady,
|
||||||
|
selectedAssistantKnowledgeBaseIds,
|
||||||
|
selectedKnowledgeBaseIds,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThreadContext.Provider
|
<ThreadContext.Provider
|
||||||
value={{
|
value={{
|
||||||
thread: displayThread,
|
thread: displayThread,
|
||||||
knowledgeReferencesEnabled:
|
knowledgeReferencesEnabled:
|
||||||
knowledgeSelectionReady && selectedKnowledgeBaseIds.length > 0,
|
knowledgeSelectionReady &&
|
||||||
|
(selectedKnowledgeBaseIds.length > 0 ||
|
||||||
|
selectedAssistantKnowledgeBaseIds.length > 0),
|
||||||
|
knowledgeReferences,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Root: fixed-height horizontal split. ChatBox (chat + artifact
|
{/* Root: fixed-height horizontal split. ChatBox (chat + artifact
|
||||||
@@ -994,7 +1144,7 @@ export default function AgentChatPage({
|
|||||||
size="sm"
|
size="sm"
|
||||||
className="mr-1 bg-[var(--color-main)] text-white shadow-sm hover:bg-[var(--brand-950)]"
|
className="mr-1 bg-[var(--color-main)] text-white shadow-sm hover:bg-[var(--brand-950)]"
|
||||||
onClick={handleTaskStart}
|
onClick={handleTaskStart}
|
||||||
disabled={isDemoMode}
|
disabled={isDemoMode || !knowledgeSelectionReady}
|
||||||
>
|
>
|
||||||
开始回答
|
开始回答
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1029,7 +1179,7 @@ export default function AgentChatPage({
|
|||||||
// 非嵌入态:走该智能体的独立新对话页(按当前路由 base)。
|
// 非嵌入态:走该智能体的独立新对话页(按当前路由 base)。
|
||||||
router.push(
|
router.push(
|
||||||
isEmbedded
|
isEmbedded
|
||||||
? `/page/workspace/chats/new?agent=${agent_id}`
|
? `${routeBase}/chats/new?agent=${agent_id}`
|
||||||
: `${agentChatsBase}/new`,
|
: `${agentChatsBase}/new`,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
@@ -1042,7 +1192,7 @@ export default function AgentChatPage({
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{showTaskGate && (
|
{showTaskGate && knowledgeSelectionReady && (
|
||||||
<TaskPurposePanel
|
<TaskPurposePanel
|
||||||
taskId={taskId}
|
taskId={taskId}
|
||||||
onStart={handleGateStart}
|
onStart={handleGateStart}
|
||||||
@@ -1067,7 +1217,7 @@ export default function AgentChatPage({
|
|||||||
<div data-agent-guide="chat-questions" className="mb-3">
|
<div data-agent-guide="chat-questions" className="mb-3">
|
||||||
<AgentQuestionChips
|
<AgentQuestionChips
|
||||||
questions={commonQuestions}
|
questions={commonQuestions}
|
||||||
disabled={isDemoMode}
|
disabled={isDemoMode || (taskCtxOn && !knowledgeSelectionReady)}
|
||||||
onAsk={(text) => handleSubmit({ text, files: [] })}
|
onAsk={(text) => handleSubmit({ text, files: [] })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1079,12 +1229,18 @@ export default function AgentChatPage({
|
|||||||
className="w-full"
|
className="w-full"
|
||||||
isNewThread
|
isNewThread
|
||||||
hideAdminRecommendedQuestions
|
hideAdminRecommendedQuestions
|
||||||
|
knowledgeSuggestedQuestions={knowledgeSuggestedQuestions}
|
||||||
threadId={threadId}
|
threadId={threadId}
|
||||||
assistantId={agent_id}
|
assistantId={agent_id}
|
||||||
autoFocus
|
autoFocus
|
||||||
status={status}
|
status={status}
|
||||||
context={settings.context}
|
context={settings.context}
|
||||||
disabled={isDemoMode || knowledgeSelectionLoading}
|
disabled={
|
||||||
|
isDemoMode ||
|
||||||
|
knowledgeSelectionLoading ||
|
||||||
|
(taskCtxOn && !knowledgeSelectionReady) ||
|
||||||
|
directChat.isLoading
|
||||||
|
}
|
||||||
onContextChange={(context) =>
|
onContextChange={(context) =>
|
||||||
setSettings("context", context)
|
setSettings("context", context)
|
||||||
}
|
}
|
||||||
@@ -1099,17 +1255,18 @@ export default function AgentChatPage({
|
|||||||
assistantValue={selectedAssistantKnowledgeBaseIds}
|
assistantValue={selectedAssistantKnowledgeBaseIds}
|
||||||
onChange={changeKnowledgeSelection}
|
onChange={changeKnowledgeSelection}
|
||||||
onAssistantChange={changeAssistantKnowledgeSelection}
|
onAssistantChange={changeAssistantKnowledgeSelection}
|
||||||
|
onSuggestedQuestionsChange={setKnowledgeSuggestedQuestions}
|
||||||
autoSelectAll={
|
autoSelectAll={
|
||||||
isNewThread &&
|
isNewThread &&
|
||||||
pendingKnowledgeSelection.current === null &&
|
pendingKnowledgeSelection.current === null &&
|
||||||
pendingAssistantKnowledgeSelection.current === null
|
pendingAssistantKnowledgeSelection.current === null
|
||||||
}
|
}
|
||||||
disabled={isDemoMode || knowledgeSelectionLoading}
|
disabled={isDemoMode || knowledgeSelectionLoading || directChat.isLoading}
|
||||||
label="知识空间"
|
label="知识空间"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
onFollowupsVisibilityChange={setShowFollowups}
|
onFollowupsPresenceChange={setShowFollowups}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
onStop={handleStop}
|
onStop={handleStop}
|
||||||
/>
|
/>
|
||||||
@@ -1139,6 +1296,8 @@ export default function AgentChatPage({
|
|||||||
tokenUsageEnabled={tokenUsageEnabled}
|
tokenUsageEnabled={tokenUsageEnabled}
|
||||||
// 输入框在流内(MessageList 下方),按钮贴对话区底部即可。
|
// 输入框在流内(MessageList 下方),按钮贴对话区底部即可。
|
||||||
scrollButtonClassName="bottom-2"
|
scrollButtonClassName="bottom-2"
|
||||||
|
onAtBottomChange={setIsConversationAtBottom}
|
||||||
|
answerFeedbackEnabled
|
||||||
onClarificationSubmit={handleClarificationSubmit}
|
onClarificationSubmit={handleClarificationSubmit}
|
||||||
onEditResubmit={(text) => handleSubmit({ text, files: [] })}
|
onEditResubmit={(text) => handleSubmit({ text, files: [] })}
|
||||||
// agentfx「入库」协助卡:消息流尾部注入的虚拟消息经此插槽渲染。
|
// agentfx「入库」协助卡:消息流尾部注入的虚拟消息经此插槽渲染。
|
||||||
@@ -1175,7 +1334,7 @@ export default function AgentChatPage({
|
|||||||
assistantId={agent_id}
|
assistantId={agent_id}
|
||||||
status={status}
|
status={status}
|
||||||
context={settings.context}
|
context={settings.context}
|
||||||
disabled={isDemoMode || knowledgeSelectionLoading}
|
disabled={isDemoMode || knowledgeSelectionLoading || directChat.isLoading}
|
||||||
onContextChange={(context) =>
|
onContextChange={(context) =>
|
||||||
setSettings("context", context)
|
setSettings("context", context)
|
||||||
}
|
}
|
||||||
@@ -1190,13 +1349,14 @@ export default function AgentChatPage({
|
|||||||
assistantValue={selectedAssistantKnowledgeBaseIds}
|
assistantValue={selectedAssistantKnowledgeBaseIds}
|
||||||
onChange={changeKnowledgeSelection}
|
onChange={changeKnowledgeSelection}
|
||||||
onAssistantChange={changeAssistantKnowledgeSelection}
|
onAssistantChange={changeAssistantKnowledgeSelection}
|
||||||
disabled={isDemoMode || knowledgeSelectionLoading}
|
disabled={isDemoMode || knowledgeSelectionLoading || directChat.isLoading}
|
||||||
readOnly
|
readOnly
|
||||||
label="知识空间"
|
label="知识空间"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
onFollowupsVisibilityChange={setShowFollowups}
|
followupsDisplayAllowed={isConversationAtBottom}
|
||||||
|
onFollowupsPresenceChange={setShowFollowups}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
onStop={handleStop}
|
onStop={handleStop}
|
||||||
/>
|
/>
|
||||||
@@ -1227,6 +1387,11 @@ export default function AgentChatPage({
|
|||||||
onCollapsedChange={setDetailCollapsed}
|
onCollapsedChange={setDetailCollapsed}
|
||||||
showRecentChats={!taskCtxOn}
|
showRecentChats={!taskCtxOn}
|
||||||
compactAppearanceActions={compactAppearanceActions}
|
compactAppearanceActions={compactAppearanceActions}
|
||||||
|
feedbackScoreSlot={
|
||||||
|
mayViewAgentFeedbackScore ? (
|
||||||
|
<AgentFeedbackScoreCard agentId={agent_id} />
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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";
|
import { AgentGallery } from "@/components/workspace/agents/agent-gallery";
|
||||||
|
|
||||||
export default function AgentsPage() {
|
export default function AgentsPage({
|
||||||
return <AgentGallery />;
|
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