Merge branch 'master-nj' into 'master'

增强 Deep Research 可靠性,并接入 8BF 任务卡片与会商上下文。

See merge request knowledge-base-bj/deerflow-code!25
This commit is contained in:
董宏凯 committed 2026-08-18 09:55:17 +00:00
commit 33b0af333c
81 files changed
+8436 -1076

No files matched your search

@@ -19,8 +19,9 @@ import { useQueryClient } from "@tanstack/react-query";
import { useParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react";
import { toast } from "sonner";
import { Streamdown } from "streamdown";
import { toast } from "sonner";
import { defaultUrlTransform } from "react-markdown";
import { Streamdown } from "streamdown";
import {
Artifact,
@@ -42,6 +43,7 @@ import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { CodeEditor } from "@/components/workspace/code-editor";
import { MarkdownAiEditor } from "@/open-canvas/components/artifact/MarkdownAiEditor";
import { useDocumentRewrite } from "@/open-canvas/hooks/useDocumentRewrite";
import { useCapabilities as useResearchCapabilities } from "@/strategy-components/api/deep-research";
import {
listDurableDocumentRewrites,
streamDurableDocumentRewriteJob,
@@ -149,9 +151,11 @@ export function ArtifactFileDetail({
onClose,
allowVirtualMarkdownEdit = false,
disableDesensitize = false,
hideFileName = false,
onDownloadClick,
}: {
hideFileName = false,
onDownloadClick,
toolbarActions,
researchSessionId,
}: {
className?: string;
filepath: string;
threadId: string;
@@ -177,8 +181,15 @@ export function ArtifactFileDetail({
* (深度研究沙箱用它弹「Markdown / Word」格式选择弹窗),并隐藏工具栏中重复的
* 「另存为 Word」。默认不传 —— 其它调用方行为不变。
*/
onDownloadClick?: () => void;
}) {
onDownloadClick?: () => void;
/** Deep-research reports use protected `deep-research://` image references. */
researchSessionId?: string;
/**
* 由宿主注入到右上角 ArtifactActions 图标组的动作。深度研究报告使用它
* 放置全文改写入口,避免再在沙箱内容区叠一个文字按钮。
*/
toolbarActions?: ReactNode;
}) {
const { t } = useI18n();
const { artifacts, setOpen, select } = useArtifacts();
const { isMock, thread, publicShareCode } = useThread();
@@ -291,13 +302,20 @@ export function ArtifactFileDetail({
const updateArtifact = useUpdateArtifactContent();
const queryClient = useQueryClient();
const documentRewrite = useDocumentRewrite();
const imageCapabilities = useResearchCapabilities();
const rewritePresentation = useDocumentRewritePresentation();
const hydratedRewriteScopeRef = useRef<string | null>(null);
const mirroredRequirementsPlanRef = useRef<{ presentationId: string | null; plan: string }>({
presentationId: null,
plan: "",
});
const mirroredThinkingTextRef = useRef<{ presentationId: string | null; thinking: string }>({
presentationId: null,
thinking: "",
});
const documentRewriteActive = documentRewrite.isActive;
const imageGenerationAvailable = imageCapabilities.data?.imageGeneration?.available === true;
const maxGeneratedImages = imageCapabilities.data?.imageGeneration?.maxImages ?? 4;
const canUseFullDocumentRewrite = canEditArtifact && !hasLocalEdits;
const displayContent = documentRewriteActive
? documentRewrite.run.draft
@@ -405,6 +423,29 @@ export function ArtifactFileDetail({
writtenCharCount: event.charCount,
});
break;
case "illustration_started":
rewritePresentation.update(presentationId, {
stage: "illustrating",
illustrationTarget: event.section,
maxGeneratedImages: event.total || 1,
});
break;
case "illustration_generated":
rewritePresentation.update(presentationId, {
stage: "illustrating",
illustrationTarget: event.section,
generatedImageCount: event.generatedCount ?? event.position,
maxGeneratedImages: event.total || 1,
});
break;
case "illustration_completed":
rewritePresentation.update(presentationId, {
stage: "illustrating",
generatedImageCount: event.generatedCount,
maxGeneratedImages: event.requestedCount || 1,
warnings: event.warnings,
});
break;
case "validation_started":
rewritePresentation.update(presentationId, { stage: "validation" });
break;
@@ -463,6 +504,8 @@ export function ArtifactFileDetail({
instruction: historical.request.instruction,
style: historical.request.style,
modelName: historical.request.modelName,
generateImages: historical.request.generateImages,
maxGeneratedImages: historical.request.maxGeneratedImages,
originalContent: historical.request.originalContent,
rewrittenContent: typeof historicalResult.content === "string" ? historicalResult.content : null,
requirementsPlan: typeof historicalResult.requirementsPlan === "string"
@@ -497,6 +540,8 @@ export function ArtifactFileDetail({
instruction: latest.request.instruction,
style: latest.request.style,
modelName: latest.request.modelName,
generateImages: latest.request.generateImages,
maxGeneratedImages: latest.request.maxGeneratedImages,
originalContent,
rewrittenContent,
requirementsPlan: typeof result.requirementsPlan === "string" ? result.requirementsPlan : "",
@@ -537,6 +582,8 @@ export function ArtifactFileDetail({
instruction: latest.request.instruction,
style: latest.request.style,
modelName: latest.request.modelName ?? undefined,
generateImages: latest.request.generateImages,
maxGeneratedImages: latest.request.maxGeneratedImages,
},
{
jobId: latest.id,
@@ -605,6 +652,19 @@ export function ArtifactFileDetail({
rewritePresentation,
]);
// Thoughts use the same rAF coalescing as the draft, then are mirrored once
// per browser frame into the regular message-list card. They never touch
// the artifact Markdown or the durable rewrite checkpoint.
useEffect(() => {
const presentationId = documentRewritePresentationId;
const thinking = documentRewrite.run.thinkingText;
if (!presentationId || !rewritePresentation || !thinking) return;
const previous = mirroredThinkingTextRef.current;
if (previous.presentationId === presentationId && previous.thinking === thinking) return;
mirroredThinkingTextRef.current = { presentationId, thinking };
rewritePresentation.update(presentationId, { thinkingText: thinking });
}, [documentRewrite.run.thinkingText, documentRewritePresentationId, rewritePresentation]);
useEffect(() => {
const handleRewriteUndo = (event: Event) => {
const detail = (event as CustomEvent<{
@@ -851,6 +911,8 @@ export function ArtifactFileDetail({
instruction: string;
style: "formal_analysis" | "professional" | "concise" | "readable";
modelName: string | null;
generateImages: boolean;
maxGeneratedImages: number;
}) => {
if (!canUseFullDocumentRewrite) {
toast.error(
@@ -872,6 +934,8 @@ export function ArtifactFileDetail({
instruction: input.instruction,
style: input.style,
modelName: input.modelName,
generateImages: input.generateImages,
maxGeneratedImages: input.maxGeneratedImages,
originalContent: draftContent,
});
setDocumentRewritePresentationId(presentationId ?? null);
@@ -882,6 +946,8 @@ export function ArtifactFileDetail({
instruction: input.instruction,
style: input.style,
modelName: input.modelName ?? undefined,
generateImages: input.generateImages,
maxGeneratedImages: input.maxGeneratedImages,
expectedHash,
},
{
@@ -921,6 +987,29 @@ export function ArtifactFileDetail({
writtenCharCount: event.charCount,
});
break;
case "illustration_started":
rewritePresentation.update(presentationId, {
stage: "illustrating",
illustrationTarget: event.section,
maxGeneratedImages: event.total || 1,
});
break;
case "illustration_generated":
rewritePresentation.update(presentationId, {
stage: "illustrating",
illustrationTarget: event.section,
generatedImageCount: event.generatedCount ?? event.position,
maxGeneratedImages: event.total || 1,
});
break;
case "illustration_completed":
rewritePresentation.update(presentationId, {
stage: "illustrating",
generatedImageCount: event.generatedCount,
maxGeneratedImages: event.requestedCount || 1,
warnings: event.warnings,
});
break;
case "validation_started":
rewritePresentation.update(presentationId, { stage: "validation" });
break;
@@ -1217,6 +1306,7 @@ export function ArtifactFileDetail({
onClick={handleSaveArtifact}
/>
)}
{toolbarActions}
{canEditArtifact && (
<ArtifactAction
icon={documentRewriteActive ? LoaderIcon : SparklesIcon}
@@ -1394,9 +1484,10 @@ export function ArtifactFileDetail({
<ArtifactFilePreview
content={disableDesensitize ? (displayContent ?? "") : desensitize(displayContent ?? "")}
language={language ?? "text"}
threadId={threadId}
publicShareCode={publicShareCode}
animated={showPreviewSwitchPulse}
threadId={threadId}
publicShareCode={publicShareCode}
researchSessionId={researchSessionId}
animated={showPreviewSwitchPulse}
activeCursor={isApplyingEdit}
/>
)}
@@ -1454,25 +1545,40 @@ export function ArtifactFileDetail({
onOpenChange={setDocumentRewriteDialogOpen}
fileName={getFileName(filepath)}
charCount={(displayContent ?? "").replace(/\s/g, "").length}
imageGenerationAvailable={imageGenerationAvailable}
maxGeneratedImages={maxGeneratedImages}
onStart={handleStartDocumentRewrite}
/>
</Artifact>
);
}
function SandboxPreviewImage({
src,
alt,
threadId,
publicShareCode,
...props
}: React.ImgHTMLAttributes<HTMLImageElement> & {
threadId: string;
publicShareCode?: string;
}) {
if (!src || typeof src !== "string") {
return null;
}
function SandboxPreviewImage({
src,
alt,
threadId,
publicShareCode,
researchSessionId,
...props
}: React.ImgHTMLAttributes<HTMLImageElement> & {
threadId: string;
publicShareCode?: string;
researchSessionId?: string;
}) {
if (!src || typeof src !== "string") {
return null;
}
if (src.startsWith("deep-research://")) {
return researchSessionId ? (
<ProtectedResearchReportImage
{...props}
alt={alt}
sessionId={researchSessionId}
src={src}
/>
) : null;
}
let url = src;
if (src.startsWith("/mnt/")) {
@@ -1496,21 +1602,108 @@ function SandboxPreviewImage({
src={url}
alt={alt}
/>
);
}
);
}
/** Fetches a private deep-research image with the normal auth client. */
function ProtectedResearchReportImage({
sessionId,
src,
alt,
...props
}: React.ImgHTMLAttributes<HTMLImageElement> & {
sessionId: string;
}) {
const [objectUrl, setObjectUrl] = useState<string | null>(null);
const [loadFailed, setLoadFailed] = useState(false);
const [retryKey, setRetryKey] = useState(0);
useEffect(() => {
setObjectUrl(null);
setLoadFailed(false);
if (!src?.startsWith("deep-research://")) {
return;
}
const name = src.slice("deep-research://".length);
if (!name) {
setLoadFailed(true);
return;
}
const controller = new AbortController();
let nextUrl: string | null = null;
let disposed = false;
void (async () => {
try {
const response = await apiFetch(
`${getBackendBaseURL()}/api/deep-research/sessions/${encodeURIComponent(sessionId)}/artifacts/${encodeURIComponent(name)}`,
{ method: "GET", signal: controller.signal },
);
if (!response.ok) {
throw new Error(`Unable to load report image (${response.status})`);
}
const blob = await response.blob();
if (!blob.size) {
throw new Error("Report image is empty");
}
nextUrl = URL.createObjectURL(blob);
if (disposed) return;
setObjectUrl(nextUrl);
} catch (error) {
if (!disposed && !(error instanceof DOMException && error.name === "AbortError")) {
// Never let an optional illustration break Markdown rendering, but don't
// leave the user with a misleading infinite loading label either.
setLoadFailed(true);
}
}
})();
return () => {
disposed = true;
controller.abort();
if (nextUrl) URL.revokeObjectURL(nextUrl);
};
}, [retryKey, sessionId, src]);
if (!objectUrl) {
if (loadFailed) {
return (
<span className="my-3 flex items-center gap-2 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-900/70 dark:bg-amber-950/30 dark:text-amber-300">
报告配图加载失败
<button
type="button"
className="font-medium underline underline-offset-2 hover:opacity-80"
onClick={() => setRetryKey((value) => value + 1)}
>
重试
</button>
</span>
);
}
return <span className="block text-xs text-muted-foreground">正在加载报告配图…</span>;
}
return (
<img
{...props}
alt={alt || "研究报告配图"}
className={cn("my-3 max-w-full overflow-hidden rounded-lg border", props.className)}
src={objectUrl}
/>
);
}
export function ArtifactFilePreview({
content,
language,
threadId,
publicShareCode,
animated = false,
threadId,
publicShareCode,
researchSessionId,
animated = false,
activeCursor = false,
}: {
content: string;
language: string;
threadId: string;
publicShareCode?: string;
threadId: string;
publicShareCode?: string;
researchSessionId?: string;
animated?: boolean;
activeCursor?: boolean;
}) {
@@ -1574,7 +1767,7 @@ export function ArtifactFilePreview({
);
},
};
}, [content]);
}, [content, publicShareCode, researchSessionId, threadId]);
useEffect(() => {
if (language !== "html") {
@@ -1639,16 +1832,26 @@ export function ArtifactFilePreview({
animated && "animate-[sandbox-preview-enter_320ms_ease-out]",
)}
>
<Streamdown
className="size-full"
{...streamdownPlugins}
components={{
<Streamdown
className="size-full"
{...streamdownPlugins}
// react-markdown's safe default strips unknown protocols before the
// custom `img` renderer sees them. Deep-research images use this
// private pseudo URL and are then fetched through the authenticated
// artifact endpoint in `SandboxPreviewImage`.
urlTransform={(url) =>
url.startsWith("deep-research://")
? url
: defaultUrlTransform(url)
}
components={{
...markdownComponents,
img: (props) => (
<SandboxPreviewImage
{...props}
threadId={threadId}
publicShareCode={publicShareCode}
threadId={threadId}
publicShareCode={publicShareCode}
researchSessionId={researchSessionId}
/>
),
}}
@@ -1,6 +1,6 @@
"use client";
import { SparklesIcon } from "lucide-react";
import { ImagePlusIcon, SparklesIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
@@ -20,6 +20,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { ModelNameSelect } from "@/components/workspace/model-name-select";
import type { DocumentRewriteStyle } from "@/open-canvas/api/rewrite";
@@ -51,16 +52,22 @@ export function DocumentRewriteDialog({
onOpenChange,
fileName,
charCount,
imageGenerationAvailable = false,
maxGeneratedImages = 4,
onStart,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
fileName: string;
charCount: number;
imageGenerationAvailable?: boolean;
maxGeneratedImages?: number;
onStart: (input: {
instruction: string;
style: DocumentRewriteStyle;
modelName: string | null;
generateImages: boolean;
maxGeneratedImages: number;
}) => void;
}) {
const [instruction, setInstruction] = useState(
@@ -68,11 +75,15 @@ export function DocumentRewriteDialog({
);
const [style, setStyle] = useState<DocumentRewriteStyle>("formal_analysis");
const [modelName, setModelName] = useState<string | null>(null);
const [generateImages, setGenerateImages] = useState(false);
const [imageCount, setImageCount] = useState("2");
useEffect(() => {
if (!open) return;
setStyle("formal_analysis");
setModelName(null);
setGenerateImages(false);
setImageCount("2");
}, [open]);
const selectedStyle = STYLE_OPTIONS.find((item) => item.value === style);
@@ -136,6 +147,48 @@ export function DocumentRewriteDialog({
</label>
</div>
{imageGenerationAvailable && (
<div className="rounded-lg border bg-muted/20 p-3">
<div className="flex items-start justify-between gap-4">
<label className="flex min-w-0 cursor-pointer items-start gap-3" htmlFor="document-rewrite-images">
<span className="bg-primary/10 text-primary mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full">
<ImagePlusIcon className="size-4" />
</span>
<span className="grid gap-0.5">
<span className="text-sm font-medium">改写后补充配图</span>
<span className="text-muted-foreground text-xs leading-5">
根据章节生成并保存为当前沙箱的本地图片,不使用外部图片链接。
</span>
</span>
</label>
<Switch
aria-label="改写后补充配图"
checked={generateImages}
id="document-rewrite-images"
onCheckedChange={setGenerateImages}
/>
</div>
{generateImages && (
<div className="mt-3 flex items-center justify-between gap-3 border-t pt-3">
<span className="text-muted-foreground text-xs">每次会额外消耗图片模型额度</span>
<label className="flex items-center gap-2 text-sm">
配图数量
<Select value={imageCount} onValueChange={setImageCount}>
<SelectTrigger className="h-8 w-20 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Array.from({ length: Math.min(Math.max(maxGeneratedImages, 1), 4) }, (_, index) => index + 1).map((count) => (
<SelectItem key={count} value={String(count)}>{count} 张</SelectItem>
))}
</SelectContent>
</Select>
</label>
</div>
)}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
取消
@@ -144,7 +197,16 @@ export function DocumentRewriteDialog({
disabled={!canStart}
onClick={() => {
if (!canStart) return;
onStart({ instruction: instruction.trim(), style, modelName });
onStart({
instruction: instruction.trim(),
style,
modelName,
generateImages,
maxGeneratedImages: Math.min(
Math.max(Number(imageCount) || 1, 1),
Math.max(Math.min(maxGeneratedImages, 4), 1),
),
});
}}
>
<SparklesIcon />
@@ -780,11 +780,10 @@ function buildTurnReferenceBatches(
continue;
}
const nextHumanIndex = findNextHumanIndex(messages, index + 1);
if (nextHumanIndex === -1 && isLoading) {
continue;
}
const endIndex = nextHumanIndex === -1 ? messages.length : nextHumanIndex;
const assistant = findLastAssistantAnswer(messages, index + 1, endIndex);
const assistant =
findLastAssistantAnswer(messages, index + 1, endIndex) ??
findFrontendRagAssistant(messages, index + 1, endIndex);
if (!assistant?.id) {
continue;
}
@@ -794,6 +793,14 @@ function buildTurnReferenceBatches(
nextHumanIndex === -1 ? undefined : messages[nextHumanIndex]?.id,
includeBeforeMessage: false,
}).filter((source) => source.mode !== "none");
// GeneralQA's single-RAG stream sends `docs` before the answer. Make
// those cards available immediately, while keeping normal streaming turns
// on their existing "wait for an answer" behavior.
const isFrontendRagTurn =
isSingleRoundRagMessage(assistant) || isMultiRoundRagMessage(assistant);
if (nextHumanIndex === -1 && isLoading && !isFrontendRagTurn) {
continue;
}
if (sources.length > 0) {
batches.push({
id: assistant.id,
@@ -832,6 +839,23 @@ function findLastAssistantAnswer(
return null;
}
function findFrontendRagAssistant(
messages: Message[],
start: number,
end: number,
): Message | null {
for (let index = end - 1; index >= start; index -= 1) {
const message = messages[index];
if (
message?.type === "ai" &&
(isSingleRoundRagMessage(message) || isMultiRoundRagMessage(message))
) {
return message;
}
}
return null;
}
function hasAssistantAnswerContent(message: Message): boolean {
const content = message.content;
if (typeof content === "string") {
@@ -2,15 +2,22 @@
import {
CheckCircle2Icon,
ChevronDownIcon,
FilePenLineIcon,
LightbulbIcon,
Loader2Icon,
Undo2Icon,
XCircleIcon,
} from "lucide-react";
import { useState } from "react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { apiFetch } from "@/core/api/fetch-client";
import {
DOCUMENT_REWRITE_UNDONE_EVENT,
@@ -26,6 +33,7 @@ const STEPS = [
{ key: "snapshot", label: "固定原文版本" },
{ key: "requirements", label: "理解改写要求" },
{ key: "writing", label: "流式重写 Markdown" },
{ key: "illustrations", label: "生成文档配图" },
{ key: "validation", label: "校验文档" },
{ key: "comparison", label: "对比原文并生成说明" },
] as const;
@@ -38,15 +46,17 @@ function stepPosition(stage: DocumentRewritePresentation["stage"]): number {
return 1;
case "writing":
return 2;
case "validation":
case "illustrating":
return 3;
case "validation":
return 4;
case "committing":
case "completed":
return 5;
return 6;
case "conflict":
case "error":
case "cancelled":
return 4;
return 5;
default:
return -1;
}
@@ -56,6 +66,42 @@ function isTerminal(stage: DocumentRewritePresentation["stage"]) {
return ["completed", "conflict", "error", "cancelled"].includes(stage);
}
function RewriteThinkingTrace({ text, active }: { text: string; active: boolean }) {
const [open, setOpen] = useState(active);
useEffect(() => {
if (active && text) setOpen(true);
}, [active, text]);
if (!text.trim()) return null;
return (
<Collapsible className="mt-2 max-w-3xl" onOpenChange={setOpen} open={open}>
<CollapsibleTrigger asChild>
<Button
aria-label={open ? "收起模型思考" : "展开模型思考"}
className="h-7 gap-1.5 px-2 text-xs"
size="sm"
type="button"
variant="ghost"
>
<LightbulbIcon className="size-3.5 text-amber-600 dark:text-amber-300" />
<span>模型思考</span>
<span className="text-muted-foreground">{active ? "思考中" : "已完成"}</span>
<ChevronDownIcon className={cn("size-3.5 transition-transform", open && "rotate-180")} />
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<p aria-live={active ? "polite" : undefined} className="sr-only" role={active ? "status" : undefined}>
{active ? "模型思考内容正在更新" : "模型思考内容已完成"}
</p>
<pre className="bg-muted/45 text-muted-foreground mt-1 max-h-56 overflow-auto rounded-lg border px-3 py-2 text-xs leading-5 whitespace-pre-wrap break-words">
{text}
</pre>
</CollapsibleContent>
</Collapsible>
);
}
export function DocumentRewriteTimeline({
item,
}: {
@@ -64,6 +110,9 @@ export function DocumentRewriteTimeline({
const current = stepPosition(item.stage);
const failed = ["conflict", "error", "cancelled"].includes(item.stage);
const completed = item.stage === "completed";
const thinkingStep = ["snapshot", "requirements"].includes(item.stage)
? "requirements"
: "writing";
const presentation = useDocumentRewritePresentation();
const [undoing, setUndoing] = useState(false);
const [diffOpen, setDiffOpen] = useState(false);
@@ -148,7 +197,13 @@ export function DocumentRewriteTimeline({
<div className="space-y-3 px-5 py-4">
{STEPS.map((step, index) => {
const active = !isTerminal(item.stage) && index === current;
const done = completed || index < current;
const illustrationSkipped = step.key === "illustrations" && !item.generateImages;
const illustrationFailed =
step.key === "illustrations" &&
item.generateImages &&
completed &&
item.generatedImageCount === 0;
const done = !illustrationSkipped && !illustrationFailed && (completed || index < current);
return (
<div className="flex items-start gap-3" key={step.key}>
<div
@@ -200,6 +255,30 @@ export function DocumentRewriteTimeline({
正在写入右侧沙箱,已生成 {item.writtenCharCount.toLocaleString()} 字。
</p>
)}
{step.key === "illustrations" && (
illustrationSkipped ? (
<p className="text-muted-foreground mt-0.5 text-xs">
本次未启用配图,保留原文已有图片与链接。
</p>
) : active ? (
<p aria-live="polite" className="text-muted-foreground mt-0.5 text-xs" role="status">
{item.illustrationTarget
? `正在为「${item.illustrationTarget}」生成配图,已完成 ${item.generatedImageCount}/${item.maxGeneratedImages} 张。`
: `正在准备 ${item.maxGeneratedImages} 张文档配图…`}
</p>
) : item.generatedImageCount > 0 ? (
<p className="text-muted-foreground mt-0.5 text-xs">
已生成并保存 {item.generatedImageCount} 张本地配图。
</p>
) : (
<p className="text-muted-foreground mt-0.5 text-xs">
未成功生成配图,文字改写结果仍已保留。
</p>
)
)}
{step.key === thinkingStep && (
<RewriteThinkingTrace active={active} text={item.thinkingText} />
)}
{step.key === "validation" && item.warnings.length > 0 && (
<p className="mt-0.5 text-xs text-amber-700 dark:text-amber-300">
{item.warnings.join(";")}
@@ -228,6 +307,11 @@ export function DocumentRewriteTimeline({
本次优化:{item.summary.requestedOptimizations.join("、")}。
</p>
) : null}
{item.summary.requestedImageCount ? (
<p className="text-muted-foreground">
文档配图:请求 {item.summary.requestedImageCount} 张,已保存 {item.summary.generatedImageCount ?? 0} 张本地图片。
</p>
) : null}
{item.summary.headingChanges && (
item.summary.headingChanges.reordered ||
item.summary.headingChanges.added.length > 0 ||
@@ -523,7 +523,13 @@ function DeepResearchProgressStep({
? args.label
: "正在撰写研究报告";
const detail = typeof args.detail === "string" ? args.detail.trim() : "";
const thinking = typeof args.thinking === "string" ? args.thinking.trim() : "";
const active = args.status !== "complete";
const [thinkingOpen, setThinkingOpen] = useState(false);
useEffect(() => {
if (active && thinking) setThinkingOpen(true);
}, [active, thinking]);
return (
<ChainOfThoughtStep
@@ -546,6 +552,32 @@ function DeepResearchProgressStep({
{detail}
</div>
)}
{thinking && (
<Collapsible className="mt-2" onOpenChange={setThinkingOpen} open={thinkingOpen}>
<CollapsibleTrigger asChild>
<Button
aria-label={thinkingOpen ? "收起模型思考" : "展开模型思考"}
className="h-7 gap-1.5 px-2 text-xs"
size="sm"
type="button"
variant="ghost"
>
<LightbulbIcon className="size-3.5 text-amber-600 dark:text-amber-300" />
<span>模型思考</span>
<span className="text-muted-foreground">{active ? "思考中" : "已完成"}</span>
<ChevronDownIcon className={cn("size-3.5 transition-transform", thinkingOpen && "rotate-180")} />
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<p aria-live={active ? "polite" : undefined} className="sr-only" role={active ? "status" : undefined}>
{active ? "模型思考内容正在更新" : "模型思考内容已完成"}
</p>
<pre className="bg-muted/45 text-muted-foreground mt-1 max-h-56 overflow-auto rounded-lg border px-3 py-2 text-xs leading-5 whitespace-pre-wrap break-words">
{thinking}
</pre>
</CollapsibleContent>
</Collapsible>
)}
</ChainOfThoughtStep>
);
}
@@ -6,9 +6,10 @@ import {
ExternalLinkIcon,
FileIcon,
Loader2Icon,
PencilIcon,
SearchIcon,
} from 'lucide-react';
PencilIcon,
SearchIcon,
SparklesIcon,
} from 'lucide-react';
import {
memo,
useCallback,
@@ -16,9 +17,10 @@ import {
useEffect,
useMemo,
useState,
type AnchorHTMLAttributes,
type ImgHTMLAttributes,
} from 'react';
type AnchorHTMLAttributes,
type ImgHTMLAttributes,
type ReactNode,
} from 'react';
import rehypeKatex from 'rehype-katex';
import { Loader } from '@/components/ai-elements/loader';
@@ -838,7 +840,7 @@ function FrontendRagSourcesPanel({
const [open, setOpen] = useState(false);
if (sources.length === 0) return null;
return (
<Collapsible open={open} onOpenChange={setOpen} className="mt-3 w-full">
<Collapsible open={open} onOpenChange={setOpen} className="mt-3 w-full max-w-5xl">
<CollapsibleTrigger asChild>
<button
type="button"
@@ -944,14 +946,17 @@ function MultiRoundRagContent({
isLoading: boolean;
rehypePlugins: MarkdownContentProps['rehypePlugins'];
components: MarkdownContentProps['components'];
}) {
const [open, setOpen] = useState(!trace.isComplete);
useEffect(() => {
if (!trace.isComplete) setOpen(true);
}, [trace.isComplete]);
}) {
const [open, setOpen] = useState(!trace.isComplete);
useEffect(() => {
// Keep the active turn expanded so each new retrieval is visible as it
// arrives, then match GeneralQA by collapsing the completed trace. The
// header still carries the total round count for quick scanning.
setOpen(!trace.isComplete);
}, [trace.isComplete]);
return (
<div className="space-y-3">
<div className="w-full min-w-0 max-w-5xl space-y-4 overflow-hidden">
<section className="border-border/70 overflow-hidden rounded-xl border bg-muted/20">
<button
type="button"
@@ -965,31 +970,42 @@ function MultiRoundRagContent({
<Loader2Icon className="text-primary size-4 shrink-0 animate-spin" />
)}
<div>
<div className="text-sm font-medium">多轮检索过程</div>
<div className="text-muted-foreground text-xs">
{trace.isComplete
? `已完成 ${trace.rounds.length} 轮检索`
: '正在分析问题并检索资料'}
</div>
<div className="text-sm font-medium">多轮检索思考过程</div>
<div className="text-muted-foreground text-xs">
{trace.isComplete
? `共 ${trace.rounds.length} 轮推理与检索,可展开查看细节`
: '正在分析问题并检索资料'}
</div>
</div>
</div>
<ChevronDownIcon
className={cn('text-muted-foreground size-4 transition-transform', !open && '-rotate-90')}
/>
</button>
{open && (
<div className="border-border/60 space-y-3 border-t px-4 py-3">
{trace.rounds.map((round, index) => (
<MultiRoundRagRoundCard
key={round.id}
index={index + 1}
round={round}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
components={components}
/>
))}
</div>
{open && (
<div className="border-border/60 space-y-3 border-t px-4 py-3">
{trace.rounds.map((round, index) => (
<div
key={round.id}
className={cn(
'relative pl-5',
index < trace.rounds.length - 1 && 'pb-1',
)}
>
{index < trace.rounds.length - 1 && (
<span className="bg-border absolute bottom-0 left-[7px] top-8 w-px" />
)}
<span className="border-primary bg-background absolute left-0 top-4 size-3.5 rounded-full border-2" />
<MultiRoundRagRoundCard
index={index + 1}
round={round}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
components={components}
/>
</div>
))}
</div>
)}
</section>
<section className="border-border/70 rounded-xl border p-4">
@@ -1014,7 +1030,7 @@ function MultiRoundRagContent({
);
}
function MultiRoundRagRoundCard({
function MultiRoundRagRoundCard({
index,
round,
isLoading,
@@ -1026,78 +1042,228 @@ function MultiRoundRagRoundCard({
isLoading: boolean;
rehypePlugins: MarkdownContentProps['rehypePlugins'];
components: MarkdownContentProps['components'];
}) {
return (
<div className="border-border/60 rounded-lg border bg-background/70 p-3">
<div className="flex items-center gap-2 text-sm font-medium">
<span className="bg-primary/10 text-primary flex size-5 shrink-0 items-center justify-center rounded-full text-xs">
{index}
</span>
<span>{round.title}</span>
{round.isSearching && <Loader2Icon className="text-primary size-3.5 animate-spin" />}
</div>
{round.thought && (
<section className="border-border/50 bg-muted/30 mt-3 rounded-md border p-3">
<div className="text-muted-foreground mb-2 text-xs font-medium">思考内容</div>
<MarkdownContent
content={round.thought}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
components={components}
className="text-muted-foreground text-sm"
/>
</section>
)}
{round.body && (
<section className="border-border/50 mt-3 rounded-md border p-3">
<div className="text-muted-foreground mb-2 text-xs font-medium">本轮正文</div>
<MarkdownContent
content={round.body}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
components={components}
className="text-sm"
/>
</section>
)}
{round.query && (
<div className="border-primary/20 bg-primary/5 text-primary mt-2 inline-flex max-w-full items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs">
<SearchIcon className="size-3 shrink-0" />
<span className="truncate">{round.query}</span>
</div>
)}
{round.retrievals.length > 0 && (
<section className="border-border/50 mt-3 rounded-md border p-3">
<div className="text-muted-foreground mb-2 text-xs font-medium">
检索内容 · {round.retrievals.length} 条
</div>
<div className="space-y-2">
{round.retrievals.map((item, resultIndex) => (
<details
key={`${item.id ?? item.title}-${resultIndex}`}
className="border-border/60 rounded-md border px-3 py-2"
open={resultIndex === 0}
>
<summary className="cursor-pointer list-none text-sm font-medium">
<span className="mr-2 text-xs text-muted-foreground">{resultIndex + 1}.</span>
{item.title}
</summary>
<div className="text-muted-foreground mt-2 text-sm leading-6 whitespace-pre-wrap">
{item.content || '暂无资料摘要'}
</div>
{(item.source || item.time) && (
<div className="text-muted-foreground mt-2 text-xs">
{[item.source, item.time].filter(Boolean).join(' · ')}
</div>
)}
</details>
))}
</div>
</section>
)}
</div>
);
}
}) {
const [open, setOpen] = useState(!round.query || round.isSearching);
useEffect(() => {
// A newly created round stays expanded to expose its thought and loading
// query. Once that query has returned, retain only the round header until
// the user asks to inspect the completed detail again.
setOpen(!round.query || round.isSearching);
}, [round.id, round.isSearching, round.query]);
return (
<Collapsible open={open} onOpenChange={setOpen}>
<article className="border-border/60 bg-background/80 overflow-hidden rounded-xl border shadow-sm">
<CollapsibleTrigger asChild>
<button
type="button"
className="hover:bg-muted/40 focus-visible:ring-ring flex min-h-11 w-full min-w-0 items-center gap-2 px-3 py-2.5 text-left text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset"
>
<span className="bg-primary/10 text-primary flex size-6 shrink-0 items-center justify-center rounded-full text-xs font-semibold">
{index}
</span>
<span className="min-w-0 flex-1 truncate">{round.title}</span>
{round.isSearching ? (
<span className="bg-primary/10 text-primary inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium">
<Loader2Icon className="size-3 motion-reduce:animate-none animate-spin" />
检索中
</span>
) : (
<span className="text-muted-foreground shrink-0 text-[11px]">已完成</span>
)}
<ChevronDownIcon
className={cn(
'text-muted-foreground size-4 shrink-0 transition-transform duration-200 motion-reduce:transition-none',
!open && '-rotate-90',
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="border-border/50 border-t px-3 pb-3">
<div className="pt-3">
{round.thought && (
<MultiRoundRagDetailSection
title="思考内容"
icon={<SparklesIcon className="text-primary size-4" />}
>
<MarkdownContent
content={round.thought}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
components={components}
className="text-muted-foreground text-sm"
/>
</MultiRoundRagDetailSection>
)}
{round.body && (
<section className="border-border/50 mt-3 rounded-lg border p-3">
<div className="text-muted-foreground mb-2 text-xs font-medium">本轮正文</div>
<MarkdownContent
content={round.body}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
components={components}
className="text-sm"
/>
</section>
)}
{(round.query || round.isSearching) && (
<div
className={cn(
'border-primary/20 bg-primary/5 text-primary mt-3 inline-flex max-w-full items-center gap-1.5 rounded-full border px-2.5 py-1.5 text-xs',
round.isSearching && 'animate-pulse motion-reduce:animate-none',
)}
aria-live={round.isSearching ? 'polite' : undefined}
>
{round.isSearching ? (
<Loader2Icon className="size-3.5 shrink-0 motion-reduce:animate-none animate-spin" />
) : (
<SearchIcon className="size-3.5 shrink-0" />
)}
<span className="shrink-0 text-muted-foreground">检索词</span>
<span className="min-w-0 truncate font-medium">
{round.query || '正在生成检索词…'}
</span>
</div>
)}
{(round.query || round.isSearching || round.retrievals.length > 0) && (
<MultiRoundRagDetailSection
defaultOpen={!round.isSearching}
title="检索内容"
icon={<FileIcon className="text-primary size-3.5" />}
suffix={<span className="text-muted-foreground text-xs">{round.retrievals.length} 条</span>}
>
{round.isSearching ? (
<div className="text-muted-foreground flex items-center gap-2 py-1 text-sm">
<Loader2Icon className="text-primary size-4 motion-reduce:animate-none animate-spin" />
正在召回相关内容…
</div>
) : (
<div className="space-y-2">
{round.retrievals.length > 0 ? (
round.retrievals.map((item, resultIndex) => (
<MultiRoundRagResultCard
key={`${item.id ?? item.title}-${resultIndex}`}
item={item}
index={resultIndex + 1}
/>
))
) : (
<p className="text-muted-foreground py-1 text-sm">
本轮未召回相关资料。
</p>
)}
</div>
)}
</MultiRoundRagDetailSection>
)}
</div>
</CollapsibleContent>
</article>
</Collapsible>
);
}
function MultiRoundRagDetailSection({
title,
icon,
suffix,
defaultOpen = true,
children,
}: {
title: string;
icon: ReactNode;
suffix?: ReactNode;
defaultOpen?: boolean;
children: ReactNode;
}) {
const [open, setOpen] = useState(defaultOpen);
useEffect(() => setOpen(defaultOpen), [defaultOpen]);
return (
<Collapsible open={open} onOpenChange={setOpen}>
<section className="border-border/50 bg-muted/20 mt-3 overflow-hidden rounded-lg border">
<CollapsibleTrigger asChild>
<button
type="button"
className="hover:bg-muted/40 focus-visible:ring-ring flex min-h-11 w-full min-w-0 items-center gap-2 px-3 py-2 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset"
>
{icon}
<span className="min-w-0 flex-1 text-sm font-medium">{title}</span>
{suffix}
<ChevronDownIcon
className={cn(
'text-muted-foreground size-4 shrink-0 transition-transform duration-200 motion-reduce:transition-none',
!open && '-rotate-90',
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="border-border/50 border-t px-3 pb-3 pt-2.5">
{children}
</CollapsibleContent>
</section>
</Collapsible>
);
}
function MultiRoundRagResultCard({
item,
index,
}: {
item: MultiRoundRagRound['retrievals'][number];
index: number;
}) {
const [open, setOpen] = useState(index === 1);
const sourceMeta = [item.source, item.time].filter(Boolean).join(' · ');
return (
<Collapsible open={open} onOpenChange={setOpen}>
<div className="border-border/60 hover:border-primary/30 bg-background rounded-lg border transition-colors">
<CollapsibleTrigger asChild>
<button
type="button"
className="hover:bg-muted/40 focus-visible:ring-ring flex min-h-11 w-full min-w-0 items-center gap-2 rounded-lg px-3 py-2.5 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset"
>
<span className="bg-primary/10 text-primary flex size-5 shrink-0 items-center justify-center rounded-md text-[11px] font-semibold">
{index}
</span>
<span className="min-w-0 flex-1 truncate text-sm font-medium" title={item.title}>
{item.title}
</span>
<ChevronDownIcon
className={cn(
'text-muted-foreground size-4 shrink-0 transition-transform duration-200 motion-reduce:transition-none',
!open && '-rotate-90',
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="border-border/50 border-t px-3 pb-3 pt-2.5">
<p className="text-muted-foreground text-sm leading-6 whitespace-pre-wrap">
{item.content || '暂无资料摘要'}
</p>
{(sourceMeta || item.url) && (
<div className="text-muted-foreground mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
{sourceMeta && <span>{sourceMeta}</span>}
{item.url && (
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
>
查看原文
<ExternalLinkIcon className="size-3" />
</a>
)}
</div>
)}
</CollapsibleContent>
</div>
</Collapsible>
);
}
function resolveSingleRoundRagSourceUrl(
source: RuntimeDisplaySource | null,
@@ -255,15 +255,15 @@ export function MessageList({
const [settings] = useLocalSettings();
const ragModeEnabled =
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
const hasSingleRoundRag = useMemo(
const hasSingleRoundRag = useMemo(
() =>
messages.some(
(message) =>
isSingleRoundRagMessage(message) || isMultiRoundRagMessage(message),
),
[messages],
);
const referencesEnabled = ragModeEnabled || hasSingleRoundRag;
[messages],
);
const referencesEnabled = ragModeEnabled || hasSingleRoundRag;
const { skills } = useSkills();
const skillDisplayMap = useMemo(
() => (referencesEnabled ? buildSkillDisplayMap(skills) : new Map()),
@@ -436,11 +436,12 @@ export function MessageList({
className={cn("flex size-full flex-col justify-center", className)}
>
<ScrollToBottomOnUserMessage threadId={threadId} messages={messages} />
<ConversationContent
className={cn(
"mx-auto w-full max-w-(--container-width-lg) gap-8 pt-12",
contentClassName,
)}
<ConversationContent
className={cn(
"mx-auto w-full max-w-(--container-width-lg)",
hasSingleRoundRag ? "gap-4 pt-4" : "gap-8 pt-12",
contentClassName,
)}
>
<MessageMetricsProvider messages={messages} isLoading={thread.isLoading}>
{groupMessages(messages, (group) => {
@@ -54,6 +54,7 @@ import type {
CompactShortcutRouteParam,
} from "@/core/system-settings/api";
import { cn } from "@/lib/utils";
import { createClientId } from "@/lib/client-id";
import {
APP_THEME_COLORS,
LOGIN_SOURCE_OPTIONS,
@@ -77,10 +78,7 @@ const languageOptions: { value: Locale; label: string }[] = [
const DEFAULT_SHORTCUT_THEME_MAP = "red=red;green=green;blue=blue;dark-blue=dark-blue";
function makeShortcutId() {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `shortcut-${Date.now()}-${Math.random().toString(16).slice(2)}`;
return createClientId("shortcut");
}
function newShortcutParam(): CompactShortcutRouteParam {
@@ -43,6 +43,7 @@ import {
customThemeTextContrast,
type CustomThemeAdvancedKey,
} from "@/core/theme/custom-theme";
import { createClientId } from "@/lib/client-id";
import { cn } from "@/lib/utils";
const MAX_CUSTOM_THEMES = 12;
@@ -127,10 +128,7 @@ const ADVANCED_COLOR_FIELDS: Array<{
];
function makeThemeId(): `custom-theme-${string}` {
const suffix =
typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID().replace(/[^a-z0-9-]/gi, "").toLowerCase()
: `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
const suffix = createClientId().replace(/[^a-z0-9-]/gi, "").toLowerCase();
return `custom-theme-${suffix}`;
}
@@ -6,6 +6,7 @@ import type {
DocumentRewriteStage,
} from "@/open-canvas/hooks/useDocumentRewrite";
import type { DocumentRewriteSummary } from "@/open-canvas/api/rewrite";
import { createClientId } from "@/lib/client-id";
export type DocumentRewritePresentation = {
id: string;
@@ -19,11 +20,17 @@ export type DocumentRewritePresentation = {
instruction: string;
style: string;
modelName: string | null;
generateImages: boolean;
maxGeneratedImages: number;
generatedImageCount: number;
illustrationTarget: string;
stage: DocumentRewriteStage;
originalCharCount: number;
writtenCharCount: number;
/** Short model-generated plan, streamed while it understands the request. */
requirementsPlan: string;
/** Ephemeral thought trace. Never included in the Markdown draft or persisted job state. */
thinkingText: string;
warnings: string[];
summary: DocumentRewriteSummary | null;
/** Kept in-memory for the line-level compare dialog; backend job hydration refills it. */
@@ -42,9 +49,14 @@ type CreatePresentationInput = Omit<
DocumentRewritePresentation,
| "id"
| "stage"
| "generateImages"
| "maxGeneratedImages"
| "generatedImageCount"
| "illustrationTarget"
| "originalCharCount"
| "writtenCharCount"
| "requirementsPlan"
| "thinkingText"
| "warnings"
| "summary"
| "originalContent"
@@ -59,6 +71,8 @@ type CreatePresentationInput = Omit<
originalContent?: string;
rewrittenContent?: string | null;
requirementsPlan?: string;
generateImages?: boolean;
maxGeneratedImages?: number;
};
type DocumentRewritePresentationContextValue = {
@@ -74,10 +88,7 @@ const DocumentRewritePresentationContext =
createContext<DocumentRewritePresentationContextValue | null>(null);
function createId() {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return "drw-ui-" + crypto.randomUUID();
}
return "drw-ui-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2);
return createClientId("drw-ui");
}
export function DocumentRewritePresentationProvider({
@@ -96,9 +107,14 @@ export function DocumentRewritePresentationProvider({
...input,
id,
stage: "snapshot",
generateImages: input.generateImages === true,
maxGeneratedImages: Math.min(Math.max(input.maxGeneratedImages ?? 2, 1), 4),
generatedImageCount: 0,
illustrationTarget: "",
originalCharCount: 0,
writtenCharCount: 0,
requirementsPlan: input.requirementsPlan ?? "",
thinkingText: "",
warnings: [],
summary: null,
originalContent: input.originalContent ?? "",
@@ -2,6 +2,7 @@ import type { Message } from "@langchain/langgraph-sdk";
import { useCallback, useEffect, useRef, useState } from "react";
import { runSentimentAgent, toAgUiMessages } from "./api";
import { createClientId } from "@/lib/client-id";
const STORAGE_PREFIX = "workspace.sentiment-agent.";
@@ -53,9 +54,9 @@ export function useSentimentAgentChat(threadId: string) {
const question = query.trim();
if (!question) return;
const assistantId = `sentiment-ai-${crypto.randomUUID()}`;
const assistantId = createClientId("sentiment-ai");
const userMessage: Message = {
id: `sentiment-human-${crypto.randomUUID()}`,
id: createClientId("sentiment-human"),
type: "human",
content: [{ type: "text", text: question }],
additional_kwargs: {
@@ -115,7 +116,7 @@ export function useSentimentAgentChat(threadId: string) {
try {
await runSentimentAgent({
threadId,
runId: `run-${crypto.randomUUID()}`,
runId: createClientId("run"),
messages: history,
signal: controller.signal,
handlers: {
@@ -0,0 +1,90 @@
/**
* Some OpenAI-compatible reasoning models write ``<think>...</think>`` into
* the ordinary text stream rather than a dedicated reasoning field. Markdown
* sandboxes must never render those fragments as document content. Callers
* can separately render the returned thinking part in a labelled UI trace.
*
* The filter is stateful because either tag can be split across arbitrary SSE
* deltas. Hold only a possible tag suffix, so confirmed Markdown continues
* to appear with the normal token-streaming cadence.
*/
export class InlineThinkTagFilter {
private static readonly OPEN_TAG = "<think>";
private static readonly CLOSE_TAG = "</think>";
private insideThink = false;
private pending = "";
private possibleTagPrefixSize(content: string, tag: string) {
const lowered = content.toLowerCase();
for (let size = Math.min(content.length, tag.length - 1); size > 0; size -= 1) {
if (lowered.slice(-size) === tag.slice(0, size)) return size;
}
return 0;
}
pushParts(chunk: string): { text: string; thinking: string } {
if (!chunk) return { text: "", thinking: "" };
let remaining = this.pending + chunk;
this.pending = "";
let visible = "";
let thinking = "";
while (remaining) {
const tag = this.insideThink
? InlineThinkTagFilter.CLOSE_TAG
: InlineThinkTagFilter.OPEN_TAG;
const tagIndex = remaining.toLowerCase().indexOf(tag);
if (tagIndex >= 0) {
if (this.insideThink) thinking += remaining.slice(0, tagIndex);
else visible += remaining.slice(0, tagIndex);
remaining = remaining.slice(tagIndex + tag.length);
this.insideThink = !this.insideThink;
continue;
}
const suffixSize = this.possibleTagPrefixSize(remaining, tag);
const confirmed = suffixSize ? remaining.slice(0, -suffixSize) : remaining;
if (this.insideThink) thinking += confirmed;
else visible += confirmed;
this.pending = suffixSize ? remaining.slice(-suffixSize) : "";
break;
}
return { text: visible, thinking };
}
push(chunk: string) {
return this.pushParts(chunk).text;
}
/** Flush a completed stream into its visible/thinking channels. */
finishParts(): { text: string; thinking: string } {
if (this.insideThink) {
const thinking = this.pending;
this.insideThink = false;
this.pending = "";
return { text: "", thinking };
}
const tail = this.pending;
this.pending = "";
return { text: tail, thinking: "" };
}
finish() {
return this.finishParts().text;
}
/** Replace a rendered checkpoint while retaining the correct tag state. */
replace(content: string) {
this.insideThink = false;
this.pending = "";
return this.push(content);
}
}
export function stripInlineThinkTags(content: string) {
const filter = new InlineThinkTagFilter();
return filter.push(content) + filter.finish();
}
@@ -2,6 +2,7 @@ import type { Message } from "@langchain/langgraph-sdk";
import { useCallback, useEffect, useRef, useState } from "react";
import { readRuntimeEnv } from "@/env";
import { createClientId } from "@/lib/client-id";
const STORAGE_PREFIX = "workspace.multi-round-rag.";
@@ -13,6 +14,9 @@ export type MultiRoundRagRetrieval = {
time?: string;
recUuid?: string;
url?: string;
/** Original tool payload, retained so a future renderer can add more fields
* without losing data received from the multi-round service. */
raw?: Record<string, unknown>;
};
export type MultiRoundRagRound = {
@@ -74,7 +78,7 @@ function writeStoredMessages(threadId: string, messages: Message[]) {
}
function createId(prefix: string) {
return `${prefix}-${crypto.randomUUID()}`;
return createClientId(prefix);
}
function isAbortError(error: unknown) {
@@ -87,8 +91,11 @@ function asRecord(value: unknown): Record<string, unknown> | null {
: null;
}
function asString(value: unknown): string {
return typeof value === "string" ? value : "";
/** Values in the deployed tools are occasionally numeric IDs, not strings. */
function asText(value: unknown): string {
if (typeof value === "string") return value;
if (typeof value === "number" && Number.isFinite(value)) return String(value);
return "";
}
function parseJson(value: unknown): unknown {
@@ -103,8 +110,11 @@ function parseJson(value: unknown): unknown {
function parseEventLine(line: string): MultiRoundEvent | null {
const trimmed = line.trim();
if (!trimmed.startsWith("data:")) return null;
const payload = trimmed.slice("data:".length).trim();
if (!payload || payload === "[DONE]") return { event: "done" };
try {
return JSON.parse(trimmed.slice("data:".length).trim()) as MultiRoundEvent;
const parsed = parseJson(payload);
return asRecord(parsed) as MultiRoundEvent | null;
} catch {
return null;
}
@@ -141,10 +151,10 @@ function addThinkingRound(trace: MultiRoundRagTrace, thought: string) {
if (current.title === "正在准备检索" && !current.thought && !current.query) {
current.title = "思考过程";
current.thought = thought;
return;
return current;
}
current.isSearching = false;
trace.rounds.push({
const next = {
id: createId("multi-rag-round"),
title: "思考过程",
thought,
@@ -152,7 +162,9 @@ function addThinkingRound(trace: MultiRoundRagTrace, thought: string) {
query: "",
isSearching: true,
retrievals: [],
});
};
trace.rounds.push(next);
return next;
}
function appendRoundBody(trace: MultiRoundRagTrace, body: string) {
@@ -162,49 +174,94 @@ function appendRoundBody(trace: MultiRoundRagTrace, body: string) {
current.body = `${current.body ?? ""}${body}`;
}
function mapRetrievals(value: unknown): MultiRoundRagRetrieval[] {
const root = asRecord(parseJson(value));
const list =
(Array.isArray(root?.results) && root.results) ||
(Array.isArray(root?.organic) && root.organic) ||
[];
return list.flatMap((item, index) => {
function firstArray(values: unknown[]): unknown[] {
for (const value of values) {
const parsed = parseJson(value);
if (Array.isArray(parsed)) return parsed;
}
return [];
}
function toRecordList(value: unknown): Record<string, unknown>[] {
const parsed = parseJson(value);
const root = asRecord(parsed);
const list = Array.isArray(parsed)
? parsed
: firstArray([
root?.results,
root?.organic,
root?.data,
root?.items,
root?.list,
root?.documents,
]);
return list.flatMap((item) => {
if (typeof item === "string") return [{ content: item }];
const record = asRecord(item);
if (!record) return [];
const recUuid = asString(record.recUuid ?? record.rec_uuid);
const url = asString(record.url ?? record.link ?? record.href);
return record ? [record] : [];
});
}
/**
* GeneralQA's service fans out to several business search tools. Normalising
* all of their result shapes here prevents a successful tool call from being
* silently absent just because it did not use `results` or `organic`.
*/
function mapRetrievals(value: unknown): MultiRoundRagRetrieval[] {
const list = toRecordList(value);
return list.flatMap((item, index) => {
const recUuid = asText(item.recUuid ?? item.rec_uuid ?? item.uuid);
const url = asText(item.url ?? item.link ?? item.href);
const title = asText(
item.title ?? item.m_title ?? item.taskName ?? item.user_name ?? item.name ?? item.user_id,
);
const content = asText(
item.content ??
item.m_content ??
item.m_conent ??
item.snippet ??
item.text ??
item.overview ??
item.page_content ??
item.description ??
item.summary,
);
return [
{
id: asString(record.id) || recUuid || undefined,
title: asString(record.title) || `检索资料 ${index + 1}`,
content: asString(record.content ?? record.snippet ?? record.text),
source: asString(record.source ?? record.source1 ?? url) || undefined,
time: asString(record.publish_time ?? record.m_publish ?? record.date) || undefined,
id: asText(item.id) || recUuid || undefined,
title: title || `检索资料 ${index + 1}`,
content: content || (url && !title ? url : ""),
source: asText(item.source ?? item.source1 ?? item.m_source ?? item.platform) || undefined,
time: asText(item.publish_time ?? item.m_publish ?? item.m_publicsh ?? item.date ?? item.time) || undefined,
recUuid: recUuid || undefined,
url: /^https?:\/\//i.test(url) ? url : undefined,
raw: item,
},
];
});
}
function mapReferences(value: unknown): MultiRoundRagSource[] {
const record = asRecord(value);
const list = Array.isArray(record?.references) ? record.references : [];
const parsed = parseJson(value);
const record = asRecord(parsed);
const list = Array.isArray(parsed)
? parsed
: firstArray([record?.references, record?.results, record?.sources, record?.documents]);
return list.flatMap((item, index) => {
const source = asRecord(item);
if (!source) return [];
const content = asString(source.content ?? source.snippet);
const rawUrl = asString(source.url ?? source.link ?? source.href);
const recUuid = asString(source.recUuid ?? source.rec_uuid ?? source.uuid);
const content = asText(source.content ?? source.m_content ?? source.m_conent ?? source.snippet ?? source.text);
const rawUrl = asText(source.url ?? source.link ?? source.href);
const recUuid = asText(source.recUuid ?? source.rec_uuid ?? source.uuid);
return [
{
id: asString(source.id) || recUuid || undefined,
title: asString(source.title) || `参考资料 ${index + 1}`,
type: asString(source.type) || "参考资料",
snippet: asString(source.snippet) || content,
id: asText(source.id) || recUuid || undefined,
title: asText(source.title ?? source.m_title ?? source.taskName ?? source.user_name) || `参考资料 ${index + 1}`,
type: asText(source.type) || "参考资料",
snippet: asText(source.snippet) || content,
content,
source: asString(source.source ?? source.source1) || undefined,
time: asString(source.time ?? source.m_publish ?? source.publish_time) || undefined,
source: asText(source.source ?? source.source1 ?? source.m_source ?? source.platform) || undefined,
time: asText(source.time ?? source.m_publish ?? source.m_publicsh ?? source.publish_time ?? source.date) || undefined,
url: /^https?:\/\//i.test(rawUrl) ? rawUrl : undefined,
raw: source,
},
@@ -222,6 +279,71 @@ function cloneTrace(trace: MultiRoundRagTrace): MultiRoundRagTrace {
};
}
function toolTitle(toolName: string): string {
switch (toolName) {
case "local_search":
return "本地知识检索";
case "tavily_search":
return "联网检索";
case "google_search":
case "getTaskLikeTop":
return "思考过程";
case "search_user_profile":
return "靶标平台画像库";
case "get_taiwan_news":
case "search_keyword_taiwan_news":
return "台湾热点新闻";
case "get_world_news":
return "世界热点新闻";
case "get_root_messaage_comment":
return "获取该消息的全部评论";
case "search_user_behavior":
return "查询该用户的历史行为";
case "get_america_news":
return "美国热点新闻";
case "get_time_data":
return "态势新闻搜索";
case "get_textid_content":
return "获取新闻详细内容";
default:
return toolName || "检索过程";
}
}
function isSearchTool(toolName: string): boolean {
return [
"local_search",
"tavily_search",
"google_search",
"getTaskLikeTop",
"search_user_profile",
"get_taiwan_news",
"search_keyword_taiwan_news",
"get_world_news",
"get_root_messaage_comment",
"search_user_behavior",
"get_america_news",
"get_time_data",
].includes(toolName);
}
function queryFromToolInput(input: Record<string, unknown>, result: unknown): string {
const resultRecord = asRecord(parseJson(result));
const direct = asText(input.q ?? input.query ?? input.keyword ?? input.user_fig);
if (direct) return direct;
if (input.s || input.e) {
return [asText(input.s), asText(input.e)].filter(Boolean).join(" - ");
}
return asText(resultRecord?.query ?? resultRecord?.q);
}
function textFromEventData(value: unknown): string {
const parsed = parseJson(value);
if (typeof parsed === "string") return parsed;
const record = asRecord(parsed);
return asText(record?.text ?? record?.content ?? record?.message ?? record?.answer ?? record?.output);
}
function isSingleRoundRagMessage(message: Message) {
return message.additional_kwargs?.single_round_rag === true;
}
@@ -370,92 +492,204 @@ export function useMultiRoundRagMessages(threadId: string) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let activeThoughtRound: MultiRoundRagRound | null = null;
let activeThoughtTailIsAnswer = false;
let answerFromShowText = false;
const appendAnswer = (text: string, allowCompleteSnapshot = false) => {
if (!text) return;
// Some gateways emit one complete `message` after a `show_text`
// answer. Restrict snapshot detection to that case and to a
// meaningful length: adjacent streaming chunks can legitimately
// contain the same character, and must never be discarded.
if (
allowCompleteSnapshot &&
trace.answer.length >= 16 &&
text.startsWith(trace.answer)
) {
trace.answer = text;
return;
}
trace.answer += text;
};
const consumeShowText = (text: string, tailIsAnswer = false) => {
if (!text) return;
if (activeThoughtRound) {
const endIndex = text.indexOf("</think>");
if (endIndex < 0) {
activeThoughtRound.thought += text;
return;
}
activeThoughtRound.thought += text.slice(0, endIndex);
const roundBody = text.slice(endIndex + "</think>".length).trim();
if (roundBody) {
if (activeThoughtTailIsAnswer) {
appendAnswer(roundBody);
answerFromShowText = false;
trace.isComplete = true;
} else {
appendRoundBody(trace, roundBody);
}
}
activeThoughtRound = null;
activeThoughtTailIsAnswer = false;
return;
}
const thinkStart = text.indexOf("<think>");
if (thinkStart < 0) {
const current = latestRound(trace);
current.isSearching = false;
appendAnswer(text);
answerFromShowText = true;
trace.isComplete = true;
return;
}
const prefix = text.slice(0, thinkStart).trim();
if (prefix) appendRoundBody(trace, prefix);
// A full <think>…</think> envelope is normally delivered as one
// tool call. Keep the unfinished variant open as well: production
// proxies are allowed to split an SSE frame at any byte boundary.
const thinkEnd = text.indexOf("</think>", thinkStart + "<think>".length);
const thought = text
.slice(thinkStart + "<think>".length, thinkEnd < 0 ? undefined : thinkEnd)
.trim();
const thoughtRound = addThinkingRound(trace, thought);
if (thinkEnd < 0) {
activeThoughtRound = thoughtRound;
activeThoughtTailIsAnswer = tailIsAnswer;
return;
}
const roundBody = text.slice(thinkEnd + "</think>".length).trim();
if (roundBody) {
if (tailIsAnswer) {
appendAnswer(roundBody);
answerFromShowText = false;
trace.isComplete = true;
} else {
appendRoundBody(trace, roundBody);
}
}
};
const consumeToolCall = (payload: unknown) => {
const parsedPayload = parseJson(payload);
const tools = Array.isArray(parsedPayload) ? parsedPayload : [parsedPayload];
for (const toolValue of tools) {
const tool = asRecord(toolValue);
if (!tool) continue;
const toolName = asText(tool.tool_name ?? tool.name);
const input = asRecord(parseJson(tool.tool_input ?? tool.input ?? tool.arguments)) ?? {};
const current = latestRound(trace);
if (toolName === "show_text") {
consumeShowText(asText(input.text ?? input.content));
continue;
}
if (toolName === "get_textid_content") {
current.title = toolTitle(toolName);
const article = asRecord(parseJson(input.result));
const body = asText(article?.content ?? article?.text ?? input.content ?? input.result);
if (body) appendRoundBody(trace, body);
current.isSearching = false;
continue;
}
const hasResult = input.result !== undefined;
const query = queryFromToolInput(input, input.result);
if (isSearchTool(toolName) || hasResult) {
current.title = toolTitle(toolName);
current.query = query || current.query;
if (!hasResult && isSearchTool(toolName)) {
// GeneralQA can receive a model text event before the search
// tool announces its query. The query starts a new visible
// loading phase, so restore the round/trace state here and
// keep the query badge spinning until its result arrives.
current.isSearching = true;
trace.isComplete = false;
} else if (hasResult) {
const retrievals = mapRetrievals(input.result);
// An empty search response is still a completed response; it
// must replace a previous list instead of leaving stale cards.
current.retrievals = retrievals;
if (retrievals.length === 0) {
const fallbackBody = textFromEventData(input.result);
if (fallbackBody && fallbackBody !== current.query) {
appendRoundBody(trace, fallbackBody);
}
}
current.isSearching = false;
}
}
}
};
const finishTrace = () => {
trace.isComplete = true;
activeThoughtRound = null;
activeThoughtTailIsAnswer = false;
for (const round of trace.rounds) round.isSearching = false;
};
const consumeEvent = (event: MultiRoundEvent) => {
const eventType = asText(event.event);
const data = event.data;
if (eventType === "tool_call") {
consumeToolCall(data);
} else if (eventType === "message") {
const text = textFromEventData(data);
if (text) {
if (activeThoughtRound || /<\/?think>/i.test(text)) {
// Most deployments put thoughts in `show_text`, but this
// fallback also preserves a compatible gateway that emits
// `<think>` in normal message chunks.
consumeShowText(text, true);
} else {
latestRound(trace).isSearching = false;
appendAnswer(text, answerFromShowText);
answerFromShowText = false;
trace.isComplete = true;
}
}
} else if (eventType === "references") {
sources = mapReferences(data);
} else if (eventType === "error") {
const message = textFromEventData(data) || "多轮 RAG 服务返回错误。";
setError(message);
if (!trace.answer) trace.answer = message;
finishTrace();
} else if (eventType === "done") {
// A few compatible gateway versions include the whole final
// answer in `done`; render it when no `message` chunk was sent.
const finalText = textFromEventData(data);
if (finalText && !trace.answer) appendAnswer(finalText);
finishTrace();
}
refreshAssistant();
};
const consumeLines = (flush = false) => {
const lines = buffer.split(/\r?\n/);
buffer = flush ? "" : (lines.pop() ?? "");
for (const line of lines) {
const event = parseEventLine(line);
if (event) consumeEvent(event);
}
};
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split(/\r?\n/);
buffer = lines.pop() ?? "";
for (const line of lines) {
const event = parseEventLine(line);
if (!event) continue;
const eventType = asString(event.event);
const data = event.data;
if (eventType === "tool_call") {
const tool = asRecord(data);
const toolName = asString(tool?.tool_name);
const input = asRecord(tool?.tool_input) ?? {};
const current = latestRound(trace);
if (toolName === "show_text") {
const text = asString(input.text);
if (text.includes("<think>")) {
const thinkEnd = text.indexOf("</think>");
addThinkingRound(
trace,
(thinkEnd >= 0
? text.slice(0, thinkEnd + "</think>".length)
: text
)
.replace(/<\/?think>/g, "")
.trim(),
);
if (thinkEnd >= 0) {
const roundBody = text
.slice(thinkEnd + "</think>".length)
.trim();
if (roundBody) {
appendRoundBody(trace, roundBody);
}
}
} else {
// A non-thinking `show_text` immediately after a thought is
// this round's narrative; the terminal `message` event is
// still rendered only as the final answer below all rounds.
current.isSearching = false;
trace.answer += text;
trace.isComplete = true;
}
} else if (
toolName === "local_search" ||
toolName === "tavily_search" ||
toolName === "google_search"
) {
const queryData = asRecord(parseJson(input.result));
current.title =
toolName === "local_search" ? "本地知识检索" : "联网检索";
current.query =
asString(input.q ?? input.query) ||
asString(queryData?.query) ||
current.query;
const retrievals = mapRetrievals(input.result);
if (retrievals.length > 0) current.retrievals = retrievals;
current.isSearching = false;
} else if (toolName === "get_textid_content") {
const article = asRecord(parseJson(input.result));
const body =
asString(article?.content ?? article?.text) ||
asString(input.content);
if (body) appendRoundBody(trace, body);
current.isSearching = false;
}
refreshAssistant();
} else if (eventType === "message" && typeof data === "string") {
latestRound(trace).isSearching = false;
trace.answer += data;
trace.isComplete = true;
refreshAssistant();
} else if (eventType === "references") {
sources = mapReferences(data);
refreshAssistant();
} else if (eventType === "done") {
trace.isComplete = true;
for (const round of trace.rounds) round.isSearching = false;
refreshAssistant();
}
}
consumeLines();
}
trace.isComplete = true;
for (const round of trace.rounds) round.isSearching = false;
buffer += decoder.decode();
consumeLines(true);
finishTrace();
refreshAssistant();
} catch (caught) {
trace.isComplete = true;
@@ -2,10 +2,11 @@ import type { Message } from "@langchain/langgraph-sdk";
import { useCallback, useEffect, useRef, useState } from "react";
import { readRuntimeEnv } from "@/env";
import { createClientId } from "@/lib/client-id";
const STORAGE_PREFIX = "workspace.single-round-rag.";
type KnowledgeBaseChunk = {
type KnowledgeBaseChunk = Record<string, unknown> & {
answer?: unknown;
docs?: unknown;
};
@@ -168,38 +169,116 @@ function parseKnowledgeBaseChunk(jsonText: string): KnowledgeBaseChunk {
}
}
function flattenDocs(value: unknown): Record<string, unknown>[] {
if (Array.isArray(value)) {
return value.flatMap((item) => flattenDocs(item));
function readString(...values: unknown[]): string | undefined {
for (const value of values) {
if (typeof value === "string" && value.trim()) return value;
}
if (!value || typeof value !== "object") return [];
const record = value as Record<string, unknown>;
if ("docs" in record) return flattenDocs(record.docs);
return [record];
return undefined;
}
function normalizeSources(value: unknown): RagSource[] {
return flattenDocs(value).map((item, index) => {
const title = String(item.m_title ?? item.title ?? item.source ?? `参考资料 ${index + 1}`);
const content = String(item.m_content ?? item.content ?? item.snippet ?? "");
const source = item.source1 ?? item.source;
const rawUrl = item.url ?? item.link ?? item.href;
return {
id: String(item.recUuid ?? item.rec_uuid ?? item.uuid ?? item.id ?? "") || undefined,
title,
type: String(item.source1 ?? item.type ?? "知识库"),
snippet: content,
content,
source: typeof source === "string" ? source : undefined,
time: typeof item.m_publish === "string" ? item.m_publish : undefined,
url: typeof rawUrl === "string" && /^https?:\/\//i.test(rawUrl) ? rawUrl : undefined,
raw: item,
};
function docsFallbackContent(docs: unknown): string | undefined {
const candidates = Array.isArray(docs) ? docs : [docs];
return readString(...candidates);
}
function buildKnowledgeDetailUrl(recUuid: string | undefined): string | undefined {
const knowledgeUrl = readRuntimeEnv("VITE_AIPI_KNOWLEDGE_URL")?.replace(/\/$/, "");
if (!knowledgeUrl || !recUuid) return undefined;
return `${knowledgeUrl}/#/knowledge/mid-detail?recUuid=${encodeURIComponent(recUuid)}`;
}
function toRagSource(
record: Record<string, unknown>,
index: number,
fallbackContent?: string,
): RagSource {
const title =
readString(record.m_title, record.title, record.source, record.source1) ??
`参考资料 ${index + 1}`;
const content =
readString(
record.m_content,
// Some deployed responses spell this field incorrectly; keep it as a
// read-only compatibility alias rather than dropping the document.
record.m_conent,
record.content,
record.snippet,
fallbackContent,
record.url,
) ?? "";
const source = readString(record.source1, record.source, record.m_source);
const rawUrl = readString(record.url, record.link, record.href);
const recUuid = readString(record.recUuid, record.rec_uuid, record.uuid, record.id);
return {
id: recUuid,
title,
type: readString(record.type, record.source1) ?? "知识库",
snippet: content,
content,
source,
time: readString(record.m_publish, record.m_publicsh, record.time),
// GeneralQA uses recUuid to open the knowledge-detail page. Prefer that
// stable internal record link, then fall back to a backend-provided URL.
url:
buildKnowledgeDetailUrl(recUuid) ??
(rawUrl && /^https?:\/\//i.test(rawUrl) ? rawUrl : undefined),
raw: record,
};
}
/**
* A single-RAG stream is a concatenation of source envelopes, for example:
* `{docs:["..."], recUuid:"...", m_title:"...", m_content:"..."}`.
* The root-level metadata is the source; `docs` merely marks the envelope and
* supplies a fallback body. This mirrors GeneralQA's per-chunk processing.
*/
function normalizeSources(chunk: KnowledgeBaseChunk): RagSource[] {
const hasRootMetadata = [
chunk.recUuid,
chunk.rec_uuid,
chunk.m_title,
chunk.m_content,
chunk.m_conent,
chunk.source1,
chunk.m_publish,
chunk.m_publicsh,
].some((value) => typeof value === "string" && value.trim());
if (hasRootMetadata) {
return [toRagSource(chunk, 0, docsFallbackContent(chunk.docs))];
}
const docs = Array.isArray(chunk.docs) ? chunk.docs : chunk.docs ? [chunk.docs] : [];
return docs.flatMap((item, index): RagSource[] => {
if (typeof item === "string") {
return [toRagSource({}, index, item)];
}
if (!item || typeof item !== "object") return [];
return [toRagSource(item as Record<string, unknown>, index)];
});
}
function mergeSources(current: RagSource[], incoming: RagSource[]): RagSource[] {
const result = [...current];
const keys = new Set(
current.map((source) =>
source.id
? `id:${source.id}`
: `content:${source.title}\u0000${source.content}\u0000${source.source ?? ""}`,
),
);
for (const source of incoming) {
const key = source.id
? `id:${source.id}`
: `content:${source.title}\u0000${source.content}\u0000${source.source ?? ""}`;
if (keys.has(key)) continue;
keys.add(key);
result.push(source);
}
return result;
}
function createId(prefix: string) {
return `${prefix}-${crypto.randomUUID()}`;
return createClientId(prefix);
}
function isAbortError(error: unknown) {
@@ -397,7 +476,9 @@ export function useSingleRoundRagMessages(threadId: string) {
for (const jsonText of extracted.objects) {
const chunk = parseKnowledgeBaseChunk(jsonText);
if (typeof chunk.answer === "string") answerChunks.push({ answer: chunk.answer });
if (chunk.docs !== undefined) sources = normalizeSources(chunk.docs);
if (chunk.docs !== undefined) {
sources = mergeSources(sources, normalizeSources(chunk));
}
refreshAssistant();
}
}
@@ -0,0 +1,310 @@
import { getBackendBaseURL } from "@/core/config";
import { readRuntimeEnv } from "@/env";
export interface EightBfTask {
id: string;
externalTaskId: string;
title: string;
description: string;
direction: string;
overview: string;
priority: string;
taskPriority: string;
targetDirection: string;
taskStatus: string;
source: string;
raw: Record<string, unknown> | null;
}
export interface EightBfDictionaryOption {
label: string;
value: string;
alias?: string;
statusTone?: string;
}
export interface EightBfTaskDictionaries {
priority: EightBfDictionaryOption[];
direction: EightBfDictionaryOption[];
status: EightBfDictionaryOption[];
}
export interface EightBfTaskContextPayload {
kind: "eightbf_roundtable_task";
usage: string;
task_id: string;
task: {
id: string;
title: string;
description: string;
direction: string;
overview: string;
priority: string;
task_priority: string;
target_direction: string;
task_status: string;
};
task_tags: {
priority: string;
direction: string;
status: string;
};
}
/**
* 外部任务系统在离线部署或本地联调时可能不可用。此回退仅用于页面展示与
* 意图上下文,保持与岗位会商示例一致,避免左上任务卡退化成接口错误文本。
*/
export function createEightBfFallbackTask(taskId: string): EightBfTask {
return {
id: taskId,
externalTaskId: taskId,
title: "暑期活动",
description: "围绕暑期活动的游泳夏令营开展宣传,结合线上线下渠道吸引目标人群参与,持续提升活动影响力。",
direction: "暑期活动",
overview: "面向家长群体传播暑期游泳夏令营活动信息,提升品牌曝光与触达声量。",
priority: "urgent",
taskPriority: "urgent",
targetDirection: "summer_activity",
taskStatus: "system_created",
source: "本系统创建",
raw: null,
};
}
const TASK_DETAIL_PATH = "/task/{taskId}";
const DICTIONARY_TYPES = {
priority: "task_priority",
direction: "task_direction",
status: "task_status",
} as const;
function externalApiBase(): string {
// The position-roundtable Vue app calls this task system through
// VITE_API_BASE_URL (usually the /zncm gateway), not DeerFlow's own API.
return (
readRuntimeEnv("VITE_8BF_TASK_API_BASE_URL")?.replace(/\/+$/, "") ||
readRuntimeEnv("VITE_API_BASE_URL")?.replace(/\/+$/, "") ||
getBackendBaseURL().replace(/\/+$/, "")
);
}
function externalUrl(path: string): string {
if (/^https?:\/\//i.test(path)) return path;
return `${externalApiBase()}${path.startsWith("/") ? path : `/${path}`}`;
}
/**
* 当前 DeerFlow 的外部业务请求适配器沿用任务系统的默认约定:使用
* `Authorization: Bearer admin`,而不是 DeerFlow 登录接口返回的 token。
* 两套 token 的用途不同,不能直接复用 `apiFetch` 的默认鉴权头;否则
* 任务详情/字典接口可能返回 401,页面只会看到空任务卡。
*
* 这里仍保留 include credentials,与原 Vue 请求保持一致;同时不走
* `apiFetch` 的 401 自动清理逻辑,避免外部任务系统的鉴权失败误登出
* DeerFlow 当前用户。
*/
async function fetchExternalTaskSystem(
path: string,
init: RequestInit = {},
): Promise<Response> {
const headers = new Headers(init.headers);
if (!headers.has("Authorization")) {
headers.set("Authorization", "Bearer admin");
}
if (!headers.has("Accept")) {
headers.set("Accept", "application/json");
}
return fetch(externalUrl(path), {
...init,
credentials: init.credentials ?? "include",
headers,
});
}
function firstText(...values: unknown[]): string {
for (const value of values) {
if (value === undefined || value === null) continue;
const text = String(value).trim();
if (text) return text;
}
return "";
}
function unwrapData(payload: unknown): Record<string, unknown> {
let current: unknown = payload;
// Task services in different deployments wrap the object as either
// { data: task } or { data: { data: task } }.
for (let index = 0; index < 3; index += 1) {
if (!current || typeof current !== "object" || Array.isArray(current)) break;
const next = (current as { data?: unknown }).data;
if (!next || typeof next !== "object" || Array.isArray(next)) break;
current = next;
}
return current && typeof current === "object" && !Array.isArray(current)
? (current as Record<string, unknown>)
: {};
}
export function normalizeEightBfTask(
raw: Record<string, unknown> | null | undefined,
fallbackTaskId = "",
): EightBfTask {
const source = raw ?? {};
const id = firstText(
source.id,
source.taskId,
source.externalTaskId,
source.external_task_id,
fallbackTaskId,
);
return {
id,
externalTaskId: firstText(source.externalTaskId, source.external_task_id, id),
title: firstText(source.title, source.taskName, source.task_name, source.name),
description: firstText(
source.description,
source.taskDescription,
source.task_description,
source.taskContent,
source.task_content,
source.content,
),
direction: firstText(
source.direction,
source.taskDirection,
source.task_direction,
),
overview: firstText(source.overview, source.summary),
priority: firstText(source.priority, source.urgency),
taskPriority: firstText(
source.taskPriority,
source.task_priority,
source.priority,
source.urgency,
),
targetDirection: firstText(
source.targetDirection,
source.target_direction,
source.direction,
source.taskDirection,
source.task_direction,
),
taskStatus: firstText(source.taskStatus, source.task_status, source.status),
source: "测试分析岗任务详情接口",
raw: source,
};
}
function dictionaryRows(payload: unknown): unknown[] {
if (Array.isArray(payload)) return payload;
if (payload && typeof payload === "object") {
const directData = (payload as { data?: unknown }).data;
if (Array.isArray(directData)) return directData;
if (directData && typeof directData === "object") {
const nested = directData as { rows?: unknown; list?: unknown };
if (Array.isArray(nested.rows)) return nested.rows;
if (Array.isArray(nested.list)) return nested.list;
}
}
const data = unwrapData(payload);
if (Array.isArray(data.rows)) return data.rows;
if (Array.isArray(data.list)) return data.list;
return [];
}
function normalizeDictionary(payload: unknown): EightBfDictionaryOption[] {
return dictionaryRows(payload)
.map((item) => {
if (!item || typeof item !== "object") return null;
const row = item as Record<string, unknown>;
const label = firstText(row.dictLabel, row.label);
const value = firstText(row.dictValue, row.dict_value, row.value);
if (!label || !value) return null;
return {
label,
value,
alias: firstText(row.alias) || undefined,
statusTone: firstText(row.statusTone, row.status_tone) || undefined,
} as EightBfDictionaryOption;
})
.filter((item): item is EightBfDictionaryOption => item !== null);
}
export async function fetchEightBfTask(taskId: string): Promise<EightBfTask> {
const encodedId = encodeURIComponent(taskId);
const path = TASK_DETAIL_PATH.replaceAll("{taskId}", encodedId);
const response = await fetchExternalTaskSystem(path, { method: "GET" });
if (!response.ok) {
throw new Error(`任务详情接口请求失败(${response.status})`);
}
const payload = (await response.json()) as unknown;
const task = normalizeEightBfTask(unwrapData(payload), taskId);
// Keep the external route id as the stable scope even if a deployment's
// response includes a legacy/internal numeric id.
return { ...task, id: taskId, externalTaskId: taskId };
}
export async function fetchEightBfTaskDictionaries(): Promise<EightBfTaskDictionaries> {
const entries = await Promise.all(
(Object.entries(DICTIONARY_TYPES) as Array<[
keyof EightBfTaskDictionaries,
string,
]>).map(async ([key, path]) => {
try {
const response = await fetchExternalTaskSystem(
`/system/dict/data/type/${path}`,
{ method: "GET" },
);
if (!response.ok) throw new Error(`字典接口请求失败(${response.status})`);
return [key, normalizeDictionary(await response.json())] as const;
} catch (error) {
// A dictionary failure must not hide the task card; the card falls back
// to the raw task codes and the detail request remains usable.
console.warn(`[8bf] ${key} 字典加载失败`, error);
return [key, []] as const;
}
}),
);
return Object.fromEntries(entries) as EightBfTaskDictionaries;
}
export function dictionaryText(
options: EightBfDictionaryOption[] | undefined,
value: unknown,
preferredField: "label" | "alias" = "label",
): string {
const raw = firstText(value);
if (!raw) return "";
const match = options?.find((option) => option.value === raw);
return firstText(match?.[preferredField], match?.label, raw);
}
export function buildEightBfTaskContext(
task: EightBfTask | null,
dictionaries: EightBfTaskDictionaries,
): EightBfTaskContextPayload | null {
if (!task?.id) return null;
return {
kind: "eightbf_roundtable_task",
usage:
"这是 8BF 页面左上角任务卡片中的任务信息。请直接基于这些信息理解用户问题,不要要求用户重复提供已经存在的任务名称、任务描述、任务方向或任务状态。",
task_id: task.id,
task: {
id: task.id,
title: task.title,
description: task.description,
direction: task.direction,
overview: task.overview,
priority: task.priority,
task_priority: task.taskPriority,
target_direction: task.targetDirection,
task_status: task.taskStatus,
},
task_tags: {
priority: dictionaryText(dictionaries.priority, task.taskPriority),
direction: dictionaryText(dictionaries.direction, task.targetDirection, "alias"),
status: dictionaryText(dictionaries.status, task.taskStatus),
},
};
}
@@ -0,0 +1,451 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import {
Bot,
ChevronDown,
ChevronLeft,
Loader2,
Search,
Settings2,
UserRoundCog,
} from "lucide-react";
import { toast } from "sonner";
import { AgentDetailPanel } from "@/components/workspace/agents/agent-detail-panel";
import { useAgent, useAgents, type Agent } from "@/core/agents";
import agentConfigLogo from "@/assets/position-roundtable/agent-config-logo.svg";
import collapsePanelIcon from "@/assets/position-roundtable/collapse-panel.svg";
import doubleArrowDecoration from "@/assets/position-roundtable/double-arrow-decoration.svg";
import { PositionIntentInput } from "@/position-roundtable/components/PositionIntentInput";
import "@/position-roundtable/styles/intent-understanding.css";
import type { Step1PanelProps } from "@/roundtable-planning/components/Step1Panel";
import { Step1Panel } from "@/roundtable-planning/components/Step1Panel";
import type { BusinessChain, ChainSeat } from "@/roundtable-planning/lib/business-chain";
import { cn } from "@/lib/utils";
type IntentRecord = Record<string, unknown>;
export interface EightBfStep1PanelProps extends Step1PanelProps {
chain: BusinessChain | null;
canConfigureChain: boolean;
onConfigureChain: () => void;
selectedModel: string;
modelOptions: Array<{ label: string; value: string }>;
onSelectedModelChange: (model: string) => void;
}
export function EightBfStep1Panel({
chain,
canConfigureChain,
onConfigureChain,
selectedModel,
modelOptions,
onSelectedModelChange,
intentReady,
...step1Props
}: EightBfStep1PanelProps) {
const [rightPanelOpen, setRightPanelOpen] = useState(true);
const [rightTab, setRightTab] = useState<"agents" | "results">("agents");
const openPanel = (tab: "agents" | "results") => {
if (rightPanelOpen && rightTab === tab) {
setRightPanelOpen(false);
return;
}
setRightTab(tab);
setRightPanelOpen(true);
};
return (
<div className="position-roundtable-intent-page eightbf-roundtable-intent-page contents">
<Step1Panel
{...step1Props}
rootClassName="position-roundtable-intent-panel eightbf-roundtable-intent-panel [&_#save-draft-btn]:hidden [&_#skip-question-btn]:hidden [&_#suggest-followup-btn]:hidden"
headerLeftSlot={
<div className="position-roundtable-intent-header-title">
<span aria-hidden="true" />
<h2>{chain?.title || "8BF 业务链"} · 意图识别</h2>
</div>
}
headerRightSlot={
<div className="position-roundtable-intent-header-actions">
<button
type="button"
className={cn(rightPanelOpen && rightTab === "agents" && "is-selected")}
onClick={() => openPanel("agents")}
>
智能体配置
</button>
<button
type="button"
className={cn(rightPanelOpen && rightTab === "results" && "is-selected")}
onClick={() => openPanel("results")}
>
分析结果
</button>
</div>
}
rightSidebar={
<aside
aria-hidden={!rightPanelOpen || undefined}
className={cn(
"position-roundtable-intent-config-pane eightbf-roundtable-intent-config-pane",
rightPanelOpen ? "is-open" : "is-closed",
)}
>
<div className="position-roundtable-intent-config-pane-inner">
<EightBfAgentWorkspace
tab={rightTab}
chain={chain}
intentReady={intentReady}
canConfigureChain={canConfigureChain}
onConfigureChain={onConfigureChain}
onCollapse={() => setRightPanelOpen(false)}
/>
</div>
</aside>
}
hideQuestionCount
inputArea={(inputProps) => (
<PositionIntentInput
{...inputProps}
modelOptions={modelOptions}
selectedModel={selectedModel}
onSelectedModelChange={onSelectedModelChange}
/>
)}
intentReady={intentReady}
/>
</div>
);
}
export function EightBfStep1Sidebar({
taskCard,
businessChain,
intentReady,
}: {
taskCard?: ReactNode;
businessChain?: ReactNode;
intentReady: object | null;
}) {
return (
<div className="eightbf-roundtable__step1-sidebar position-roundtable-entry-sidebar">
{taskCard}
<EightBfIntentDelivery intentReady={intentReady} businessChain={businessChain} />
</div>
);
}
/** 第一、二步共用的意图结果面板,保持完全相同的展示结构和样式。 */
export function EightBfIntentDelivery({
intentReady,
businessChain,
}: {
intentReady: object | null;
businessChain?: ReactNode;
}) {
const sections = intentSections(intentReady);
return (
<section className="eightbf-roundtable__intent-delivery-sidebar position-roundtable-intent-delivery-sidebar" aria-labelledby="eightbf-intent-sidebar-title">
{sections.length > 0 && (
<div className="position-roundtable-intent-delivery-result">
<header className="position-roundtable-intent-delivery-header">
<span aria-hidden="true" />
<h2 id="eightbf-intent-sidebar-title">意图理解结果</h2>
</header>
<div className="position-roundtable-intent-delivery-sections">
{sections.map((section) => (
<article
key={section.label}
className={cn(
"position-roundtable-intent-delivery-section",
intentSectionTone(section.label),
)}
>
<h3>{section.label}</h3>
<ul>
{section.items.map((item) => <li key={item}>{item}</li>)}
</ul>
</article>
))}
</div>
</div>
)}
{businessChain}
</section>
);
}
/** 第二步不需要第一步的整列占位,只复用其任务卡和意图面板。 */
export function EightBfStep2SidebarHeader({
taskCard,
intentReady,
}: {
taskCard?: ReactNode;
intentReady: object | null;
}) {
return (
<div className="eightbf-roundtable__step2-sidebar-header">
{taskCard}
<EightBfIntentDelivery intentReady={intentReady} />
</div>
);
}
export function EightBfHeaderBrand({ chainTitle }: { chainTitle?: string }) {
return (
<div className="eightbf-roundtable__header-brand">
<span className="eightbf-roundtable__header-brand-icon" aria-hidden="true">
<UserRoundCog />
</span>
<div className="min-w-0">
<h1>8BF 多智能体会商</h1>
<p>按业务链承接任务、对话与分析产物</p>
</div>
<span className="eightbf-roundtable__header-status">
<span aria-hidden="true">⌁</span>
{chainTitle ? "业务链已启动" : "等待业务链配置"}
</span>
</div>
);
}
export function EightBfHeaderChain({ chainTitle }: { chainTitle?: string }) {
return (
<span className="eightbf-roundtable__header-chain" title={chainTitle || "尚未配置业务链"}>
<span>当前业务链</span>
<strong>{chainTitle || "暂未配置"}</strong>
<ChevronDown aria-hidden="true" />
</span>
);
}
function EightBfAgentWorkspace({
tab,
chain,
intentReady,
canConfigureChain,
onConfigureChain,
onCollapse,
}: {
tab: "agents" | "results";
chain: BusinessChain | null;
intentReady: object | null;
canConfigureChain: boolean;
onConfigureChain: () => void;
onCollapse: () => void;
}) {
const { agents, isLoading } = useAgents();
const seats = chain?.seats ?? [];
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
const { agent: selectedAgent, isLoading: selectedAgentLoading } = useAgent(selectedAgentId);
useEffect(() => {
if (selectedAgentId && !seats.some((seat) => seat.agent_id === selectedAgentId)) {
setSelectedAgentId(null);
}
}, [seats, selectedAgentId]);
const seatAgents = useMemo(() => {
const agentById = new Map(agents.map((agent) => [agent.id, agent]));
return seats.map((seat) => ({ seat, agent: agentById.get(seat.agent_id) ?? null }));
}, [agents, seats]);
const selectedSeat = seats.find((seat) => seat.agent_id === selectedAgentId) ?? null;
if (tab === "results") {
return (
<section className="position-roundtable-intent-analysis-results eightbf-roundtable-intent-analysis-results">
<div className="position-roundtable-intent-analysis-header">
<span>分析结果</span>
<button
type="button"
className="position-roundtable-intent-config-collapse-btn"
onClick={onCollapse}
aria-label="收起分析结果"
title="收起分析结果"
>
<img src={collapsePanelIcon} alt="" aria-hidden="true" />
</button>
</div>
<div className="position-roundtable-intent-analysis-results-scroll eightbf-roundtable-intent-results-scroll">
<EightBfIntentResults intentReady={intentReady} />
</div>
</section>
);
}
if (selectedAgentId) {
return (
<div className="position-roundtable-intent-config-workspace">
<div className="eightbf-roundtable__agent-detail-topbar">
<button type="button" onClick={() => setSelectedAgentId(null)}>
<ChevronLeft className="size-4" />
智能体列表
</button>
<button
type="button"
className="position-roundtable-intent-config-collapse-btn"
onClick={onCollapse}
aria-label="收起智能体配置"
title="收起智能体配置"
>
<img src={collapsePanelIcon} alt="" aria-hidden="true" />
</button>
</div>
<div className="position-roundtable-agent-detail position-roundtable-agent-detail--special h-[calc(100%-48px)] min-h-0">
{selectedAgentLoading ? (
<div className="flex h-full items-center justify-center gap-2 text-sm text-[#aaceff]">
<Loader2 className="size-4 animate-spin" /> 正在读取智能体配置…
</div>
) : (
<AgentDetailPanel
agent={selectedAgent}
agentName={selectedAgent?.name ?? selectedSeat?.name ?? ""}
onAskQuestion={() => toast.info("请在中间对话区向任务理解智能体发起问题")}
showRecentChats={false}
avatarSrc={agentConfigLogo}
positionName={chain?.title || "8BF 业务链"}
skillsIcon={<img src={doubleArrowDecoration} alt="" aria-hidden="true" />}
skillsTitle="技能列表"
showCommonQuestions
panelTitle="智能体配置"
embedded
hideStatusBadges
/>
)}
</div>
</div>
);
}
return (
<section className="eightbf-roundtable__agent-list-workspace">
<header>
<div>
<h3>智能体配置</h3>
<p>{chain?.title ? `业务链「${chain.title}」已配置的智能体` : "请先配置 8BF 业务链"}</p>
</div>
<div className="flex items-center gap-1.5">
{canConfigureChain && (
<button type="button" className="eightbf-roundtable__configure-chain-button" onClick={onConfigureChain}>
<Settings2 className="size-3.5" /> 配置业务链
</button>
)}
<button
type="button"
className="position-roundtable-intent-config-collapse-btn"
onClick={onCollapse}
aria-label="收起智能体配置"
title="收起智能体配置"
>
<img src={collapsePanelIcon} alt="" aria-hidden="true" />
</button>
</div>
</header>
<div className="eightbf-roundtable__agent-list-search">
<Search className="size-3.5" />
<span>业务链智能体</span>
<span>{seatAgents.length} 个</span>
</div>
<div className="eightbf-roundtable__agent-list-scroll">
{isLoading && seats.length > 0 ? (
<div className="eightbf-roundtable__agent-list-loading"><Loader2 className="size-4 animate-spin" /> 正在加载智能体…</div>
) : seats.length === 0 ? (
<div className="eightbf-roundtable__agent-list-empty">当前业务链未配置智能体</div>
) : (
seatAgents.map(({ seat, agent }, index) => (
<EightBfAgentRow
key={seat.agent_id}
seat={seat}
agent={agent}
index={index}
onSelect={() => setSelectedAgentId(seat.agent_id)}
/>
))
)}
</div>
</section>
);
}
function EightBfAgentRow({
seat,
agent,
index,
onSelect,
}: {
seat: ChainSeat;
agent: Agent | null;
index: number;
onSelect: () => void;
}) {
const name = agent?.name || seat.name || "未命名智能体";
const description = agent?.description || seat.description || "查看该智能体的模型、技能与常用问题配置";
return (
<button type="button" className="eightbf-roundtable__agent-list-row" onClick={onSelect}>
<span className="eightbf-roundtable__agent-list-avatar" aria-hidden="true"><Bot /></span>
<span className="min-w-0 flex-1 text-left">
<strong>{name}</strong>
<small>{description}</small>
</span>
<span className="eightbf-roundtable__agent-list-index">{String(index + 1).padStart(2, "0")}</span>
</button>
);
}
function EightBfIntentResults({ intentReady }: { intentReady: object | null }) {
const sections = intentSections(intentReady);
if (sections.length === 0) {
return <p className="eightbf-roundtable__intent-results-empty">完成意图识别后,将在这里展示任务意图与关键约束。</p>;
}
return (
<div className="eightbf-roundtable__intent-results-list">
{sections.map((section) => (
<section key={section.label}>
<h3>{section.label}</h3>
<ul>{section.items.map((item) => <li key={item}>{item}</li>)}</ul>
</section>
))}
</div>
);
}
function intentSections(intentReady: object | null): Array<{ label: string; items: string[] }> {
const source = intentReady && typeof intentReady === "object" ? intentReady as IntentRecord : null;
if (!source) return [];
const entries = [
["核心目标", arrayValue(source.coreGoals, source.objective)],
["风险提示", arrayValue(source.riskWarnings, source.constraints)],
["关键要点", arrayValue(source.keyPoints, source.assumptions)],
["战略意义", arrayValue(source.strategicSignificance)],
] as const;
return entries
.filter(([, items]) => items.length > 0)
.map(([label, items]) => ({ label, items }));
}
function intentSectionTone(label: string) {
switch (label) {
case "核心目标":
return "position-roundtable-intent-delivery-section--goals";
case "风险提示":
return "position-roundtable-intent-delivery-section--constraints";
case "关键要点":
return "position-roundtable-intent-delivery-section--purpose";
case "战略意义":
return "position-roundtable-intent-delivery-section--significance";
default:
return "position-roundtable-intent-delivery-section--significance";
}
}
function arrayValue(...values: unknown[]): string[] {
for (const value of values) {
if (Array.isArray(value)) {
const items = value.map((item) => String(item).trim()).filter(Boolean);
if (items.length > 0) return items;
}
if (typeof value === "string" && value.trim()) return [value.trim()];
}
return [];
}
@@ -0,0 +1,97 @@
import { cn } from "@/lib/utils";
import collapsePanelIcon from "@/assets/position-roundtable/collapse-panel.svg";
import {
dictionaryText,
type EightBfDictionaryOption,
type EightBfTask,
} from "@/eightbf-roundtable/api/task-context";
interface EightBfTaskCardProps {
task: EightBfTask | null;
dictionaries: {
priority: EightBfDictionaryOption[];
direction: EightBfDictionaryOption[];
status: EightBfDictionaryOption[];
};
loading?: boolean;
error?: string | null;
/** 第二步左栏使用,与“智能体配置”同款图标收起整列。 */
onCollapse?: () => void;
}
export function EightBfTaskCard({
task,
dictionaries,
loading = false,
error,
onCollapse,
}: EightBfTaskCardProps) {
const priority = dictionaryText(dictionaries.priority, task?.taskPriority);
const direction = dictionaryText(dictionaries.direction, task?.targetDirection, "alias");
const status = dictionaryText(dictionaries.status, task?.taskStatus);
// 外部任务接口不可用时,卡片使用与岗位会商相同的示例任务;标签也必须
// 保持可读,而不能暴露内部状态码。
const tags = task?.raw === null
? ["紧急", "本系统创建", "暑期活动"]
: [priority, status, direction].filter(Boolean);
return (
<section
className="position-roundtable-task-card eightbf-roundtable__task-card group relative shrink-0 overflow-hidden rounded-[4px] border border-[#ECE7E4] bg-white p-5 shadow-[0_4px_20px_rgba(0,0,0,0.02)] dark:border-[rgba(0,110,254,0.5)] dark:bg-[#032650] dark:shadow-none"
aria-labelledby="eightbf-task-card-title"
>
<div className="position-roundtable-task-card__content relative z-10">
{onCollapse && (
<button
type="button"
className="position-roundtable-intent-config-collapse-btn absolute right-0 top-0"
onClick={onCollapse}
aria-label="收起左侧面板"
title="收起左侧面板"
>
<img src={collapsePanelIcon} alt="" aria-hidden="true" />
</button>
)}
<h2
id="eightbf-task-card-title"
title={task?.title || task?.id || "等待任务信息"}
className="pr-8 text-[18px] font-medium leading-7 text-slate-900 dark:text-white"
>
<span className="line-clamp-1">{task?.title || (task?.id ? `任务 ${task.id}` : "等待任务信息")}</span>
</h2>
{tags.length > 0 && (
<div
data-task-tags
className="mt-3 flex flex-wrap gap-2 [&>span]:inline-flex [&>span]:min-h-7 [&>span]:items-center [&>span]:!leading-5 [&>span:first-child]:!border-[#cf1322] [&>span:nth-child(2)]:!shadow-none"
aria-label="任务标签"
>
{tags.map((tag, index) => (
<span
key={tag}
className={[
"rounded-[2px] border px-2 py-0.5 text-center text-[16px] font-light",
index === 0 && "border-[#cf1322] bg-[rgba(207,19,34,0.5)] leading-4 text-white",
index === 1 && "border-[#4bcdff] bg-[rgba(75,205,255,0.3)] leading-7 text-[#4bcdff] shadow-[0_0_9px_rgba(252,131,131,0.27)]",
index > 1 && "border-[#a2e1f9] bg-[rgba(162,225,249,0.3)] leading-[18px] text-[#a2e1f9]",
].filter(Boolean).join(" ")}
>
{tag}
</span>
))}
</div>
)}
<p
data-task-description
className={cn(
"mt-3 line-clamp-3 whitespace-pre-wrap text-sm leading-[22px] text-slate-600 dark:text-[#96bbf8]",
!task?.description && "is-empty",
)}
>
{task?.description || task?.overview || (loading ? "正在读取任务详情…" : "暂无任务描述")}
</p>
{error && <p className="eightbf-roundtable__task-card-error" role="status">{error}</p>}
</div>
</section>
);
}
@@ -1,7 +1,23 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Loader2, Save, Settings2, X } from "lucide-react";
import { useSearchParams } from "react-router-dom";
import { Loader2, Save, X } from "lucide-react";
import { getAccountDisplayName, getStoredAuth } from "@/core/auth";
import { EightBfTaskCard } from "@/eightbf-roundtable/components/EightBfTaskCard";
import {
EightBfStep1Panel,
EightBfStep1Sidebar,
EightBfStep2SidebarHeader,
} from "@/eightbf-roundtable/components/EightBfStep1Workspace";
import {
buildEightBfTaskContext,
createEightBfFallbackTask,
fetchEightBfTask,
fetchEightBfTaskDictionaries,
normalizeEightBfTask,
type EightBfTask,
type EightBfTaskDictionaries,
} from "@/eightbf-roundtable/api/task-context";
import { PositionBusinessChainFlow } from "@/position-roundtable/components/PositionBusinessChainFlow";
import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanningPage";
import { listChains } from "@/roundtable-planning/api/chains";
@@ -26,10 +42,28 @@ const BUSINESS_CODE = "8BF";
*/
export default function EightBfRoundtablePage({ compactChrome = false }: { compactChrome?: boolean }) {
const auth = getStoredAuth();
const [searchParams] = useSearchParams();
const taskId = useMemo(
() => searchParams.get("taskId")?.trim() || "",
[searchParams],
);
// 8BF 公共业务链由指定账号维护,不依赖外部透传的管理员角色。
// 与敏感词管理使用同一可信账户展示名规则(登录邮箱 @ 前缀)。
const canConfigureChain = getAccountDisplayName(auth).toLowerCase() === "lqq";
const storageTaskId = useMemo(() => `8bf:${auth?.user_id || "anonymous"}`, [auth?.user_id]);
const storageTaskId = useMemo(
() => `8bf:${auth?.user_id || "anonymous"}:${taskId || "anonymous"}`,
[auth?.user_id, taskId],
);
const [task, setTask] = useState<EightBfTask | null>(() => (
taskId ? normalizeEightBfTask({}, taskId) : null
));
const [taskDictionaries, setTaskDictionaries] = useState<EightBfTaskDictionaries>({
priority: [],
direction: [],
status: [],
});
const [taskLoading, setTaskLoading] = useState(Boolean(taskId));
const [taskError, setTaskError] = useState<string | null>(null);
const [mapping, setMapping] = useState<BusinessMapping | null>(null);
const [chains, setChains] = useState<BusinessChain[]>([]);
const [isLoading, setIsLoading] = useState(true);
@@ -41,6 +75,55 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
() => chains.find((chain) => chain.id === mapping?.chainId) ?? null,
[chains, mapping?.chainId],
);
const taskContext = useMemo(
() => buildEightBfTaskContext(task, taskDictionaries),
[task, taskDictionaries],
);
useEffect(() => {
if (!taskId) {
setTask(null);
setTaskError(null);
setTaskLoading(false);
setTaskDictionaries({ priority: [], direction: [], status: [] });
return;
}
let cancelled = false;
setTaskLoading(true);
setTaskError(null);
setTask(normalizeEightBfTask({}, taskId));
let pendingRequests = 2;
const markRequestFinished = () => {
pendingRequests -= 1;
if (!cancelled && pendingRequests === 0) setTaskLoading(false);
};
// Keep detail and dictionary requests independent: a missing dictionary
// must not prevent the task card or task context from being available.
void fetchEightBfTask(taskId)
.then((nextTask) => {
if (!cancelled) setTask(nextTask);
})
.catch((error) => {
if (cancelled) return;
console.warn("[8bf] 任务详情请求失败,使用页面示例任务", error);
setTask(createEightBfFallbackTask(taskId));
setTaskError(null);
})
.finally(markRequestFinished);
void fetchEightBfTaskDictionaries()
.then((nextDictionaries) => {
if (!cancelled) setTaskDictionaries(nextDictionaries);
})
.catch(() => {
// fetchEightBfTaskDictionaries already degrades each dictionary to an
// empty list; this guard protects future adapter changes.
})
.finally(markRequestFinished);
return () => {
cancelled = true;
};
}, [taskId]);
const loadConfiguration = useCallback(async () => {
setIsLoading(true);
@@ -94,18 +177,12 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
}
}, [canConfigureChain, chains, selectedChainId]);
const openChainSettings = useCallback(() => {
if (canConfigureChain) setIsSettingsOpen(true);
}, [canConfigureChain]);
const toolbarSlot = (
<>
{canConfigureChain && (
<button
type="button"
className="eightbf-roundtable__chain-settings-button"
onClick={() => setIsSettingsOpen(true)}
>
<Settings2 className="h-3.5 w-3.5" />
配置业务链
</button>
)}
{canConfigureChain && isSettingsOpen && (
<div className="eightbf-roundtable__settings-mask" role="presentation">
<section
@@ -162,7 +239,64 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
lockedChainId={mapping?.chainId ?? null}
lockBusinessChain
storageTaskId={storageTaskId}
taskContext={taskContext as Record<string, unknown> | null}
entryRailTaskId={taskId || null}
entryRailTaskCardSlot={taskId ? (
<EightBfTaskCard
task={task}
dictionaries={taskDictionaries}
loading={taskLoading}
error={taskError}
/>
) : undefined}
toolbarSlot={toolbarSlot}
hideHeaderSaveDraft
hideHeaderSettings
newTaskLabel="新建会话"
hideDefaultStep1RightSidebar
renderStep1={(props) => (
<EightBfStep1Panel
{...props}
chain={configuredChain}
canConfigureChain={canConfigureChain}
onConfigureChain={openChainSettings}
/>
)}
renderStep1LeftSidebar={({ intentReady }) => (
<EightBfStep1Sidebar
intentReady={intentReady}
taskCard={taskId ? (
<EightBfTaskCard
task={task}
dictionaries={taskDictionaries}
loading={taskLoading}
error={taskError}
/>
) : undefined}
businessChain={
<PositionBusinessChainFlow
chain={configuredChain}
loading={isLoading}
embedded
className="position-roundtable-intent-delivery-flow eightbf-roundtable__business-chain-flow"
/>
}
/>
)}
renderStep2LeftSidebarHeader={({ intentReady, onCollapse }) => (
<EightBfStep2SidebarHeader
intentReady={intentReady}
taskCard={taskId ? (
<EightBfTaskCard
task={task}
dictionaries={taskDictionaries}
loading={taskLoading}
error={taskError}
onCollapse={onCollapse}
/>
) : undefined}
/>
)}
entryRailBusinessChainSlot={
<PositionBusinessChainFlow
chain={configuredChain}
@@ -23,8 +23,265 @@
/* 8BF 左上业务链区域:复用岗位会商的纵向节点、点阵画布与放大查看交互。 */
.eightbf-roundtable .eightbf-roundtable__business-chain-flow {
height: 500px;
min-height: 460px;
height: 430px;
min-height: 430px;
}
/* 可在此继续覆盖第 1 步的任务理解面板与业务链流程图样式。 */
/* 8BF 外部 taskId:左上任务卡。仅挂在 8BF 根节点下,避免改动原会商任务卡。 */
.eightbf-roundtable .eightbf-roundtable__task-card {
flex: 0 0 auto;
min-width: 0;
}
.eightbf-roundtable .eightbf-roundtable__task-card-title-row {
display: flex;
align-items: center;
gap: 8px;
}
.eightbf-roundtable .eightbf-roundtable__task-card-tags {
margin-top: 12px;
}
.eightbf-roundtable .eightbf-roundtable__task-card-tags span {
border-radius: 2px;
}
.eightbf-roundtable .eightbf-roundtable__task-card-description {
margin: 12px 0 0;
}
.eightbf-roundtable .eightbf-roundtable__task-card-description.is-empty { color: #94a3b8; }
.eightbf-roundtable .eightbf-roundtable__task-card-error { margin: 8px 0 0; color: #fca5a5; font-size: 12px; line-height: 1.5; }
.dark .eightbf-roundtable .eightbf-roundtable__task-card-description.is-empty { color: #8fa8c5; }
/*
* 8BF 第一阶段直接采用「岗位多智能体会商」意图工作台的三栏尺寸和深蓝表面。
* 专属类只改变 8BF;岗位会商原有页面继续由 intent-understanding.css 管理。
*/
.eightbf-roundtable .eightbf-roundtable__header-brand {
display: flex;
min-width: 0;
flex: 1;
align-items: center;
gap: 12px;
}
.eightbf-roundtable .eightbf-roundtable__header-brand-icon {
display: inline-flex;
width: 42px;
height: 42px;
flex: 0 0 auto;
align-items: center;
justify-content: center;
border-radius: 14px;
background: rgb(43 143 254 / 16%);
color: #2b8ffe;
}
.eightbf-roundtable .eightbf-roundtable__header-brand-icon svg { width: 23px; height: 23px; }
.eightbf-roundtable .eightbf-roundtable__header-brand h1 { margin: 0; color: #1e293b; font-size: 16px; font-weight: 700; }
.eightbf-roundtable .eightbf-roundtable__header-brand p { margin: 2px 0 0; color: #64748b; font-size: 12px; }
.eightbf-roundtable .eightbf-roundtable__header-status {
display: inline-flex;
align-items: center;
gap: 5px;
margin-left: 8px;
border: 1px solid rgb(16 185 129 / 30%);
border-radius: 999px;
background: rgb(16 185 129 / 8%);
color: #047857;
padding: 5px 10px;
font-size: 12px;
font-weight: 700;
}
.eightbf-roundtable .eightbf-roundtable__header-chain {
display: inline-flex;
min-width: 230px;
max-width: 360px;
align-items: center;
gap: 8px;
border: 1px solid #cbd5e1;
border-radius: 6px;
background: #fff;
color: #64748b;
padding: 8px 10px;
font-size: 12px;
}
.eightbf-roundtable .eightbf-roundtable__header-chain strong { min-width: 0; flex: 1; overflow: hidden; color: #334155; font-size: 13px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
.eightbf-roundtable .eightbf-roundtable__header-chain svg { width: 16px; height: 16px; flex: 0 0 auto; }
/* 参考工作台的直角圆角层级:内容面、消息区与输入框均为 4px。 */
.eightbf-roundtable #middle-viewport { border-radius: 4px; }
.eightbf-roundtable .position-roundtable-intent-panel > div:first-child,
.eightbf-roundtable .position-roundtable-intent-composer-frame,
.eightbf-roundtable .position-roundtable-intent-config-pane { border-radius: 4px; }
.eightbf-roundtable .position-roundtable-intent-panel > div:first-child { border-radius: 4px 4px 0 0; }
.eightbf-roundtable .eightbf-roundtable__step1-sidebar {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
gap: 12px;
overflow: hidden;
padding-right: 0;
}
/* 8BF 首步左栏不允许横向滚动;流程图的横向超出由画布自身裁切。 */
.eightbf-roundtable[data-roundtable-step="1"] #left-sidebar,
.eightbf-roundtable .eightbf-roundtable__step1-sidebar,
.eightbf-roundtable .eightbf-roundtable__intent-delivery-sidebar {
overflow-x: hidden !important;
}
.eightbf-roundtable[data-roundtable-step="1"] #left-sidebar::-webkit-scrollbar:horizontal,
.eightbf-roundtable .eightbf-roundtable__step1-sidebar::-webkit-scrollbar:horizontal,
.eightbf-roundtable .eightbf-roundtable__intent-delivery-sidebar::-webkit-scrollbar:horizontal {
display: none;
height: 0;
}
.eightbf-roundtable .eightbf-roundtable__intent-delivery-sidebar {
min-height: 0;
overflow-y: auto;
}
.eightbf-roundtable .eightbf-roundtable__intent-sidebar-empty {
margin: 0;
padding: 12px 16px 18px;
color: #aaceff;
font-size: 14px;
line-height: 22px;
}
/* The flow is embedded into the intent surface: only its own header line and
dotted canvas are visible. The surrounding intent panel owns the border. */
.eightbf-roundtable .position-roundtable-intent-delivery-flow {
/* Reference flow has horizontal gutters. Reduce the width by those gutters
so the right-side magnifier is never clipped by the left-rail crop. */
width: calc(100% - 24px) !important;
height: 580px !important;
min-height: 580px !important;
flex-basis: 580px !important;
}
.eightbf-roundtable .position-roundtable-intent-delivery-flow > div:first-child {
margin: 0 0 12px !important;
}
.eightbf-roundtable .eightbf-roundtable__agent-list-workspace {
display: flex;
height: 100%;
min-height: 0;
flex-direction: column;
background: #032650;
}
.eightbf-roundtable .eightbf-roundtable__agent-list-workspace > header {
display: flex;
min-height: 64px;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 10px 12px;
border-bottom: 1px solid rgb(43 143 254 / 35%);
}
.eightbf-roundtable .eightbf-roundtable__agent-list-workspace h3 { margin: 0; color: #fff; font-size: 17px; font-weight: 500; }
.eightbf-roundtable .eightbf-roundtable__agent-list-workspace p { margin: 4px 0 0; color: #aaceff; font-size: 11px; line-height: 1.45; }
.eightbf-roundtable .eightbf-roundtable__configure-chain-button {
display: inline-flex;
height: 28px;
align-items: center;
gap: 4px;
border: 1px solid rgb(115 200 255 / 52%);
border-radius: 4px;
background: rgb(0 98 217 / 22%);
color: #dceaff;
padding: 0 8px;
font-size: 11px;
}
.eightbf-roundtable .eightbf-roundtable__configure-chain-button:hover { background: rgb(0 98 217 / 40%); }
.eightbf-roundtable .eightbf-roundtable__agent-list-search {
display: flex;
align-items: center;
gap: 7px;
margin: 12px;
border: 1px solid rgb(43 143 254 / 36%);
border-radius: 4px;
background: rgb(0 12 28 / 20%);
color: #aaceff;
padding: 8px 9px;
font-size: 12px;
}
.eightbf-roundtable .eightbf-roundtable__agent-list-search span:last-child { margin-left: auto; border-radius: 999px; background: #0b386d; padding: 1px 6px; color: #83c1ff; font-size: 10px; }
.eightbf-roundtable .eightbf-roundtable__agent-list-scroll { min-height: 0; flex: 1; overflow-y: auto; padding: 0 12px 12px; }
.eightbf-roundtable .eightbf-roundtable__agent-list-loading,
.eightbf-roundtable .eightbf-roundtable__agent-list-empty { display: flex; min-height: 100px; align-items: center; justify-content: center; gap: 7px; color: #8aa0c4; font-size: 12px; text-align: center; }
.eightbf-roundtable .eightbf-roundtable__agent-list-row {
display: flex;
width: 100%;
align-items: center;
gap: 10px;
border-bottom: 1px solid rgb(43 143 254 / 21%);
padding: 11px 2px;
color: inherit;
text-align: left;
transition: background-color .16s ease, transform .16s ease;
}
.eightbf-roundtable .eightbf-roundtable__agent-list-row:hover { background: rgb(0 98 217 / 13%); transform: translateX(2px); }
.eightbf-roundtable .eightbf-roundtable__agent-list-avatar { display: inline-flex; width: 34px; height: 34px; flex: 0 0 auto; align-items: center; justify-content: center; border: 1px solid #007e82; border-radius: 7px; background: #00306a; color: #73c8ff; }
.eightbf-roundtable .eightbf-roundtable__agent-list-avatar svg { width: 18px; height: 18px; }
.eightbf-roundtable .eightbf-roundtable__agent-list-row strong,
.eightbf-roundtable .eightbf-roundtable__agent-list-row small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.eightbf-roundtable .eightbf-roundtable__agent-list-row strong { color: #fff; font-size: 13px; font-weight: 600; }
.eightbf-roundtable .eightbf-roundtable__agent-list-row small { margin-top: 3px; color: #aaceff; font-size: 11px; }
.eightbf-roundtable .eightbf-roundtable__agent-list-index { color: #75b7f5; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; }
.eightbf-roundtable .eightbf-roundtable__agent-detail-topbar { display: flex; height: 48px; align-items: center; justify-content: space-between; border-bottom: 1px solid rgb(43 143 254 / 35%); padding: 0 12px; }
.eightbf-roundtable .eightbf-roundtable__agent-detail-topbar > button:first-child { display: inline-flex; align-items: center; gap: 2px; color: #aaceff; font-size: 12px; }
.eightbf-roundtable .eightbf-roundtable__agent-detail-topbar > button:first-child:hover { color: #fff; }
.eightbf-roundtable .eightbf-roundtable__intent-results-scroll { padding: 12px; }
.eightbf-roundtable .eightbf-roundtable__intent-results-empty { margin: 0; color: #aaceff; font-size: 13px; line-height: 1.8; }
.eightbf-roundtable .eightbf-roundtable__intent-results-list { display: grid; gap: 10px; }
.eightbf-roundtable .eightbf-roundtable__intent-results-list section { border: 1px solid rgb(43 143 254 / 32%); border-radius: 4px; background: rgb(0 12 28 / 20%); padding: 12px; }
.eightbf-roundtable .eightbf-roundtable__intent-results-list h3 { margin: 0 0 8px; color: #a2e1f9; font-size: 14px; font-weight: 500; }
.eightbf-roundtable .eightbf-roundtable__intent-results-list ul { margin: 0; padding-left: 17px; color: #dceaff; font-size: 12px; line-height: 1.8; }
/* Screenshot-equivalent dark workbench shell. */
.dark .eightbf-roundtable { background: #061b3b; color: #e7f0ff; }
.dark .eightbf-roundtable > header { min-height: 64px; border-color: rgb(43 143 254 / 45%); background: #032650; box-shadow: none; }
.dark .eightbf-roundtable .eightbf-roundtable__header-brand h1 { color: #e7f0ff; }
.dark .eightbf-roundtable .eightbf-roundtable__header-brand p { color: #8aa0c4; }
.dark .eightbf-roundtable .eightbf-roundtable__header-status { border-color: rgb(101 214 198 / 45%); background: rgb(15 118 110 / 24%); color: #9ef0df; }
.dark .eightbf-roundtable .eightbf-roundtable__header-chain { border-color: rgb(143 196 255 / 44%); background: #082b57; color: #aaceff; }
.dark .eightbf-roundtable .eightbf-roundtable__header-chain strong { color: #e7f0ff; }
.dark .eightbf-roundtable #new-task-btn { border: 1px solid rgb(115 200 255 / 45%); border-radius: 4px; background: transparent; color: #e7f0ff; box-shadow: none; }
.dark .eightbf-roundtable #new-task-btn:hover { background: rgb(0 98 217 / 24%); }
.dark .eightbf-roundtable[data-roundtable-step="1"] > main { gap: 0; padding: 12px 0; }
.dark .eightbf-roundtable[data-roundtable-step="1"] #middle-viewport { border-color: rgb(43 143 254 / 50%); border-radius: 4px; background: #032650; box-shadow: none; }
.dark .eightbf-roundtable[data-roundtable-step="1"] #left-sidebar {
width: 374px;
overflow: hidden;
border: 0;
border-radius: 0;
background: transparent;
padding: 0 12px;
}
.dark .eightbf-roundtable .eightbf-roundtable__intent-delivery-sidebar {
border-color: rgb(0 110 254 / 50%);
background: #032650;
}
@media (max-width: 1280px) {
.eightbf-roundtable .eightbf-roundtable__header-chain { min-width: 180px; max-width: 220px; }
.dark .eightbf-roundtable[data-roundtable-step="1"] #left-sidebar { width: 326px; }
.eightbf-roundtable .position-roundtable-intent-config-pane { --prt-intent-config-width: 320px; }
}
@media (max-width: 1024px) {
.dark .eightbf-roundtable[data-roundtable-step="1"] #left-sidebar { width: 294px; }
.eightbf-roundtable .eightbf-roundtable__header-status,
.eightbf-roundtable .eightbf-roundtable__header-brand p { display: none; }
.eightbf-roundtable .eightbf-roundtable__header-chain { display: none; }
.eightbf-roundtable .position-roundtable-intent-config-pane { --prt-intent-config-width: min(360px, 42vw); }
}
@@ -14,4 +14,67 @@
.eightbf-roundtable .eightbf-roundtable__chain-settings-button:hover { background: #115e59; }
/* 可在此继续覆盖会商消息、席位卡片、流程节点及展开按钮的样式。 */
/* 展开时工作区紧贴左栏;收起时恢复通用的 16px 间距,避免窄栏贴着内容区。 */
.dark .eightbf-roundtable[data-roundtable-step="2"] > main[data-step2-sidebar-expanded="true"] {
gap: 0;
}
.dark .eightbf-roundtable[data-roundtable-step="2"] > main[data-step2-sidebar-expanded="false"] {
gap: 10px;
}
/* 第二步左栏顶部直接复用第一步任务卡和意图理解区,但不复用第一步整列的
flex: 1 行为;内容高度随意图实际内容收拢,剩余内容交给左栏统一滚动。 */
.eightbf-roundtable[data-roundtable-step="2"] .eightbf-roundtable__step2-sidebar-header {
display: flex;
flex: 0 0 auto;
flex-direction: column;
gap: 12px;
min-height: 0;
overflow: visible;
}
.eightbf-roundtable[data-roundtable-step="2"] .eightbf-roundtable__step2-sidebar-header
.eightbf-roundtable__intent-delivery-sidebar {
flex: 0 0 auto;
overflow: visible;
}
/* 角色面板与列表均依内容撑开,再交由左栏的单一滚动容器承载;否则取消
内层滚动后,角色项会从固定高度的父面板溢出并覆盖下方内容。 */
.eightbf-roundtable[data-roundtable-step="2"] #left-sidebar > .eightbf-roundtable__step2-sidebar-header + div {
flex: 0 0 auto !important;
min-height: 0 !important;
}
.eightbf-roundtable[data-roundtable-step="2"] #left-sidebar .custom-scrollbar {
flex: 0 0 auto;
overflow: visible;
padding-right: 0;
}
/* 8BF 第二步沿用第一步的 4px 面板语言,避免通用会商的 rounded-2xl 混入。 */
.eightbf-roundtable[data-roundtable-step="2"] #left-sidebar > .rounded-2xl {
border-radius: 4px !important;
}
/* 展开态与第一步左栏保持同一宽度和内边距。注意不能无条件锁定宽度,否则会
覆盖收起态的 w-[56px],使顶部图标按钮看起来无效。 */
.dark .eightbf-roundtable[data-roundtable-step="2"] #left-sidebar[data-sidebar-expanded="true"] {
width: 374px;
padding: 0 12px;
}
/* 第二步输入区直接复用第一步 PositionIntentInput;共识动作也使用其
#enter-roundtable-btn 规则,保证和「确认意图并启动业务链」完全同款。 */
.dark .eightbf-roundtable[data-roundtable-step="2"] .eightbf-roundtable-step2-input {
flex: 0 0 auto;
}
@media (max-width: 1280px) {
.dark .eightbf-roundtable[data-roundtable-step="2"] #left-sidebar[data-sidebar-expanded="true"] { width: 326px; }
}
@media (max-width: 1024px) {
.dark .eightbf-roundtable[data-roundtable-step="2"] #left-sidebar[data-sidebar-expanded="true"] { width: 294px; }
}
+27
View File
@@ -0,0 +1,27 @@
/**
* Generate a client-only id without assuming a secure browser context.
*
* `crypto.randomUUID()` is unavailable in several browsers when the app is
* opened directly through an `http://<public-ip>:<port>` address. Request and
* UI ids need uniqueness, not a security token, so a timestamp/random fallback
* keeps those flows usable until the deployment is served over HTTPS.
*/
export function createClientId(prefix?: string): string {
const cryptoApi = typeof globalThis === "object" ? globalThis.crypto : undefined;
let id: string | undefined;
if (typeof cryptoApi?.randomUUID === "function") {
id = cryptoApi.randomUUID();
} else if (typeof cryptoApi?.getRandomValues === "function") {
try {
const values = new Uint32Array(4);
cryptoApi.getRandomValues(values);
id = Array.from(values, (value) => value.toString(16).padStart(8, "0")).join("");
} catch {
// Some restricted browser contexts expose crypto but reject random data.
}
}
id ??= `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`;
return prefix ? `${prefix}-${id}` : id;
}
@@ -119,6 +119,10 @@ export interface DocumentRewriteRequest {
instruction: string;
style?: DocumentRewriteStyle;
modelName?: string;
/** Generate locally saved illustration assets after the prose rewrite. */
generateImages?: boolean;
/** Bounded server-side to 1–4. Ignored unless generateImages is true. */
maxGeneratedImages?: number;
expectedHash?: string;
}
@@ -140,6 +144,8 @@ export interface DocumentRewriteSummary {
};
requestedOptimizations?: string[];
validationWarnings?: string[];
generatedImageCount?: number;
requestedImageCount?: number;
}
export type DocumentRewriteStreamEvent =
@@ -156,6 +162,15 @@ export type DocumentRewriteStreamEvent =
| { type: "rewrite_delta"; delta: string; charCount: number }
/** Durable full-draft recovery point used after an SSE reconnect. */
| { type: "rewrite_checkpoint"; content: string; charCount: number }
| { type: "illustration_started"; section: string; position: number; total: number }
| {
type: "illustration_generated";
section: string;
position: number;
total: number;
generatedCount?: number;
}
| { type: "illustration_completed"; generatedCount: number; requestedCount: number; warnings: string[] }
| { type: "validation_started" }
| { type: "validation_completed"; passed: boolean; warnings: string[] }
| { type: "comparison_ready"; summary: DocumentRewriteSummary }
@@ -177,6 +192,8 @@ export interface DurableDocumentRewriteJob {
instruction: string;
style: DocumentRewriteStyle;
modelName: string | null;
generateImages: boolean;
maxGeneratedImages: number;
originalContent: string;
};
}
@@ -229,6 +246,30 @@ async function readDocumentRewriteEvent(
content: String(parsed.content ?? ""),
charCount: Number(parsed.charCount ?? 0),
};
case "illustration_started":
return {
type: "illustration_started",
section: String(parsed.section ?? ""),
position: Number(parsed.position ?? 0),
total: Number(parsed.total ?? 0),
};
case "illustration_generated":
return {
type: "illustration_generated",
section: String(parsed.section ?? ""),
position: Number(parsed.position ?? 0),
total: Number(parsed.total ?? 0),
generatedCount: Number(parsed.generatedCount ?? parsed.position ?? 0),
};
case "illustration_completed":
return {
type: "illustration_completed",
generatedCount: Number(parsed.generatedCount ?? 0),
requestedCount: Number(parsed.requestedCount ?? 0),
warnings: Array.isArray(parsed.warnings)
? parsed.warnings.filter((item): item is string => typeof item === "string")
: [],
};
case "validation_started":
return { type: "validation_started" };
case "validation_completed":
@@ -362,6 +403,8 @@ export async function listDurableDocumentRewrites(
instruction: String(request.instruction ?? ""),
style: (request.style ?? "professional") as DocumentRewriteStyle,
modelName: typeof request.modelName === "string" ? request.modelName : null,
generateImages: request.generateImages === true,
maxGeneratedImages: Number(request.maxGeneratedImages ?? 2),
originalContent: String(request.originalContent ?? ""),
},
}];
@@ -9,12 +9,14 @@ import {
cancelDurableDocumentRewrite,
streamDocumentRewrite,
} from "../api/rewrite";
import { InlineThinkTagFilter, stripInlineThinkTags } from "@/core/streamdown/inline-think";
export type DocumentRewriteStage =
| "idle"
| "snapshot"
| "requirements"
| "writing"
| "illustrating"
| "validation"
| "committing"
| "completed"
@@ -27,6 +29,8 @@ export interface DocumentRewriteRun {
draft: string;
/** Live, compact execution plan produced before Markdown generation starts. */
requirementsPlan: string;
/** Ephemeral model reasoning, deliberately kept out of the Markdown draft. */
thinkingText: string;
originalCharCount: number;
writtenCharCount: number;
fileName: string;
@@ -41,6 +45,7 @@ const INITIAL_RUN: DocumentRewriteRun = {
stage: "idle",
draft: "",
requirementsPlan: "",
thinkingText: "",
originalCharCount: 0,
writtenCharCount: 0,
fileName: "",
@@ -67,6 +72,10 @@ export function useDocumentRewrite() {
const frameRef = useRef<number | null>(null);
const pendingRequirementsDeltaRef = useRef("");
const requirementsFrameRef = useRef<number | null>(null);
const pendingThinkingRef = useRef("");
const thinkingFrameRef = useRef<number | null>(null);
const draftThinkFilterRef = useRef(new InlineThinkTagFilter());
const requirementsThinkFilterRef = useRef(new InlineThinkTagFilter());
const flushDelta = useCallback(() => {
if (frameRef.current !== null) {
@@ -86,27 +95,6 @@ export function useDocumentRewrite() {
}));
}, []);
const enqueueDelta = useCallback((delta: string, writtenCharCount: number) => {
if (!delta) return;
pendingDeltaRef.current += delta;
pendingWrittenCharCountRef.current = writtenCharCount;
if (frameRef.current !== null) return;
frameRef.current = requestAnimationFrame(() => {
frameRef.current = null;
const pending = pendingDeltaRef.current;
const pendingWrittenCharCount = pendingWrittenCharCountRef.current;
pendingDeltaRef.current = "";
pendingWrittenCharCountRef.current = null;
if (!pending) return;
setRun((previous) => ({
...previous,
stage: "writing",
draft: previous.draft + pending,
...(pendingWrittenCharCount !== null ? { writtenCharCount: pendingWrittenCharCount } : {}),
}));
});
}, []);
const flushRequirements = useCallback(() => {
if (requirementsFrameRef.current !== null) {
cancelAnimationFrame(requirementsFrameRef.current);
@@ -124,9 +112,35 @@ export function useDocumentRewrite() {
}));
}, []);
const enqueueRequirementsDelta = useCallback((delta: string) => {
const flushThinking = useCallback(() => {
if (thinkingFrameRef.current !== null) {
cancelAnimationFrame(thinkingFrameRef.current);
thinkingFrameRef.current = null;
}
const delta = pendingThinkingRef.current;
pendingThinkingRef.current = "";
if (!delta) return;
pendingRequirementsDeltaRef.current += delta;
setRun((previous) => ({ ...previous, thinkingText: previous.thinkingText + delta }));
}, []);
const enqueueThinking = useCallback((delta: string) => {
if (!delta) return;
pendingThinkingRef.current += delta;
if (thinkingFrameRef.current !== null) return;
thinkingFrameRef.current = requestAnimationFrame(() => {
thinkingFrameRef.current = null;
const pending = pendingThinkingRef.current;
pendingThinkingRef.current = "";
if (!pending) return;
setRun((previous) => ({ ...previous, thinkingText: previous.thinkingText + pending }));
});
}, []);
const enqueueRequirementsDelta = useCallback((delta: string) => {
const { text: visibleDelta, thinking } = requirementsThinkFilterRef.current.pushParts(delta);
enqueueThinking(thinking);
if (!visibleDelta) return;
pendingRequirementsDeltaRef.current += visibleDelta;
if (requirementsFrameRef.current !== null) return;
requirementsFrameRef.current = requestAnimationFrame(() => {
requirementsFrameRef.current = null;
@@ -141,7 +155,30 @@ export function useDocumentRewrite() {
requirementsPlan: previous.requirementsPlan + pending,
}));
});
}, []);
}, [enqueueThinking]);
const enqueueDelta = useCallback((delta: string, writtenCharCount: number) => {
const { text: visibleDelta, thinking } = draftThinkFilterRef.current.pushParts(delta);
enqueueThinking(thinking);
if (!visibleDelta) return;
pendingDeltaRef.current += visibleDelta;
pendingWrittenCharCountRef.current = writtenCharCount;
if (frameRef.current !== null) return;
frameRef.current = requestAnimationFrame(() => {
frameRef.current = null;
const pending = pendingDeltaRef.current;
const pendingWrittenCharCount = pendingWrittenCharCountRef.current;
pendingDeltaRef.current = "";
pendingWrittenCharCountRef.current = null;
if (!pending) return;
setRun((previous) => ({
...previous,
stage: "writing",
draft: previous.draft + pending,
...(pendingWrittenCharCount !== null ? { writtenCharCount: pendingWrittenCharCount } : {}),
}));
});
}, [enqueueThinking]);
const cancel = useCallback(() => {
controllerRef.current?.abort();
@@ -155,12 +192,13 @@ export function useDocumentRewrite() {
}
flushDelta();
flushRequirements();
flushThinking();
setRun((previous) =>
previous.stage === "completed"
? previous
: { ...previous, stage: "cancelled", error: "已停止全文改写,原文件未被修改。" },
);
}, [flushDelta, flushRequirements]);
}, [flushDelta, flushRequirements, flushThinking]);
const reset = useCallback(() => {
controllerRef.current?.abort();
@@ -169,6 +207,9 @@ export function useDocumentRewrite() {
pendingDeltaRef.current = "";
pendingWrittenCharCountRef.current = null;
pendingRequirementsDeltaRef.current = "";
pendingThinkingRef.current = "";
draftThinkFilterRef.current = new InlineThinkTagFilter();
requirementsThinkFilterRef.current = new InlineThinkTagFilter();
if (frameRef.current !== null) {
cancelAnimationFrame(frameRef.current);
frameRef.current = null;
@@ -177,6 +218,10 @@ export function useDocumentRewrite() {
cancelAnimationFrame(requirementsFrameRef.current);
requirementsFrameRef.current = null;
}
if (thinkingFrameRef.current !== null) {
cancelAnimationFrame(thinkingFrameRef.current);
thinkingFrameRef.current = null;
}
setRun(INITIAL_RUN);
}, []);
@@ -202,6 +247,9 @@ export function useDocumentRewrite() {
pendingDeltaRef.current = "";
pendingWrittenCharCountRef.current = null;
pendingRequirementsDeltaRef.current = "";
pendingThinkingRef.current = "";
draftThinkFilterRef.current = new InlineThinkTagFilter();
requirementsThinkFilterRef.current = new InlineThinkTagFilter();
setRun({
...INITIAL_RUN,
stage: "snapshot",
@@ -236,28 +284,36 @@ export function useDocumentRewrite() {
case "requirements_delta":
enqueueRequirementsDelta(event.delta);
break;
case "thinking":
enqueueThinking(event.chunk);
break;
case "requirements_checkpoint":
flushRequirements();
setRun((previous) => ({
...previous,
stage: "requirements",
requirementsPlan: event.content,
requirementsPlan: requirementsThinkFilterRef.current.replace(event.content),
}));
break;
case "requirements_completed":
flushRequirements();
setRun((previous) => ({
...previous,
stage: "requirements",
requirementsPlan: event.content,
}));
{
const requirementsPlan =
requirementsThinkFilterRef.current.replace(event.content) +
requirementsThinkFilterRef.current.finish();
setRun((previous) => ({
...previous,
stage: "requirements",
requirementsPlan,
}));
}
break;
case "requirements_resolved":
flushRequirements();
setRun((previous) => ({
...previous,
stage: "writing",
...(event.plan ? { requirementsPlan: event.plan } : {}),
...(event.plan ? { requirementsPlan: stripInlineThinkTags(event.plan) } : {}),
}));
break;
case "rewrite_delta":
@@ -276,13 +332,20 @@ export function useDocumentRewrite() {
setRun((previous) => ({
...previous,
stage: "writing",
draft: event.content,
writtenCharCount: event.charCount,
draft: draftThinkFilterRef.current.replace(event.content),
writtenCharCount: stripInlineThinkTags(event.content).replace(/\s/g, "").length,
}));
break;
case "illustration_started":
case "illustration_generated":
case "illustration_completed":
flushDelta();
setRun((previous) => ({ ...previous, stage: "illustrating" }));
break;
case "validation_started":
flushDelta();
flushRequirements();
flushThinking();
setRun((previous) => ({ ...previous, stage: "validation" }));
break;
case "validation_completed":
@@ -300,17 +363,23 @@ export function useDocumentRewrite() {
case "committed":
flushDelta();
flushRequirements();
setRun((previous) => ({
...previous,
draft: event.content,
writtenCharCount: event.content.replace(/\s/g, "").length,
versionId: event.versionId ?? previous.versionId,
}));
options?.onCommitted?.(event.content);
flushThinking();
{
const committedContent =
draftThinkFilterRef.current.replace(event.content) + draftThinkFilterRef.current.finish();
setRun((previous) => ({
...previous,
draft: committedContent,
writtenCharCount: committedContent.replace(/\s/g, "").length,
versionId: event.versionId ?? previous.versionId,
}));
options?.onCommitted?.(committedContent);
}
break;
case "done":
flushDelta();
flushRequirements();
flushThinking();
receivedTerminalEvent = true;
setRun((previous) => ({
...previous,
@@ -321,6 +390,7 @@ export function useDocumentRewrite() {
case "conflict":
flushDelta();
flushRequirements();
flushThinking();
receivedTerminalEvent = true;
setRun((previous) => ({
...previous,
@@ -331,12 +401,14 @@ export function useDocumentRewrite() {
case "cancelled":
flushDelta();
flushRequirements();
flushThinking();
receivedTerminalEvent = true;
setRun((previous) => ({ ...previous, stage: "cancelled", error: event.message }));
return;
case "error":
flushDelta();
flushRequirements();
flushThinking();
receivedTerminalEvent = true;
setRun((previous) => ({
...previous,
@@ -351,6 +423,7 @@ export function useDocumentRewrite() {
if (!controller.signal.aborted && !receivedTerminalEvent) {
flushDelta();
flushRequirements();
flushThinking();
setRun((previous) => ({
...previous,
stage: "error",
@@ -361,6 +434,7 @@ export function useDocumentRewrite() {
if (controller.signal.aborted) return;
flushDelta();
flushRequirements();
flushThinking();
setRun((previous) => ({
...previous,
stage: "error",
@@ -372,7 +446,7 @@ export function useDocumentRewrite() {
}
}
},
[enqueueDelta, enqueueRequirementsDelta, flushDelta, flushRequirements],
[enqueueDelta, enqueueRequirementsDelta, enqueueThinking, flushDelta, flushRequirements, flushThinking],
);
useEffect(
@@ -380,13 +454,14 @@ export function useDocumentRewrite() {
controllerRef.current?.abort();
if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);
if (requirementsFrameRef.current !== null) cancelAnimationFrame(requirementsFrameRef.current);
if (thinkingFrameRef.current !== null) cancelAnimationFrame(thinkingFrameRef.current);
},
[],
);
return {
run,
isActive: ["snapshot", "requirements", "writing", "validation", "committing"].includes(run.stage),
isActive: ["snapshot", "requirements", "writing", "illustrating", "validation", "committing"].includes(run.stage),
start,
cancel,
reset,
@@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { useAgentSelector } from "@/core/agents";
import { createClientId } from "@/lib/client-id";
import {
type DomainAgentMapping,
normalizeDomainInput,
@@ -20,10 +21,7 @@ import {
function makeRow(): DomainAgentMapping {
return {
id:
typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
id: createClientId(),
domain: "",
agentId: "",
enabled: true,
@@ -123,6 +123,7 @@ import {
advancedOptionsFromConfig,
buildReportCardConfig,
ReportConfigCard,
ReportOutlineEditor,
} from "./ReportConfigCard";
import { ResearchSandboxPanel } from "./ResearchSandboxPanel";
import { REPORT_OUTPUT_PATH, useReportSandbox } from "./useReportSandbox";
@@ -183,6 +184,8 @@ type WorkbenchRun = {
phase: string;
planText: string;
planReasoning: string;
/** Live-only model thinking while report Markdown is being streamed. */
reportThinking: string;
report: string;
reportTargets: Record<string, { content: string; version: number }>;
reportVersion: number;
@@ -201,6 +204,7 @@ const EMPTY_RUN: WorkbenchRun = {
phase: "initializing",
planText: "",
planReasoning: "",
reportThinking: "",
report: "",
reportTargets: {},
reportVersion: 1,
@@ -418,6 +422,10 @@ function DeepResearchWorkbenchInner() {
presentationId: null,
plan: "",
});
const mirroredReportRewriteThinkingRef = useRef<{ presentationId: string | null; thinking: string }>({
presentationId: null,
thinking: "",
});
const sessions = useResearchSessions();
const sessionQuery = useResearchSession(selectedId);
@@ -428,6 +436,10 @@ function DeepResearchWorkbenchInner() {
const [localSettings] = useLocalSettings();
const { models, isLoading: modelsLoading } = useModels();
const seenEventKeys = useRef(new Set<string>());
// Live provider deltas can legitimately repeat the same text (including a
// single space). They have no durable sequence, so never deduplicate them
// by payload or a thought token could disappear from the visible trace.
const liveEventSerial = useRef(0);
const replayedTraceJobs = useRef(new Set<string>());
const pendingReportEvents = useRef<ProgressEvent[]>([]);
const reportAnimationFrame = useRef<number | null>(null);
@@ -679,6 +691,16 @@ function DeepResearchWorkbenchInner() {
});
}, [reportDocumentRewrite.run.requirementsPlan, rewritePresentation]);
useEffect(() => {
const presentationId = reportRewritePresentationId.current;
const thinking = reportDocumentRewrite.run.thinkingText;
if (!presentationId || !rewritePresentation || !thinking) return;
const previous = mirroredReportRewriteThinkingRef.current;
if (previous.presentationId === presentationId && previous.thinking === thinking) return;
mirroredReportRewriteThinkingRef.current = { presentationId, thinking };
rewritePresentation.update(presentationId, { thinkingText: thinking });
}, [reportDocumentRewrite.run.thinkingText, rewritePresentation]);
useEffect(() => {
const handleRewriteUndo = (event: Event) => {
const detail = (event as CustomEvent<{
@@ -854,6 +876,9 @@ function DeepResearchWorkbenchInner() {
phase: session?.status === "completed" ? "done" : run.phase,
materialCount,
error: run.error,
thinking: run.reportThinking,
events: run.events,
requestedImageCount: requestedReportImageCount(session?.config),
}),
} as Injected);
}
@@ -870,6 +895,7 @@ function DeepResearchWorkbenchInner() {
? buildCompletedReportMessage({
sourceCount: materialCount,
summaryText: run.summaryText,
imageCount: countEmbeddedReportImages(session?.reportMarkdown),
})
: buildStreamingSummaryMessage(run.summaryText),
// MessageList 会将该标记转为既有的 assistant 流式渲染状态,避免摘要只在结束后才显示。
@@ -950,7 +976,9 @@ function DeepResearchWorkbenchInner() {
pendingFollowUp,
followUpStream,
run.error,
run.events,
run.phase,
run.reportThinking,
run.summaryText,
session,
streamJobId,
@@ -980,6 +1008,7 @@ function DeepResearchWorkbenchInner() {
if (replayingTerminalTrace && replayedTraceJobs.current.has(streamJobId)) return;
if (replayingTerminalTrace) replayedTraceJobs.current.add(streamJobId);
seenEventKeys.current = new Set();
liveEventSerial.current = 0;
pendingReportEvents.current = [];
setRun({ ...EMPTY_RUN, jobId: streamJobId });
// 进行中的写作仍自动打开沙箱;历史完成记录只回放步骤与摘要,等待用户点击
@@ -997,6 +1026,7 @@ function DeepResearchWorkbenchInner() {
let reportTargets = current.reportTargets;
let planText = current.planText;
let planReasoning = current.planReasoning;
let reportThinking = current.reportThinking;
let summaryText = current.summaryText;
let phase = current.phase;
for (const event of queued) {
@@ -1009,6 +1039,10 @@ function DeepResearchWorkbenchInner() {
planReasoning += typeof event.payload.delta === "string" ? event.payload.delta : "";
continue;
}
if (event.type === "report_thinking") {
reportThinking += typeof event.payload.delta === "string" ? event.payload.delta : "";
continue;
}
if (event.type === "summary_delta") {
summaryText += typeof event.payload.delta === "string" ? event.payload.delta : "";
continue;
@@ -1020,6 +1054,7 @@ function DeepResearchWorkbenchInner() {
if (event.type === "report_reset") {
report = "";
reportTargets = {};
reportThinking = "";
reportVersion = typeof event.payload.reportVersion === "number" ? event.payload.reportVersion : reportVersion + 1;
continue;
}
@@ -1035,7 +1070,7 @@ function DeepResearchWorkbenchInner() {
reportVersion = Math.max(reportVersion, version);
}
}
return { ...current, report, reportTargets, reportVersion, phase, planText, planReasoning, summaryText };
return { ...current, report, reportTargets, reportVersion, phase, planText, planReasoning, reportThinking, summaryText };
});
};
@@ -1043,12 +1078,15 @@ function DeepResearchWorkbenchInner() {
streamJobId,
{
onEvent: (event) => {
const key = eventKey(event);
const key = event.seq === 0 && !event.liveId
? `live-arrival:${++liveEventSerial.current}`
: eventKey(event);
if (seenEventKeys.current.has(key)) return;
seenEventKeys.current.add(key);
if (
event.type === "plan_delta" ||
event.type === "plan_reasoning" ||
event.type === "report_thinking" ||
event.type === "report_delta" ||
event.type === "report_chunk" ||
event.type === "report_reset" ||
@@ -1521,6 +1559,10 @@ function DeepResearchWorkbenchInner() {
case "config":
return (
<ReportConfigCard
// A new durable config-card message is a new editing session. Do
// not remount for ordinary collector/event refreshes: that would
// discard the user's in-progress “生成配图” toggle.
key={`${session.id}:${payload.messageId ?? "report-config"}`}
availableModes={availableModes}
defaultMode={session.mode}
defaultOptions={advancedOptionsFromConfig(session.config)}
@@ -1842,6 +1884,7 @@ type WritingProgressToolCall = {
label: string;
detail: string;
status: "complete" | "active";
thinking?: string;
};
};
@@ -1854,12 +1897,23 @@ function buildWritingProgressToolCalls({
phase,
materialCount,
error,
thinking,
events,
requestedImageCount,
}: {
phase: string;
materialCount: number;
error: string | null;
thinking: string;
events: ProgressEvent[];
requestedImageCount: number;
}): WritingProgressToolCall[] {
const rank = chatPhaseRank(phase);
const imageSnapshot = buildTraceSnapshot(events);
const imageStageVisible = requestedImageCount > 0 || imageSnapshot.imageTotal > 0 || imageSnapshot.images.length > 0;
const imageTotal = imageSnapshot.imageTotal || requestedImageCount;
const generatedImages = imageSnapshot.images.filter((image) => image.status === "completed").length;
const failedImages = imageSnapshot.images.filter((image) => image.status === "failed").length;
const stages = [
{
id: "harvest",
@@ -1879,6 +1933,18 @@ function buildWritingProgressToolCalls({
boundary: chatPhaseRank("reviewing"),
detail: "正在逐字写入右侧沙箱中的 report.md",
},
...(imageStageVisible ? [{
id: "illustrate",
label: "生成报告配图",
boundary: chatPhaseRank("exporting"),
detail: generatedImages > 0
? `已生成 ${generatedImages} 张报告配图并嵌入 report.md${failedImages ? `;另有 ${failedImages} 张未生成,文字报告已保留` : ""}`
: failedImages > 0
? `配图未生成,已保留完整文字报告${imageTotal ? `(已处理 ${failedImages}/${imageTotal} 张)` : ""}`
: imageTotal > 0
? `正在生成报告配图,已处理 ${generatedImages + failedImages}/${imageTotal} 张`
: "正在为报告关键章节生成配图",
}] : []),
{
id: "summary",
label: "生成报告摘要",
@@ -1901,6 +1967,7 @@ function buildWritingProgressToolCalls({
label: stage.label,
detail,
status: complete ? "complete" : "active",
...(stage.id === "write" && thinking.trim() ? { thinking } : {}),
},
};
});
@@ -1910,14 +1977,19 @@ function buildWritingProgressToolCalls({
function buildCompletedReportMessage({
sourceCount,
summaryText,
imageCount,
}: {
sourceCount: number;
summaryText: string;
imageCount: number;
}): string {
const parts = [
"## 研究报告已完成",
`已基于 ${sourceCount} 条资料完成报告,正文已在右侧沙箱中打开;可通过沙箱工具栏下载 Markdown 或 Word。`,
];
if (imageCount > 0) {
parts.push(`已生成 ${imageCount} 张报告配图,并嵌入正文对应章节。`);
}
if (summaryText.trim()) {
parts.push(`### 报告摘要\n\n${summaryText.trim()}`);
}
@@ -2104,6 +2176,7 @@ function ResearchConversation({
const hasTrace = active || events.length > 0;
const streamedSourceCount = events.reduce((count, event) => count + (event.type === "source_added" ? 1 : 0), 0);
const liveSourceCount = Math.max(sourceCount, streamedSourceCount);
const requestedImageCount = requestedReportImageCount(session.config);
return (
<div className="space-y-6">
<UserBubble content={session.query} />
@@ -2120,6 +2193,7 @@ function ResearchConversation({
error={streamError}
onOpenSandbox={onOpenSandbox}
active={active}
requestedImageCount={requestedImageCount}
/>
</AssistantTurn>
)}
@@ -2169,6 +2243,15 @@ type TraceSource = {
snippet?: string;
};
type TraceImage = {
position: number;
section: string;
status: "generating" | "completed" | "failed";
name?: string;
alt?: string;
error?: string;
};
type TraceSnapshot = {
groups: { title: string; queries: string[] }[];
subtopics: string[];
@@ -2179,6 +2262,9 @@ type TraceSnapshot = {
curationReason?: string;
compressedSourceCount?: number;
reportCompleted: boolean;
imageTotal: number;
images: TraceImage[];
imagePhaseFinished: boolean;
warnings: string[];
};
@@ -2198,17 +2284,30 @@ function numberValue(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function countEmbeddedReportImages(markdown?: string): number {
return (markdown?.match(/!\[[^\]]*\]\(deep-research:\/\/[^)\s]+\)/g) ?? []).length;
}
function requestedReportImageCount(config?: Record<string, unknown>): number {
if (config?.generate_images !== true) return 0;
const configured = numberValue(config.max_generated_images);
return Math.max(1, Math.min(4, Math.floor(configured ?? 2)));
}
function buildTraceSnapshot(events: ProgressEvent[]): TraceSnapshot {
const groupQueries = new Map<string, string[]>();
const subtopics: string[] = [];
const queryMap = new Map<string, TraceQuery>();
const sourceMap = new Map<string, TraceSource>();
const imageMap = new Map<number, TraceImage>();
const selectedIds = new Set<string>();
const removedIds = new Set<string>();
const warnings: string[] = [];
let curationReason: string | undefined;
let compressedSourceCount: number | undefined;
let reportCompleted = false;
let imageTotal = 0;
let imagePhaseFinished = false;
const addQuery = (query: string, group = "研究课题") => {
const normalized = query.trim();
@@ -2223,6 +2322,11 @@ function buildTraceSnapshot(events: ProgressEvent[]): TraceSnapshot {
for (const event of events) {
const payload = event.payload ?? {};
if (event.type === "phase_changed") {
const from = traceString(payload.from);
const to = traceString(payload.to);
if (from === "illustrating" || to === "exporting") imagePhaseFinished = true;
}
if (event.type === "plan_created") {
const plan = traceRecord(payload.plan);
for (const subtopic of traceStrings(payload.subtopics).concat(traceStrings(plan.subtopics))) {
@@ -2292,9 +2396,39 @@ function buildTraceSnapshot(events: ProgressEvent[]): TraceSnapshot {
reportCompleted = true;
continue;
}
if (event.type === "image_generation_started" || event.type === "image_generated") {
const position = Math.max(1, Math.floor(numberValue(payload.position) ?? imageMap.size + 1));
const total = numberValue(payload.total);
if (total !== undefined) imageTotal = Math.max(imageTotal, Math.floor(total));
imageTotal = Math.max(imageTotal, position);
const prior = imageMap.get(position);
imageMap.set(position, {
position,
section: traceString(payload.section) ?? prior?.section ?? `第 ${position} 张配图`,
status: event.type === "image_generated" ? "completed" : prior?.status === "completed" ? "completed" : "generating",
name: traceString(payload.name) ?? prior?.name,
alt: traceString(payload.alt) ?? prior?.alt,
});
continue;
}
if (event.type === "warning" || event.type === "error") {
const message = traceString(payload.message) ?? traceString(payload.detail);
if (message && !warnings.includes(message)) warnings.push(message);
if (traceString(payload.code) === "image_generation_failed") {
const position = Math.max(1, Math.floor(numberValue(payload.position) ?? imageMap.size + 1));
const total = numberValue(payload.total);
if (total !== undefined) imageTotal = Math.max(imageTotal, Math.floor(total));
imageTotal = Math.max(imageTotal, position);
const prior = imageMap.get(position);
imageMap.set(position, {
position,
section: traceString(payload.section) ?? prior?.section ?? `第 ${position} 张配图`,
status: "failed",
name: prior?.name,
alt: prior?.alt,
error: message ?? "报告配图生成失败",
});
}
}
}
@@ -2308,12 +2442,15 @@ function buildTraceSnapshot(events: ProgressEvent[]): TraceSnapshot {
curationReason,
compressedSourceCount,
reportCompleted,
imageTotal,
images: [...imageMap.values()].sort((left, right) => left.position - right.position),
imagePhaseFinished,
warnings,
};
}
function phaseAtOrAfter(phase: string, boundary: string): boolean {
const order = ["initializing", "planning", "collecting", "curating", "compressing", "deepening", "writing", "reviewing", "illustrating", "exporting", "done"];
const order = ["initializing", "planning", "collecting", "curating", "compressing", "deepening", "writing", "summarizing", "reviewing", "illustrating", "exporting", "done"];
return Math.max(0, order.indexOf(phase)) >= Math.max(0, order.indexOf(boundary));
}
@@ -2345,7 +2482,7 @@ function ReasoningStream({ text }: { text: string }) {
);
}
const ResearchTrace = memo(function ResearchTrace({ events, phase, planText, planReasoning, sourceCount, reportStarted, error, onOpenSandbox, active }: { events: ProgressEvent[]; phase: string; planText: string; planReasoning: string; sourceCount: number; reportStarted: boolean; error: string | null; onOpenSandbox: () => void; active: boolean }) {
const ResearchTrace = memo(function ResearchTrace({ events, phase, planText, planReasoning, sourceCount, reportStarted, error, onOpenSandbox, active, requestedImageCount }: { events: ProgressEvent[]; phase: string; planText: string; planReasoning: string; sourceCount: number; reportStarted: boolean; error: string | null; onOpenSandbox: () => void; active: boolean; requestedImageCount: number }) {
const [expanded, setExpanded] = useState(true);
const snapshot = useMemo(() => buildTraceSnapshot(events), [events]);
const sourcesById = useMemo(() => new Map(snapshot.sources.map((source) => [source.id, source])), [snapshot.sources]);
@@ -2358,6 +2495,14 @@ const ResearchTrace = memo(function ResearchTrace({ events, phase, planText, pla
const curatingDone = phaseAtOrAfter(phase, "writing") || snapshot.selectedIds.size > 0;
const writingDone = snapshot.reportCompleted || phase === "done";
const streamedQueries = planningDone ? [] : parseStreamedQueries(planText);
const imageStageVisible = requestedImageCount > 0 || snapshot.imageTotal > 0 || snapshot.images.length > 0;
const imageTotal = snapshot.imageTotal || requestedImageCount;
const completedImages = snapshot.images.filter((image) => image.status === "completed").length;
const failedImages = snapshot.images.filter((image) => image.status === "failed").length;
const processedImages = completedImages + failedImages;
const imageDone = imageStageVisible && (snapshot.imagePhaseFinished || phaseAtOrAfter(phase, "exporting") || (!active && phase === "done"));
const imageCurrent = imageStageVisible && !imageDone && (phase === "illustrating" || snapshot.images.some((image) => image.status === "generating"));
const imageProgress = imageTotal ? Math.min(100, Math.round((processedImages / imageTotal) * 100)) : 0;
return (
<div className="overflow-hidden rounded-2xl rounded-tl-md border border-border bg-card shadow-sm">
@@ -2390,6 +2535,12 @@ const ResearchTrace = memo(function ResearchTrace({ events, phase, planText, pla
<button type="button" onClick={onOpenSandbox} className="inline-flex items-center gap-1.5 rounded-md bg-primary/8 px-2.5 py-1.5 text-xs font-medium text-primary hover:bg-primary/12"><FileText className="h-3.5 w-3.5" />{writingDone ? "查看已保存的 report.md" : "打开正在写入的 report.md"}<ChevronRight className="h-3.5 w-3.5" /></button>
</TraceStage>
{imageStageVisible && <TraceStage number={5} title="生成报告配图" detail={imageDone ? completedImages > 0 ? `已生成 ${completedImages} 张配图并嵌入 report.md${failedImages ? `;另有 ${failedImages} 张未生成,文字报告已保留` : ""}。` : "配图未生成,已保留完整文字报告。" : imageCurrent ? `正在生成报告配图,已处理 ${processedImages}/${imageTotal || "?"} 张。` : "报告写完后会为关键章节生成并嵌入配图。"} done={imageDone} current={imageCurrent}>
{imageTotal > 0 && <div className="mb-3 h-2 overflow-hidden rounded-full bg-muted"><div className="h-full rounded-full bg-primary transition-[width] duration-300" style={{ width: `${imageDone ? 100 : imageProgress}%` }} /></div>}
{snapshot.images.length > 0 ? <div className="space-y-2">{snapshot.images.map((image) => <div key={image.position} className="flex min-w-0 items-center gap-2 rounded-lg border border-border/70 bg-muted/[0.12] px-3 py-2"><ImagePlus className={`h-3.5 w-3.5 shrink-0 ${image.status === "completed" ? "text-emerald-600" : image.status === "failed" ? "text-amber-600" : "animate-pulse text-primary"}`} /><div className="min-w-0 flex-1"><p className="truncate text-xs font-medium">第 {image.position} 张 · {image.section}</p><p className="mt-0.5 text-[11px] text-muted-foreground">{image.status === "completed" ? `${image.name ?? "已保存并嵌入报告"}` : image.status === "failed" ? image.error ?? "生成失败,已保留文字报告" : "正在调用图片模型…"}</p></div><span className={`shrink-0 text-[11px] ${image.status === "completed" ? "text-emerald-700 dark:text-emerald-300" : image.status === "failed" ? "text-amber-700 dark:text-amber-300" : "text-primary"}`}>{image.status === "completed" ? "已生成" : image.status === "failed" ? "未生成" : "生成中"}</span></div>)}</div> : <div className="rounded-lg border border-dashed border-border px-3 py-2 text-xs text-muted-foreground">{imageCurrent ? "正在准备第一张报告配图…" : `最多生成 ${imageTotal || requestedImageCount} 张,并在完成后插入对应章节。`}</div>}
{completedImages > 0 && <button type="button" onClick={onOpenSandbox} className="mt-3 inline-flex items-center gap-1.5 rounded-md bg-primary/8 px-2.5 py-1.5 text-xs font-medium text-primary hover:bg-primary/12"><ImagePlus className="h-3.5 w-3.5" />查看已嵌入报告的配图<ChevronRight className="h-3.5 w-3.5" /></button>}
</TraceStage>}
{snapshot.warnings.length > 0 && <div className="rounded-lg bg-amber-500/10 px-3 py-2.5 text-xs leading-5 text-amber-800 dark:text-amber-200"><p className="font-medium">过程提示</p>{snapshot.warnings.map((warning) => <p key={warning} className="mt-1">{warning}</p>)}</div>}
{error && <p className="rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">实时连接提示:{error}。研究仍会继续,页面会从已保存的事件记录恢复。</p>}
</div>}
@@ -2398,7 +2549,8 @@ const ResearchTrace = memo(function ResearchTrace({ events, phase, planText, pla
});
function CompletionSummary({ session, sourceCount, onOpenSandbox, onExport }: { session: DeepResearchSession; sourceCount: number; onOpenSandbox: () => void; onExport: (format: "word" | "markdown" | "html") => void }) {
return <div className="rounded-2xl rounded-tl-md border border-emerald-500/20 bg-card p-4 shadow-sm"><div className="flex items-start gap-2"><div className="mt-0.5 rounded-full bg-emerald-500/12 p-1 text-emerald-600"><CheckCircle2 className="h-3.5 w-3.5" /></div><div><p className="text-sm font-medium">研究报告已完成</p><p className="mt-1 text-sm leading-6 text-muted-foreground">已基于 {sourceCount || session.sourceCount} 条资料完成报告,并保留来源供后续追问。报告正文在右侧沙箱中打开。</p></div></div><div className="mt-4 flex flex-wrap gap-2"><Button size="sm" onClick={onOpenSandbox}><FolderOpen className="mr-1.5 h-3.5 w-3.5" />查看报告</Button><Button size="sm" variant="outline" onClick={() => onExport("word")}><FileDown className="mr-1.5 h-3.5 w-3.5" />Word</Button><Button size="sm" variant="outline" onClick={() => onExport("markdown")}><FileCode2 className="mr-1.5 h-3.5 w-3.5" />Markdown</Button><Button size="sm" variant="outline" onClick={() => onExport("html")}><FileType2 className="mr-1.5 h-3.5 w-3.5" />HTML</Button></div></div>;
const imageCount = countEmbeddedReportImages(session.reportMarkdown);
return <div className="rounded-2xl rounded-tl-md border border-emerald-500/20 bg-card p-4 shadow-sm"><div className="flex items-start gap-2"><div className="mt-0.5 rounded-full bg-emerald-500/12 p-1 text-emerald-600"><CheckCircle2 className="h-3.5 w-3.5" /></div><div><p className="text-sm font-medium">研究报告已完成</p><p className="mt-1 text-sm leading-6 text-muted-foreground">已基于 {sourceCount || session.sourceCount} 条资料完成报告,并保留来源供后续追问。报告正文在右侧沙箱中打开。</p>{imageCount > 0 && <p className="mt-1.5 flex items-center gap-1.5 text-sm leading-6 text-emerald-700 dark:text-emerald-300"><ImagePlus className="h-3.5 w-3.5" />已生成 {imageCount} 张报告配图,已嵌入正文对应章节。</p>}</div></div><div className="mt-4 flex flex-wrap gap-2"><Button size="sm" onClick={onOpenSandbox}><FolderOpen className="mr-1.5 h-3.5 w-3.5" />查看报告</Button><Button size="sm" variant="outline" onClick={() => onExport("word")}><FileDown className="mr-1.5 h-3.5 w-3.5" />Word</Button><Button size="sm" variant="outline" onClick={() => onExport("markdown")}><FileCode2 className="mr-1.5 h-3.5 w-3.5" />Markdown</Button><Button size="sm" variant="outline" onClick={() => onExport("html")}><FileType2 className="mr-1.5 h-3.5 w-3.5" />HTML</Button></div></div>;
}
function PlanReviewCard({ plan, submitting, onSubmit }: { plan: Record<string, unknown> | undefined; submitting: boolean; onSubmit: (action: "approve" | "revise", feedback?: string) => void }) {
@@ -2409,8 +2561,132 @@ function PlanReviewCard({ plan, submitting, onSubmit }: { plan: Record<string, u
// ── advanced settings dialog ─────────────────────────────────────────────────
function AdvancedResearchSettingsDialog({ open, onOpenChange, mode, options, classicMode, onClassicModeChange, imageGenerationAvailable, imageLimit, onChange }: { open: boolean; onOpenChange: (open: boolean) => void; mode: ResearchMode; options: AdvancedResearchOptions; classicMode: boolean; onClassicModeChange: (value: boolean) => void; imageGenerationAvailable: boolean; imageLimit: number; onChange: <K extends keyof AdvancedResearchOptions>(key: K, value: AdvancedResearchOptions[K]) => void }) {
return <Dialog open={open} onOpenChange={onOpenChange}><DialogContent className="max-h-[88vh] max-w-3xl overflow-y-auto"><DialogHeader><DialogTitle>高级研究配置</DialogTitle><DialogDescription>本次设置会作为下一次研究的默认值;对话式收集的最终选项在报告配置卡片中确认。</DialogDescription></DialogHeader><div className="rounded-xl border border-border p-3"><label className="flex cursor-pointer gap-3"><input type="checkbox" checked={classicMode} onChange={(event) => onClassicModeChange(event.target.checked)} className="mt-0.5 accent-primary" /><span><span className="block text-sm font-medium">经典模式</span><span className="mt-1 block text-xs leading-5 text-muted-foreground">回退到旧全流程:输入框直接选模式并自动开始研究,不经过收集对话与报告配置卡片。仅影响下一次新建的研究。</span></span></label></div><div className="grid gap-5 py-2 sm:grid-cols-2"><SettingSelect label="输出语言" value={options.language} onValueChange={(value) => onChange("language", value as AdvancedResearchOptions["language"])} options={[{ value: "zh-CN", label: "中文" }, { value: "en-US", label: "English" }]} /><SettingSelect label="报告语气" value={options.tone} onValueChange={(value) => onChange("tone", value as ResearchTone)} options={[{ value: "objective", label: "客观" }, { value: "formal", label: "正式" }, { value: "analytical", label: "分析" }, { value: "persuasive", label: "说服" }]} /><NumberSetting label="单次查询结果" hint="1–20 条" value={options.maxSearchResults} onChange={(value) => onChange("maxSearchResults", value)} /><NumberSetting label="子问题轮数" hint="1–10 轮" value={options.maxIterations} onChange={(value) => onChange("maxIterations", value)} /><NumberSetting label="最大并发" hint="1–8 路" value={options.maxConcurrency} onChange={(value) => onChange("maxConcurrency", value)} /><NumberSetting label="上下文词数" hint="500–120000" value={options.maxContextWords} onChange={(value) => onChange("maxContextWords", value)} />{(mode === "detailed" || mode === "multi_agent") && <NumberSetting label="最多子主题" hint="1–20 个" value={options.maxSubtopics} onChange={(value) => onChange("maxSubtopics", value)} />}{mode === "deep" && <><NumberSetting label="深挖广度" hint="1–8" value={options.deepBreadth} onChange={(value) => onChange("deepBreadth", value)} /><NumberSetting label="深挖层级" hint="1–4" value={options.deepDepth} onChange={(value) => onChange("deepDepth", value)} /></>}{mode === "multi_agent" && <NumberSetting label="修订轮数" hint="0–5 轮" value={options.maxRevisionLoops} onChange={(value) => onChange("maxRevisionLoops", value)} />}</div><div className="space-y-2"><label htmlFor="research-outline" className="text-sm font-medium">自定义报告结构</label><Textarea id="research-outline" value={options.customOutline} onChange={(event) => onChange("customOutline", event.target.value)} className="min-h-28 resize-y" placeholder={"例如:\n# 执行摘要\n## 市场与背景\n## 机会与风险"} /><p className="text-xs text-muted-foreground">支持 Markdown 标题或编号列表,会作为报告写作结构要求。</p></div><div className="grid gap-3 sm:grid-cols-2"><label className="flex cursor-pointer gap-3 rounded-xl border p-3"><input type="checkbox" checked={options.thinkingEnabled} onChange={(event) => onChange("thinkingEnabled", event.target.checked)} className="mt-0.5 accent-primary" /><span><span className="block text-sm font-medium">启用模型深度思考</span><span className="mt-1 block text-xs leading-5 text-muted-foreground">仅在当前系统默认模型支持时生效,会增加响应时延与模型消耗。</span></span></label>{imageGenerationAvailable && <div className="rounded-xl border p-3"><NumberSetting label="最多报告配图" hint={`1–${imageLimit} 张`} value={options.maxGeneratedImages} onChange={(value) => onChange("maxGeneratedImages", value)} /></div>}</div><DialogFooter><Button onClick={() => onOpenChange(false)}>保存配置</Button></DialogFooter></DialogContent></Dialog>;
function AdvancedResearchSettingsDialog({
open,
onOpenChange,
mode,
options,
classicMode,
onClassicModeChange,
imageGenerationAvailable,
imageLimit,
onChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
mode: ResearchMode;
options: AdvancedResearchOptions;
classicMode: boolean;
onClassicModeChange: (value: boolean) => void;
imageGenerationAvailable: boolean;
imageLimit: number;
onChange: <K extends keyof AdvancedResearchOptions>(key: K, value: AdvancedResearchOptions[K]) => void;
}) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[88vh] w-[calc(100vw-2rem)] max-w-none overflow-y-auto sm:w-[600px] sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>高级研究配置</DialogTitle>
<DialogDescription>
本次设置会作为下一次研究的默认值;对话式收集的最终选项在报告配置卡片中确认。
</DialogDescription>
</DialogHeader>
<div className="rounded-xl border border-border p-3">
<label className="flex cursor-pointer gap-3">
<input
type="checkbox"
checked={classicMode}
onChange={(event) => onClassicModeChange(event.target.checked)}
className="mt-0.5 accent-primary"
/>
<span>
<span className="block text-sm font-medium">经典模式</span>
<span className="mt-1 block text-xs leading-5 text-muted-foreground">
回退到旧全流程:输入框直接选模式并自动开始研究,不经过收集对话与报告配置卡片。仅影响下一次新建的研究。
</span>
</span>
</label>
</div>
<div className="grid gap-5 py-2 sm:grid-cols-2">
<SettingSelect
label="输出语言"
value={options.language}
onValueChange={(value) => onChange("language", value as AdvancedResearchOptions["language"])}
options={[
{ value: "zh-CN", label: "中文" },
{ value: "en-US", label: "English" },
]}
/>
<SettingSelect
label="报告语气"
value={options.tone}
onValueChange={(value) => onChange("tone", value as ResearchTone)}
options={[
{ value: "objective", label: "客观" },
{ value: "formal", label: "正式" },
{ value: "analytical", label: "分析" },
{ value: "persuasive", label: "说服" },
]}
/>
<NumberSetting label="单次查询结果" hint="1–20 条" value={options.maxSearchResults} onChange={(value) => onChange("maxSearchResults", value)} />
<NumberSetting label="子问题轮数" hint="1–10 轮" value={options.maxIterations} onChange={(value) => onChange("maxIterations", value)} />
<NumberSetting label="最大并发" hint="1–8 路" value={options.maxConcurrency} onChange={(value) => onChange("maxConcurrency", value)} />
<NumberSetting label="上下文词数" hint="500–120000" value={options.maxContextWords} onChange={(value) => onChange("maxContextWords", value)} />
{(mode === "detailed" || mode === "multi_agent") && (
<NumberSetting label="最多子主题" hint="1–20 个" value={options.maxSubtopics} onChange={(value) => onChange("maxSubtopics", value)} />
)}
{mode === "deep" && (
<>
<NumberSetting label="深挖广度" hint="1–8" value={options.deepBreadth} onChange={(value) => onChange("deepBreadth", value)} />
<NumberSetting label="深挖层级" hint="1–4" value={options.deepDepth} onChange={(value) => onChange("deepDepth", value)} />
</>
)}
{mode === "multi_agent" && (
<NumberSetting label="修订轮数" hint="0–5 轮" value={options.maxRevisionLoops} onChange={(value) => onChange("maxRevisionLoops", value)} />
)}
</div>
<ReportOutlineEditor
id="research-outline"
value={options.customOutline}
onChange={(value) => onChange("customOutline", value)}
/>
<div className="grid gap-3 sm:grid-cols-2">
<label className="flex cursor-pointer gap-3 rounded-xl border p-3">
<input
type="checkbox"
checked={options.thinkingEnabled}
onChange={(event) => onChange("thinkingEnabled", event.target.checked)}
className="mt-0.5 accent-primary"
/>
<span>
<span className="block text-sm font-medium">启用模型深度思考</span>
<span className="mt-1 block text-xs leading-5 text-muted-foreground">
仅在当前系统默认模型支持时生效,会增加响应时延与模型消耗。
</span>
</span>
</label>
{imageGenerationAvailable && (
<div className="rounded-xl border p-3">
<NumberSetting
label="最多报告配图"
hint={`1–${imageLimit} 张`}
value={options.maxGeneratedImages}
onChange={(value) => onChange("maxGeneratedImages", value)}
/>
</div>
)}
</div>
<DialogFooter>
<Button onClick={() => onOpenChange(false)}>保存配置</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function NumberSetting({ label, hint, value, onChange }: { label: string; hint: string; value: string; onChange: (value: string) => void }) { return <label className="block"><span className="mb-2 block text-sm font-medium">{label}</span><Input value={value} inputMode="numeric" onChange={(event) => onChange(event.target.value)} /><span className="mt-1 block text-xs text-muted-foreground">{hint}</span></label>; }
@@ -8,7 +8,7 @@
* 以 chat 入口启动报告写作 job —— 保留旧输入框全部能力,改造收敛在这一张卡片。
*/
import { useEffect, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import {
ChevronRight,
ImagePlus,
@@ -65,6 +65,198 @@ export type AdvancedResearchOptions = {
customOutline: string;
};
/**
* A deliberately domain-neutral structure which gives lightweight models a
* dependable writing scaffold without forcing market-analysis terminology on
* every research topic. Users can edit or replace it in the advanced dialog.
*/
export const DEFAULT_REPORT_OUTLINE = `# 报告标题
# 引言
用 1–2 段说明研究对象、提出本议题的背景、研究范围及资料边界;简要交代全文的分析路径,不单独拆分小节。
# 一、研究对象与事实基础
## 1.1 概念、范围与背景
界定研究对象、关键概念、时间和空间范围;说明读者理解问题所需的背景。
## 1.2 已有事实与证据
按时间或逻辑顺序梳理已知事实、数据、案例或材料发现;优先使用可核验的来源。
## 1.3 当前状态与主要特征
归纳研究对象目前呈现的状态、关键变化、共性与差异;没有数据支撑时明确说明。
# 二、问题形成与作用机制
## 2.1 直接原因与触发条件
分析近期事件、条件变化或关键节点如何推动问题出现或演变。
## 2.2 深层影响因素
从制度、技术、资源、行为、环境或历史条件中选择适用维度,分析长期影响。
## 2.3 因果关系与相互作用
说明不同因素如何共同作用;区分来源已证实的事实、分析判断和仍待验证的推断。
# 三、影响、问题与不同观点
## 3.1 对研究对象的直接影响
从结果、效率、质量、风险、成本、体验或其他适用维度,分析当前影响。
## 3.2 对相关主体与外部环境的影响
说明影响如何传导到相关群体、组织、地区、系统或后续事项;不适用的维度可省略。
## 3.3 主要争议与解释差异
并列材料支持的不同观点、解释或结论,并说明各自的证据基础与局限。
# 四、比较、趋势与不确定性
## 4.1 可比案例或横向比较
在材料允许时选择历史案例、不同地区、不同方案或不同阶段进行比较,说明异同与启示。
## 4.2 发展趋势与关键变量
识别后续可能变化、决定性变量、观察指标和潜在转折点;不把预测写成既成事实。
## 4.3 风险情景与不确定性
说明不同情景的触发条件、可能影响和证据不足之处;对没有依据的推测明确标注。
# 五、结论、建议与后续关注
## 5.1 核心结论
用编号或分点方式收束全文;每条结论都应能回溯到正文中的事实和分析。
## 5.2 可行建议或应对方向
仅在研究材料支持时提出建议;说明适用对象、前提条件、优先级和预期作用。
## 5.3 资料局限与后续关注
列出证据缺口、数据口径差异、尚不能下结论的问题,以及值得持续跟踪的事项。
# 参考资料
仅列出本报告实际使用的来源,并与正文引用保持一致。
> 写作要求:引言保持简洁;每个二级小节按材料充分程度写 2–5 段完整分析,每段围绕一个明确观点展开,采用“事实/证据 → 分析 → 影响或小结”的顺序。章节可根据研究主题合并、增删或调整,但不要为了套模板而编造不适用的主体、数据、案例或引用。`;
/**
* A causal, chapter-to-chapter alternative for topics where the report should
* read as one continuous argument rather than a set of parallel dimensions.
* Each chapter explicitly takes the previous chapter's conclusion as input.
*/
export const PROGRESSIVE_REPORT_OUTLINE = `# 报告标题
# 引言
用 1–2 段说明研究对象、核心问题、研究范围和资料边界;简要说明本文将按“事实基线 → 成因机制 → 影响判断 → 应对方向 → 结论”的递进逻辑展开。
# 一、事实基线:问题是什么
## 1.1 概念界定与研究范围
明确关键概念、时间/对象边界和判断口径,避免后续讨论混用概念。
## 1.2 关键事实、数据与证据
按时间或逻辑顺序列出已经发生的事实、可核验数据、案例和来源。
## 1.3 基线判断与待解问题
基于前两节归纳当前状态、主要矛盾和需要解释的核心问题。
> 本章产出:后续分析必须以本章已经确认的事实和问题为基础,不得在后文补写没有来源支撑的新事实。
# 二、形成机制:为什么会这样
## 2.1 直接原因与触发条件
从第一章识别的问题出发,分析近期事件、条件变化或关键节点如何导致当前结果。
## 2.2 深层条件与关键约束
结合第一章的证据,说明制度、技术、资源、行为、环境或历史条件等长期影响。
## 2.3 因果链条与关键变量
把前两节串成清晰的“条件 → 机制 → 结果”链条,标出决定性变量及仍待验证的环节。
> 本章产出:第三章只能沿用本章已说明的机制评估影响,不能把相关性直接写成因果关系。
# 三、结果与影响:会带来什么
## 3.1 直接结果与短期影响
依据第二章的因果链条,分析对研究对象最直接、最先出现的影响。
## 3.2 传导效应与中长期影响
继续说明影响如何传导至相关主体、外部环境或后续事项;不适用的维度可省略。
## 3.3 权衡、分歧与边界
比较不同影响之间的取舍、材料支持的不同观点,以及结论适用的边界。
> 本章产出:第四章的趋势判断、情景和建议应以本章已评估的影响、风险和权衡为依据。
# 四、趋势与应对:接下来怎么办
## 4.1 未来走势与观察指标
从第三章的影响出发,识别可能变化、关键观察指标和潜在转折点;不把预测写成既成事实。
## 4.2 可选方案与适用条件
针对第三章发现的问题和风险,提出可行的应对方向、前提条件与优先级。
## 4.3 风险情景与验证重点
说明不同情景的触发条件、可能后果,以及下一步需要补充验证的数据或材料。
> 本章产出:第五章只综合前四章已经建立的事实、机制、影响和建议,不新增独立论点。
# 五、综合结论与后续关注
## 5.1 核心结论
按“事实基线 → 原因机制 → 主要影响 → 应对方向”逐项收束,每条结论都可回溯到前文证据。
## 5.2 局限与后续研究
列出资料缺口、数据口径差异、尚不能下结论的问题和后续跟踪清单。
# 参考资料
仅列出本报告实际使用的来源,并与正文引用保持一致。
> 写作要求:除引言外,每个二级小节写 2–5 段完整分析。后章必须明确承接前章的结论;若材料无法支持递进关系,应说明资料限制而非编造衔接。`;
export type ReportOutlineTemplate = "general" | "progressive" | "custom";
const REPORT_OUTLINE_TEMPLATE_OPTIONS: Array<{ value: ReportOutlineTemplate; label: string }> = [
{ value: "general", label: "通用分析结构" },
{ value: "progressive", label: "递进式论证结构" },
{ value: "custom", label: "自定义结构" },
];
function matchingOutlineTemplate(outline: string): ReportOutlineTemplate {
const normalized = outline.trim();
if (normalized === DEFAULT_REPORT_OUTLINE.trim()) return "general";
if (normalized === PROGRESSIVE_REPORT_OUTLINE.trim()) return "progressive";
return "custom";
}
/** Shared by the initial settings dialog and the write-confirmation card. */
export function ReportOutlineEditor({
id,
value,
onChange,
}: {
id: string;
value: string;
onChange: (value: string) => void;
}) {
const [customSelected, setCustomSelected] = useState(false);
const template = customSelected ? "custom" : matchingOutlineTemplate(value);
const selectTemplate = (next: ReportOutlineTemplate) => {
if (next === "custom") {
setCustomSelected(true);
// “自定义” is an intentional blank starting point. The two presets are
// still available as one-click scaffolds whenever the user wants them.
onChange("");
return;
}
setCustomSelected(false);
onChange(next === "general" ? DEFAULT_REPORT_OUTLINE : PROGRESSIVE_REPORT_OUTLINE);
};
return (
<div className="space-y-2">
<label htmlFor={`${id}-template`} className="block text-xs font-medium">
报告结构模板
</label>
<Select value={template} onValueChange={(next) => selectTemplate(next as ReportOutlineTemplate)}>
<SelectTrigger id={`${id}-template`} className="h-9 text-sm" aria-label="报告结构模板">
<SelectValue />
</SelectTrigger>
<SelectContent>
{REPORT_OUTLINE_TEMPLATE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
<label htmlFor={id} className="block pt-1 text-xs font-medium">
报告结构(可编辑)
</label>
<Textarea
id={id}
value={value}
onChange={(event) => {
setCustomSelected(true);
onChange(event.target.value);
}}
maxLength={4000}
className="min-h-28 resize-y text-xs"
placeholder={"例如:\n# 引言\n# 一、问题与事实\n## 1.1 范围与依据"}
/>
<p className="text-[10px] leading-4 text-muted-foreground">
选择预设模板会替换下方内容;选择“自定义”会清空后从头编辑。支持 Markdown 标题或编号列表。
</p>
</div>
);
}
export const DEFAULT_ADVANCED_OPTIONS: AdvancedResearchOptions = {
language: "zh-CN",
tone: "objective",
@@ -78,7 +270,7 @@ export const DEFAULT_ADVANCED_OPTIONS: AdvancedResearchOptions = {
maxRevisionLoops: "2",
maxGeneratedImages: "2",
thinkingEnabled: false,
customOutline: "",
customOutline: DEFAULT_REPORT_OUTLINE,
};
/** Seed the card's editable options from the frozen session config snapshot. */
@@ -100,7 +292,9 @@ export function advancedOptionsFromConfig(config?: Record<string, unknown>): Adv
maxRevisionLoops: numberish("max_revision_loops", DEFAULT_ADVANCED_OPTIONS.maxRevisionLoops),
maxGeneratedImages: numberish("max_generated_images", DEFAULT_ADVANCED_OPTIONS.maxGeneratedImages),
thinkingEnabled: config?.thinking_enabled === true,
customOutline: typeof config?.custom_outline === "string" ? config.custom_outline : "",
customOutline: typeof config?.custom_outline === "string" && config.custom_outline.trim()
? config.custom_outline
: DEFAULT_REPORT_OUTLINE,
};
}
@@ -174,11 +368,6 @@ export function ReportConfigCard({
const [generateImages, setGenerateImages] = useState(defaultGenerateImages);
const [advancedOpen, setAdvancedOpen] = useState(false);
useEffect(() => {
setOptions(defaultOptions);
setGenerateImages(defaultGenerateImages);
}, [defaultOptions, defaultGenerateImages]);
const modes = useMemo(
() => (availableModes.length ? availableModes : (["basic", "quick", "detailed", "deep", "multi_agent"] as ResearchMode[])),
[availableModes],
@@ -278,15 +467,11 @@ export function ReportConfigCard({
<CardNumber label="修订轮数" hint="0–5 轮" value={options.maxRevisionLoops} onChange={(v) => update("maxRevisionLoops", v)} />
)}
<div className="sm:col-span-2">
<span className="mb-1.5 block text-xs font-medium">自定义报告结构</span>
<Textarea
<ReportOutlineEditor
id="report-card-outline"
value={options.customOutline}
onChange={(event) => update("customOutline", event.target.value)}
maxLength={4000}
className="min-h-20 resize-y text-xs"
placeholder={"例如:\n# 执行摘要\n## 市场与背景\n## 机会与风险\n## 建议"}
onChange={(value) => update("customOutline", value)}
/>
<p className="mt-1 text-[10px] text-muted-foreground">支持 Markdown 标题或编号列表,作为报告写作结构要求。</p>
</div>
<label className="flex cursor-pointer items-start gap-2 rounded-lg border border-border/70 bg-background/60 p-2.5 text-[11px] leading-4 sm:col-span-2">
<input
@@ -10,11 +10,12 @@
* 切预览 + 脉冲、复制/另存、展示层脱敏)。
*/
import type { BaseStream } from "@langchain/langgraph-sdk/react";
import { FileTextIcon, XIcon } from "lucide-react";
import { FileTextIcon, Loader2Icon, SparklesIcon, XIcon } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import type { GroupImperativeHandle } from "react-resizable-panels";
import { ConversationEmptyState } from "@/components/ai-elements/conversation";
import { ArtifactAction } from "@/components/ai-elements/artifact";
import { Button } from "@/components/ui/button";
import {
ResizableHandle,
@@ -136,18 +137,6 @@ export function ResearchSandboxPanel({
<div className="relative h-full bg-[#fffdfa] p-4">
{selectedArtifact && previewThreadId ? (
<>
{onDocumentRewriteStart && (
<div className="absolute top-7 right-36 z-30">
<Button
size="sm"
variant="outline"
disabled={documentRewriteActive}
onClick={() => setRewriteDialogOpen(true)}
>
{documentRewriteActive ? "正在改写" : "AI 全文改写"}
</Button>
</div>
)}
{documentRewriteActive && onCancelDocumentRewrite && (
<div className="absolute right-36 bottom-7 z-30">
<Button size="sm" variant="outline" onClick={onCancelDocumentRewrite}>
@@ -159,8 +148,24 @@ export function ResearchSandboxPanel({
className="size-full"
filepath={selectedArtifact}
threadId={previewThreadId}
researchSessionId={previewThreadId}
onClose={handleArtifactsClose}
onDownloadClick={onDownload}
toolbarActions={
onDocumentRewriteStart ? (
<ArtifactAction
icon={documentRewriteActive ? Loader2Icon : SparklesIcon}
label={documentRewriteActive ? "正在全文改写" : "AI 全文改写"}
tooltip={
documentRewriteActive
? "正在生成临时草稿,原报告尚未被覆盖"
: "使用 AI 重写完整 report.md"
}
disabled={documentRewriteActive}
onClick={() => setRewriteDialogOpen(true)}
/>
) : null
}
/>
</>
) : (
@@ -20,6 +20,7 @@ import {
artifactPreviewKey,
useArtifacts,
} from "@/components/workspace/artifacts";
import { stripInlineThinkTags } from "@/core/streamdown/inline-think";
/** 报告在沙箱中的固定虚拟路径(与 runner 实际写入的 artifacts 路径一致)。 */
export const REPORT_OUTPUT_PATH = "/mnt/user-data/outputs/report.md";
@@ -72,7 +73,12 @@ export function useReportSandbox({ threadKey, report, writing }: UseReportSandbo
setStubMessage((prev) => {
const prevArgs = prev?.tool_calls?.[0]?.args as { content?: unknown } | undefined;
const prevContent = typeof prevArgs?.content === "string" ? prevArgs.content : "";
const content = report.trim() ? report : prevContent;
// Backstop the report sandbox against providers that expose reasoning
// through raw `<think>` tags in ordinary text chunks. The backend
// filters them at the source; this also protects historical/replayed
// streams generated by an older service.
const visibleReport = stripInlineThinkTags(report);
const content = visibleReport.trim() ? visibleReport : prevContent;
return {
id: messageId,
type: "ai",
@@ -6,6 +6,7 @@
*/
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import { createClientId } from "@/lib/client-id";
/**
* 节点状态由岗位会商服务推进;前端只据此决定可否发送、展示进度,
@@ -147,10 +148,7 @@ const BASE = () => `${getBackendBaseURL()}${PRIMARY_API_PATH}`;
* (不会重复 +revision / 重复解锁下游 / 重复驳回)。每次新的用户操作则取新键。
*/
function newCommandId(): string {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID().replace(/-/g, "");
}
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 12)}`;
return createClientId().replace(/-/g, "");
}
/**
@@ -29,6 +29,11 @@ export interface PositionBusinessChainFlowProps {
chain: BusinessChain | null;
loading?: boolean;
className?: string;
/**
* Render inside the Step 1 intent-delivery surface. The host owns the
* border and background; this flow keeps only its title row and canvas.
*/
embedded?: boolean;
}
function getFlowStages(chain: BusinessChain): FlowStage[] {
@@ -50,6 +55,7 @@ export function PositionBusinessChainFlow({
chain,
loading = false,
className,
embedded = false,
}: PositionBusinessChainFlowProps) {
const [dialogOpen, setDialogOpen] = useState(false);
@@ -57,26 +63,40 @@ export function PositionBusinessChainFlow({
<section
className={cn(
"position-business-chain-flow flex h-full min-h-[460px] w-full flex-col overflow-hidden rounded-2xl border border-[#ece7e4] bg-white p-3 shadow-[0_4px_20px_rgba(0,0,0,0.02)] dark:border-[#2b8ffe]/30 dark:bg-[#042b59]/55 dark:shadow-none",
embedded && "min-h-0 rounded-none border-0 bg-transparent p-0 shadow-none",
className,
)}
>
<div className="mb-3 flex shrink-0 items-center justify-between gap-2 border-b border-slate-200 pb-3 dark:border-[#2b8ffe]/40">
<div
className={cn(
"mb-3 flex shrink-0 items-center justify-between gap-2 border-b border-slate-200 pb-3 dark:border-[#2b8ffe]/40",
embedded && "relative mb-4 h-10 border-slate-200 pb-0 dark:border-[#2b8ffe]/60",
)}
>
<div className="flex min-w-0 items-center gap-2">
<span
aria-hidden="true"
className="h-6 w-1 shrink-0 rounded-l-[2px] rounded-br-[2px] bg-[linear-gradient(167deg,#f15959_0%,#cc1925_100%)] dark:bg-[linear-gradient(167deg,#a2e1f9_0%,#45c3f3_100%)]"
className={cn(
"h-6 w-1 shrink-0 rounded-l-[2px] rounded-br-[2px] bg-[linear-gradient(167deg,#f15959_0%,#cc1925_100%)] dark:bg-[linear-gradient(167deg,#a2e1f9_0%,#45c3f3_100%)]",
embedded && "h-6",
)}
/>
<div className="min-w-0">
<h2 className="truncate text-base font-semibold text-slate-900 dark:text-white">业务链条</h2>
<p className="truncate text-[11px] text-slate-400 dark:text-[#8aa0c4]" aria-live="polite">
{chain?.title ?? (loading ? "正在加载管理员配置…" : "等待管理员配置")}
</p>
<h2 className={cn("truncate text-base font-semibold text-slate-900 dark:text-white", embedded && "text-lg font-[550] dark:text-white")}>业务链条</h2>
{!embedded && (
<p className="truncate text-[11px] text-slate-400 dark:text-[#8aa0c4]" aria-live="polite">
{chain?.title ?? (loading ? "正在加载管理员配置…" : "等待管理员配置")}
</p>
)}
</div>
</div>
{chain && (
<button
type="button"
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-[#f0c8c8] bg-[#fff7f7] text-[#cc1925] transition-colors hover:border-[#cc1925] hover:bg-[#fff0f0] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#cc1925]/30 dark:border-[#2b8ffe]/45 dark:bg-[#082d5f] dark:text-[#8fc4ff]"
className={cn(
"flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-[#f0c8c8] bg-[#fff7f7] text-[#cc1925] transition-colors hover:border-[#cc1925] hover:bg-[#fff0f0] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#cc1925]/30 dark:border-[#2b8ffe]/45 dark:bg-[#082d5f] dark:text-[#8fc4ff]",
embedded && "h-7 w-7 rounded-[4px]",
)}
title="查看完整流程图"
aria-label="查看完整流程图"
onClick={() => setDialogOpen(true)}
@@ -92,7 +112,7 @@ export function PositionBusinessChainFlow({
</div>
) : (
<>
<BusinessChainCanvas chain={chain} className="min-h-0 flex-1" />
<BusinessChainCanvas chain={chain} embedded={embedded} className="min-h-0 flex-1" />
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="flex h-[86vh] w-[96vw] max-w-[1180px] flex-col gap-0 overflow-hidden p-0">
<DialogHeader className="shrink-0 border-b px-5 py-4 dark:border-[#2b8ffe]/25">
@@ -112,18 +132,20 @@ export function PositionBusinessChainFlow({
function BusinessChainCanvas({
chain,
expanded = false,
embedded = false,
className,
}: {
chain: BusinessChain;
expanded?: boolean;
embedded?: boolean;
className?: string;
}) {
const [view, setView] = useState({ tx: 0, ty: 0 });
const [panning, setPanning] = useState(false);
const dragRef = useRef<{ x: number; y: number; tx: number; ty: number } | null>(null);
const stages = getFlowStages(chain);
const nodeWidth = expanded ? 250 : FLOW_NODE_WIDTH;
const nodeGap = expanded ? 18 : FLOW_NODE_GAP;
const nodeWidth = expanded ? 250 : embedded ? 158 : FLOW_NODE_WIDTH;
const nodeGap = expanded ? 18 : embedded ? 10 : FLOW_NODE_GAP;
let colorIndex = 0;
const endPan = (event: React.PointerEvent<HTMLDivElement>) => {
@@ -141,6 +163,7 @@ function BusinessChainCanvas({
<div
className={cn(
"relative overflow-hidden rounded-lg border border-slate-100 bg-slate-50/70 touch-none dark:border-[#2b8ffe]/25 dark:bg-[#032650]/75",
embedded && "rounded-[4px]",
panning ? "cursor-grabbing" : "cursor-grab",
className,
)}
@@ -193,6 +216,7 @@ function BusinessChainCanvas({
description={stage.goal?.trim() || seat.description?.trim() || "业务链席位"}
accent={accent}
width={nodeWidth}
compact={embedded}
/>
);
})}
@@ -252,17 +276,22 @@ function FlowNode({
accent,
completed = false,
width,
compact = false,
}: {
title: string;
description: string;
accent: string;
completed?: boolean;
width: number;
compact?: boolean;
}) {
const StatusIcon = completed ? CheckCircle2 : Circle;
return (
<article
className="relative overflow-hidden rounded-xl border border-slate-200 bg-white p-3 pr-10 shadow-sm dark:border-[#2b8ffe]/40 dark:bg-[#0a2d58]"
className={cn(
"relative overflow-hidden rounded-xl border border-slate-200 bg-white p-3 pr-10 shadow-sm dark:border-[#2b8ffe]/40 dark:bg-[#0a2d58]",
compact && "rounded-lg",
)}
style={{ width, borderLeftWidth: 5, borderLeftColor: accent }}
>
<span
@@ -459,7 +459,14 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
// Clarifications collected live from `type:"tool"` frames, deduped by
// tool_call_id, then reconciled against the complete terminal list.
const seenClarificationIds = new Set<string>();
// A clarification tool call is a hard pause in the Step 1 conversation.
// Some model providers keep emitting text (and can even produce an
// INTENT_READY terminal frame) after the tool result. That text belongs to
// a continuation the user has not approved yet, so never let it replace the
// assistance card in the current turn.
let awaitingUserClarification = false;
const collectClarification = (payload: ClarificationPayload, toolCallId: string) => {
awaitingUserClarification = true;
const dedupeKey = toolCallId || `idx-${result.clarifications.length}`;
if (seenClarificationIds.has(dedupeKey)) return;
seenClarificationIds.add(dedupeKey);
@@ -501,6 +508,25 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
}
if (isFinalIntentFrame(parsed)) {
// Reconcile any terminal clarification cards, but preserve the first
// tool-call pause as the authoritative state for this turn. In
// particular, do not accept a later `done` frame / summary before the
// user has answered the displayed questions.
if (awaitingUserClarification) {
if (parsed.status === "clarification") {
const terminalCards = Array.isArray(parsed.clarifications)
? parsed.clarifications
.map(parseTerminalClarification)
.filter((item): item is { payload: ClarificationPayload; toolCallId: string } => item !== null)
: [];
if (terminalCards.length > 0) {
terminalCards.forEach((card, index) => {
collectClarification(card.payload, card.toolCallId || `terminal-${index}`);
});
}
}
continue;
}
result.status = parsed.status;
if (typeof parsed.content === "string") result.content = parsed.content;
if (parsed.summary && typeof parsed.summary === "object") {
@@ -548,6 +574,11 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
continue;
}
// Do not show follow-up thinking / generated summary text while the
// user-assistance card is waiting for an answer. This also prevents a
// later AI delta from hiding the card through an [INTENT_READY] block.
if (awaitingUserClarification) continue;
// Phase inference from LangGraph messages-tuple frames. Tool calls
// (typically ask_clarification) flip phase to tool_calling; AI text
// chunks flip to streaming. Everything else leaves phase alone.
@@ -592,5 +623,11 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
}
}
if (awaitingUserClarification && result.clarifications.length > 0) {
result.status = "clarification";
result.summary = null;
result.clarification = result.clarifications[0] ?? null;
}
return result;
}
@@ -41,6 +41,7 @@ function formatFileSize(bytes: number): string {
const SHOW_BACKGROUND_SUSPEND = true;
import { AutoGrowTextarea } from "@/components/ui/auto-grow-textarea";
import { PositionIntentInput } from "@/position-roundtable/components/PositionIntentInput";
import { useLocalSettings } from "@/core/settings";
import { cn } from "@/lib/utils";
import type { MessageMetric } from "@/components/workspace/messages/use-message-metrics";
@@ -93,6 +94,8 @@ interface ModelOption {
}
export interface Step2PanelProps {
/** 独立业务页需要与第一步完全相同的输入组件(8BF 专用,默认不改变原会商)。 */
useStep1InputAppearance?: boolean;
// ─── State ────────────────────────────────────────────────────
step2RoundtableDialogues: Step2Dialogue[];
isInitializing: boolean;
@@ -161,6 +164,7 @@ export interface Step2PanelProps {
export function Step2Panel(props: Step2PanelProps) {
const {
useStep1InputAppearance = false,
step2RoundtableDialogues,
isInitializing,
isRoundTableRunning,
@@ -377,18 +381,15 @@ export function Step2Panel(props: Step2PanelProps) {
// 干预消息携带的本地附件(点「干预并发送」时连同指令一起上传)。
const [injectFiles, setInjectFiles] = useState<File[]>([]);
const injectFileInputRef = useRef<HTMLInputElement>(null);
const handlePickInjectFiles = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const picked = Array.from(e.target.files ?? []);
const addInjectFiles = useCallback(
(picked: File[]) => {
if (picked.length > 0) {
setInjectFiles((prev) => {
const seen = new Set(prev.map((f) => `${f.name}:${f.size}`));
return [...prev, ...picked.filter((f) => !seen.has(`${f.name}:${f.size}`))];
});
}
e.target.value = "";
},
[],
);
@@ -730,92 +731,71 @@ export function Step2Panel(props: Step2PanelProps) {
</div>
)}
{/* Console + intervention bar */}
<div className="shrink-0 p-4 bg-slate-50 border-t border-slate-100 flex flex-col gap-2">
{/* 已选附件预览(点「干预并发送」时随指令上传) */}
{injectFiles.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{injectFiles.map((file, idx) => (
<span
key={`${file.name}-${file.size}-${idx}`}
className="inline-flex items-center gap-1.5 bg-white border border-slate-200 rounded-lg pl-2 pr-1 py-1 text-[11px] text-slate-600 max-w-[200px]"
{useStep1InputAppearance ? (
/* 8BF 直接复用第一步 PositionIntentInput 的 DOM、图标和深蓝框体;仅替换成会商干预的状态与动作。 */
<div className="position-roundtable-intent-page eightbf-roundtable-step2-input [&_#suggest-followup-btn]:hidden [&_#skip-question-btn]:hidden [&_#save-draft-btn]:hidden">
<PositionIntentInput
value={customAgentInput}
onValueChange={setCustomAgentInput}
attachedFiles={injectFiles}
onAddFiles={addInjectFiles}
onRemoveFile={removeInjectFile}
charactersLeft={Math.max(0, 500 - customAgentInput.length)}
isSending={false}
canSend
pendingClarification={false}
onSend={handleInject}
onStop={() => undefined}
onFollowUp={() => undefined}
onSkip={() => undefined}
onEnterRoundtable={() => undefined}
enterButtonLabel=""
intentReady={null}
hideEnterRoundtable
onManualSave={() => undefined}
onTriggerToast={() => undefined}
modelOptions={availableModels.map((model) => ({
label: model.display_name && model.display_name !== model.name
? `${model.display_name} (${model.name})`
: model.name,
value: model.name,
}))}
selectedModel={selectedModel}
onSelectedModelChange={setSelectedModel}
footerAction={(
/* 与第一步「确认意图并启动业务链」复用相同的稳定 id 和样式规则。 */
<button
id="enter-roundtable-btn"
type="button"
onClick={onProceedToStep3}
disabled={!hasConsensus}
aria-disabled={!hasConsensus}
title={hasConsensus ? undefined : "圆桌尚未达成共识,请等待总控自然收敛"}
className={cn(
"flex h-10 shrink-0 items-center gap-1.5 rounded-[4px] px-3 text-xs font-black transition active:scale-[0.98] shadow-md shadow-emerald-500/10",
hasConsensus
? "cursor-pointer bg-emerald-600 text-white hover:bg-emerald-700"
: "cursor-not-allowed bg-emerald-600/40 text-white",
)}
>
<FileIcon className="w-3 h-3 shrink-0 text-primary" />
<span className="truncate font-medium" title={file.name}>
{file.name}
</span>
<span className="shrink-0 text-slate-400">
{formatFileSize(file.size)}
</span>
<button
type="button"
onClick={() => removeInjectFile(idx)}
className="shrink-0 p-0.5 rounded hover:bg-slate-200 text-slate-400 hover:text-slate-600 transition"
title="移除"
>
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
)}
<div className="flex items-end gap-3">
<AutoGrowTextarea
value={customAgentInput}
onChange={(e) => setCustomAgentInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleInject();
}
}}
placeholder="作为指挥官,您可以随时插话输入指令,对智能体博弈朝向实施人工干预...(Enter 发送 / Shift+Enter 换行)"
className="flex-1 bg-white border border-slate-200 rounded-xl px-3.5 py-2.5 text-xs leading-relaxed focus:outline-none focus:border-primary focus:ring-1 focus:ring-[var(--brand-200)]/50 font-medium"
maxHeight={280}
/>
<input
ref={injectFileInputRef}
type="file"
multiple
className="hidden"
onChange={handlePickInjectFiles}
/>
<button
type="button"
onClick={() => injectFileInputRef.current?.click()}
title="上传参考文件(席位/总控将读取文件内容辅助研讨)"
className="bg-white border border-slate-200 hover:bg-slate-50 text-slate-500 hover:text-primary p-2.5 rounded-xl transition shrink-0 cursor-pointer active:scale-95"
>
<Paperclip className="w-4 h-4" />
</button>
<button
type="button"
onClick={handleInject}
title="记录干预指令并立即恢复会商执行"
className="bg-primary hover:bg-[var(--brand-900)] text-white text-xs font-bold px-4 py-2.5 rounded-xl transition shrink-0 cursor-pointer active:scale-95 shadow-md shadow-primary/10"
>
干预并发送
</button>
<button
type="button"
onClick={onProceedToStep3}
disabled={!hasConsensus}
aria-disabled={!hasConsensus}
title={hasConsensus ? undefined : "圆桌尚未达成共识,请等待总控自然收敛"}
className={`text-xs font-black px-4 py-2.5 rounded-xl transition shrink-0 flex items-center gap-1 active:scale-95 shadow-md shadow-emerald-500/10 ${
hasConsensus
? "bg-emerald-600 hover:bg-emerald-700 text-white cursor-pointer"
: "bg-emerald-600/40 text-white cursor-not-allowed"
}`}
>
<Sparkles className="w-3.5 h-3.5 text-emerald-100" />
达成共识生成全案成果 &gt;
</button>
<Sparkles className="size-3.5 text-emerald-100" />
达成共识生成全案成果 &gt;
</button>
)}
/>
</div>
</div>
) : (
<Step2LegacyComposer
value={customAgentInput}
onValueChange={setCustomAgentInput}
files={injectFiles}
onAddFiles={addInjectFiles}
onRemoveFile={removeInjectFile}
onSend={handleInject}
hasConsensus={hasConsensus}
onProceed={onProceedToStep3}
/>
)}
</>
);
@@ -834,6 +814,113 @@ export function Step2Panel(props: Step2PanelProps) {
);
}
/** 原通用会商的输入区。8BF 使用第一步复用组件时,其他会商页面保持此既有结构。 */
function Step2LegacyComposer({
value,
onValueChange,
files,
onAddFiles,
onRemoveFile,
onSend,
hasConsensus,
onProceed,
}: {
value: string;
onValueChange: React.Dispatch<React.SetStateAction<string>>;
files: File[];
onAddFiles: (files: File[]) => void;
onRemoveFile: (index: number) => void;
onSend: () => void;
hasConsensus: boolean;
onProceed: () => void;
}) {
const fileInputRef = useRef<HTMLInputElement>(null);
return (
<div className="shrink-0 flex flex-col gap-2 border-t border-slate-100 bg-slate-50 p-4">
{files.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{files.map((file, index) => (
<span
key={`${file.name}-${file.size}-${index}`}
className="inline-flex max-w-[200px] items-center gap-1.5 rounded-lg border border-slate-200 bg-white py-1 pl-2 pr-1 text-[11px] text-slate-600"
>
<FileIcon className="size-3 shrink-0 text-primary" />
<span className="truncate font-medium" title={file.name}>{file.name}</span>
<span className="shrink-0 text-slate-400">{formatFileSize(file.size)}</span>
<button
type="button"
onClick={() => onRemoveFile(index)}
className="shrink-0 rounded p-0.5 text-slate-400 transition hover:bg-slate-200 hover:text-slate-600"
title="移除"
>
<X className="size-3" />
</button>
</span>
))}
</div>
)}
<div className="flex items-end gap-3">
<AutoGrowTextarea
value={value}
onChange={(event) => onValueChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
onSend();
}
}}
placeholder="作为指挥官,您可以随时插话输入指令,对智能体博弈朝向实施人工干预...(Enter 发送 / Shift+Enter 换行)"
className="min-h-[48px] flex-1 rounded-xl border border-slate-200 bg-white px-3.5 py-2.5 text-xs font-medium leading-relaxed focus:border-primary focus:outline-none focus:ring-1 focus:ring-[var(--brand-200)]/50"
maxHeight={280}
/>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={(event) => {
onAddFiles(Array.from(event.target.files ?? []));
event.target.value = "";
}}
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
title="上传参考文件(席位/总控将读取文件内容辅助研讨)"
className="shrink-0 cursor-pointer rounded-xl border border-slate-200 bg-white p-2.5 text-slate-500 transition hover:bg-slate-50 hover:text-primary active:scale-95"
>
<Paperclip className="size-4" />
</button>
<button
type="button"
onClick={onSend}
title="记录干预指令并立即恢复会商执行"
className="shrink-0 cursor-pointer rounded-xl bg-primary px-4 py-2.5 text-xs font-bold text-white shadow-md shadow-primary/10 transition hover:bg-[var(--brand-900)] active:scale-95"
>
干预并发送
</button>
<button
type="button"
onClick={onProceed}
disabled={!hasConsensus}
aria-disabled={!hasConsensus}
title={hasConsensus ? undefined : "圆桌尚未达成共识,请等待总控自然收敛"}
className={cn(
"flex shrink-0 items-center gap-1 rounded-xl px-4 py-2.5 text-xs font-black shadow-md shadow-emerald-500/10 transition active:scale-95",
hasConsensus
? "cursor-pointer bg-emerald-600 text-white hover:bg-emerald-700"
: "cursor-not-allowed bg-emerald-600/40 text-white",
)}
>
<Sparkles className="size-3.5 text-emerald-100" />
达成共识生成全案成果 &gt;
</button>
</div>
</div>
);
}
/**
* 单条 Step 2 对话气泡。包含:
* - ChainOfThought 折叠卡(若 msg.steps 非空)
@@ -24,6 +24,7 @@ import {
type IntentSummary,
} from "@/roundtable-planning/api/intent";
import { uploadFiles } from "@/core/uploads/api";
import { createClientId } from "@/lib/client-id";
import {
clarificationKey,
formatIntentClarificationAnswer,
@@ -449,9 +450,7 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
// Phase 4 单飞幂等键:每个回合一个稳定 id。后端据此对 (threadId, clientTurnId)
// 跨 worker 单飞——重复 / 双击命中既有回合即幂等返回,杜绝并发下双发意图。
const clientTurnId =
(typeof crypto !== "undefined" && crypto.randomUUID && crypto.randomUUID()) ||
`${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
const clientTurnId = createClientId();
const res = await streamIntent({
threadId: intentThreadId,
@@ -94,7 +94,7 @@ import { flowNodeMeta, type FlowNodeType } from "@/roundtable-planning/lib/flow-
import { RecommendAgentsDialog } from "@/roundtable-planning/components/RecommendAgentsDialog";
import { BusinessChainListDialog } from "@/roundtable-planning/components/BusinessChainListDialog";
import { DispatchChainJobModal } from "@/roundtable-planning/components/DispatchChainJobModal";
import { Step1Panel } from "@/roundtable-planning/components/Step1Panel";
import { Step1Panel, type Step1PanelProps } from "@/roundtable-planning/components/Step1Panel";
import { readActionDetail, readCopTaskDetail } from "@/core/auth/task-deeplink";
import { Step2Panel } from "@/roundtable-planning/components/Step2Panel";
import { Step3Panel } from "@/roundtable-planning/components/Step3Panel";
@@ -298,8 +298,32 @@ export default function RoundtablePlanningPage({
toolbarSlot,
/** 独立业务页在第一步左上角替换默认业务链选择区的内容。 */
entryRailBusinessChainSlot,
/** 独立业务页在第一步左上角提供自己的任务卡片;不触发原深链任务接口。 */
entryRailTaskCardSlot,
/** 独立业务页传给意图智能体的隐藏任务上下文。 */
taskContext,
/** 独立业务页的任务作用域,仅用于左栏记录筛选,不改变原深链逻辑。 */
entryRailTaskId,
/** 独立业务页的根节点样式类,避免修改原会商样式。 */
rootClassName,
/** 独立业务页替换第一步消息工作区的纯展示组件;状态和行为仍由本页统一拥有。 */
renderStep1,
/** 独立业务页替换第一步左侧入口栏。 */
renderStep1LeftSidebar,
/** 独立业务页在第二步左栏顶部复用自己的任务/意图展示。 */
renderStep2LeftSidebarHeader,
/** 第一阶段使用内嵌右栏时,隐藏通用的模型/摘要/澄清记录栏。 */
hideDefaultStep1RightSidebar = false,
/** 独立业务页的工作区品牌区,取代通用步骤导航。 */
headerLeftSlot,
/** 放在历史记录后面的独立业务操作或状态。 */
headerActionAfterHistory,
/** 独立业务页可隐藏通用的“保存草稿”按钮。 */
hideHeaderSaveDraft = false,
/** 独立业务页可调整新建动作的展示文案,行为不变。 */
newTaskLabel,
/** 独立业务页可隐藏通用的设置入口。 */
hideHeaderSettings = false,
}: {
compactChrome?: boolean;
lockedChainId?: string | null;
@@ -307,7 +331,26 @@ export default function RoundtablePlanningPage({
storageTaskId?: string | null;
toolbarSlot?: ReactNode;
entryRailBusinessChainSlot?: ReactNode;
entryRailTaskCardSlot?: ReactNode;
taskContext?: Record<string, unknown> | null;
entryRailTaskId?: string | null;
rootClassName?: string;
renderStep1?: (props: Step1PanelProps & {
selectedModel: string;
modelOptions: Array<{ label: string; value: string }>;
onSelectedModelChange: (model: string) => void;
}) => ReactNode;
renderStep1LeftSidebar?: (context: { intentReady: object | null }) => ReactNode;
renderStep2LeftSidebarHeader?: (context: {
intentReady: object | null;
onCollapse: () => void;
}) => ReactNode;
hideDefaultStep1RightSidebar?: boolean;
headerLeftSlot?: ReactNode;
headerActionAfterHistory?: ReactNode;
hideHeaderSaveDraft?: boolean;
newTaskLabel?: string;
hideHeaderSettings?: boolean;
}) {
const { theme } = useTheme();
// 全工作区跨页的后台作业完成通知器(Provider 挂在 PageLayout 常驻层)。
@@ -315,6 +358,10 @@ export default function RoundtablePlanningPage({
const isDark = theme === "dark";
const navigate = useNavigate();
const location = useLocation();
const taskContextRef = useRef<Record<string, unknown> | null>(taskContext ?? null);
useEffect(() => {
taskContextRef.current = taskContext ?? null;
}, [taskContext]);
/** 无界(wujie)嵌入抽屉:隐藏业务链条入口,设置按钮改作手动回传交付。 */
const inWujieEmbed = isWujieEnv();
@@ -331,6 +378,13 @@ export default function RoundtablePlanningPage({
const draftStorageTaskId = storageTaskId ?? recordTaskIdOf(prefillRef.current);
// 由 prefill.businessCode 解析出的「自动选用业务链」结果;有值时「进入研讨」跳过选链弹窗直接进 Step 2。
const [autoChainResult, setAutoChainResult] = useState<SeatSelectionResult | null>(null);
// 固定业务链(8BF 等独立页面)的解析状态。不能只以 autoChainResult 是否为空判断:
// 它为空既可能是「还在拉取」,也可能是「业务链已配置但可用席位不足」。
// 若混为一谈,用户刚保存配置后立刻点击进入研讨时会被错误提示为「未配置业务链」。
const [lockedChainResolution, setLockedChainResolution] = useState<
"idle" | "loading" | "ready" | "unavailable"
>("idle");
const [lockedChainUnavailableReason, setLockedChainUnavailableReason] = useState<string | null>(null);
/** 无界直传 chainId 时,链条解析完成前禁止弹选链窗(避免 modal 布局抖动)。 */
const [autoChainLoading, setAutoChainLoading] = useState(
() =>
@@ -342,21 +396,57 @@ export default function RoundtablePlanningPage({
useEffect(() => {
if (!lockedChainId) {
setAutoChainResult(null);
setLockedChainResolution("idle");
setLockedChainUnavailableReason(null);
return;
}
let cancelled = false;
// 映射切换时先清掉旧链结果,避免异步请求尚未返回时误用上一条业务链进入研讨。
setAutoChainResult(null);
setLockedChainResolution("loading");
setLockedChainUnavailableReason(null);
setAutoChainLoading(true);
void Promise.all([getChain(lockedChainId), listAgents().catch(() => [])])
void Promise.all([getChain(lockedChainId), listAgents()])
.then(([chain, agents]) => {
if (cancelled || !chain) return;
const result = buildChainSeatSelection(chain, filterRecommendCandidates(agents));
if (!cancelled) setAutoChainResult(result);
if (cancelled) return;
if (!chain) {
setAutoChainResult(null);
setLockedChainResolution("unavailable");
setLockedChainUnavailableReason("已配置的业务链不存在或未发布,请重新选择一条已发布的业务链");
return;
}
const candidates = filterRecommendCandidates(agents);
const result = buildChainSeatSelection(chain, candidates);
if (!cancelled) {
setAutoChainResult(result);
setLockedChainResolution(result ? "ready" : "unavailable");
if (!result) {
const availableAgentIds = new Set(candidates.map((agent) => agent.agent_id));
const unavailableSeats = chain.seats
.filter((seat) => !availableAgentIds.has(seat.agent_id))
.map((seat) => seat.name || seat.agent_id);
setLockedChainUnavailableReason(
unavailableSeats.length > 0
? `业务链中的智能体「${unavailableSeats.join("、")}」尚未发布、已删除或当前账号无访问权限;请先将它们发布,并保证至少有 2 个可用智能体`
: "业务链至少需要 2 个可用智能体,请检查业务链席位配置",
);
}
}
})
.catch(() => {
if (!cancelled) setAutoChainResult(null);
if (!cancelled) {
setAutoChainResult(null);
setLockedChainResolution("unavailable");
setLockedChainUnavailableReason("读取已配置业务链或智能体失败,请刷新后重试");
}
})
.finally(() => {
if (!cancelled) setAutoChainLoading(false);
if (!cancelled) {
setAutoChainLoading(false);
// getChain 返回 null 时不会进入 then 内的 result 分支,同样标记为不可用。
setLockedChainResolution((current) => current === "loading" ? "unavailable" : current);
setLockedChainUnavailableReason((current) => current ?? "已配置的业务链暂不可用,请重新选择后重试");
}
});
return () => {
cancelled = true;
@@ -542,6 +632,38 @@ export default function RoundtablePlanningPage({
}>({ intentReady: null, interactiveMessages: [] });
const buildInitialIntent = useCallback((): string => {
const task = taskContextRef.current?.task as Record<string, unknown> | null | undefined;
const taskTags = taskContextRef.current?.task_tags as
| Record<string, unknown>
| undefined;
const taskValue = (value: unknown) =>
value === undefined || value === null ? "" : String(value).trim();
const taskLines = task
? [
["任务 ID", taskValue(task.id ?? taskContextRef.current?.task_id)],
["任务名称", taskValue(task.title)],
["任务描述", taskValue(task.description)],
["任务概览", taskValue(task.overview)],
["任务方向", taskValue(task.direction ?? task.target_direction)],
["优先级", taskValue(task.task_priority ?? task.priority)],
["任务状态", taskValue(task.task_status)],
["优先级标签", taskValue(taskTags?.priority)],
["方向标签", taskValue(taskTags?.direction)],
["状态标签", taskValue(taskTags?.status)],
]
.filter(([, value]) => value)
.map(([label, value]) => `${label}: ${value}`)
: [];
const taskCardContext = taskLines.length
? [
"【8BF 任务卡片上下文】",
...taskLines,
"请把以上任务卡片信息作为本轮研讨的既有背景,不要让用户重复提供。",
"",
].join("\n")
: "";
const withTaskCardContext = (value: string) =>
taskCardContext ? `${taskCardContext}${value}` : value;
const { intentReady, interactiveMessages } = step1StateRef.current;
if (intentReady) {
const parts: string[] = [intentReady.objective];
@@ -559,17 +681,17 @@ export default function RoundtablePlanningPage({
parts.push(
"用户已经过任务理解智能体澄清,以上信息可以直接作为研讨依据。请基于上述目标与约束,在圆桌内调度合适的子智能体完成研讨,不要再反问用户。",
);
return parts.join("\n");
return withTaskCardContext(parts.join("\n"));
}
const userTurns = interactiveMessages
.filter((m) => m.sender === "user")
.map((m) => m.content?.trim())
.filter((s): s is string => !!s);
if (userTurns.length === 0) {
return "用户尚未在第一步明确任务意图。请先调用 ask_clarification,简要询问目标、范围与硬性约束,再进入研讨。";
return withTaskCardContext("用户尚未在第一步明确任务意图。请先调用 ask_clarification,简要询问目标、范围与硬性约束,再进入研讨。");
}
const conversation = userTurns.map((t, i) => `用户#${i + 1}: ${t}`).join("\n");
return `${conversation}\n\n注意:用户尚未完成结构化的任务理解流程,以上是原始输入。若信息不足以开展研讨,请先调用 ask_clarification 询问关键约束,再开始派活。`;
return withTaskCardContext(`${conversation}\n\n注意:用户尚未完成结构化的任务理解流程,以上是原始输入。若信息不足以开展研讨,请先调用 ask_clarification 询问关键约束,再开始派活。`);
}, []);
const step2 = useStep2Orchestration({
@@ -593,6 +715,7 @@ export default function RoundtablePlanningPage({
currentStep,
selectedModel,
triggerToast,
taskContext,
});
// 同步 step1StateRef,让 buildInitialIntent / Persona / Step 3 都能读到最新值。
@@ -1917,9 +2040,66 @@ export default function RoundtablePlanningPage({
triggerToast("追问生成就绪!");
}, [step1, triggerToast]);
// 独立业务页可只替换 Step 1 的外观;这里将通用状态/动作收敛为一份受控 props,
// 避免它们复制或分叉意图识别、附件上传、澄清和进入研讨的业务逻辑。
const step1PanelProps: Step1PanelProps = {
interactiveMessages: step1.interactiveMessages,
intentThreadId: step1.intentThreadId,
intentReady: step1.intentReady,
isIntentSending: step1.isIntentSending,
pendingIntentClarificationMsgId: step1.pendingIntentClarificationMsgId,
intentClarificationSelections: step1.intentClarificationSelections,
setIntentClarificationSelections: step1.setIntentClarificationSelections,
onSendMessage: (text, files) => void step1.sendIntentMessage(text, files),
onStopIntent: step1.stopIntentStream,
onSkipQuestion: handleSkipQuestion,
onFollowUp: handleAgentFollowUp,
onEnterRoundtable: () => {
if (!step1.intentReady) {
triggerToast("请先与任务理解智能体完成澄清,生成任务摘要后再进入");
return;
}
// 会商自动化串联(模块 C):若任务页带来的业务链已解析成功,跳过选链弹窗,
// 直接据该业务链进入 Step 2 研讨(用户无需再手选)。
if (autoChainResult) {
const result = autoChainResult;
void drafts.ensureDraft(1).finally(() => handleConfirmAgents(result));
return;
}
if (lockBusinessChain) {
triggerToast(
autoChainLoading || lockedChainResolution === "loading"
? "正在加载管理员配置的业务链,请稍候…"
: lockedChainResolution === "unavailable"
? lockedChainUnavailableReason ?? "已配置的业务链暂不可用,请确认至少保留 2 个可用智能体后重试"
: "管理员尚未配置可用的业务链,请联系管理员处理",
);
return;
}
if (
inWujieEmbed &&
autoChainLoading &&
(prefillRef.current?.chainId || prefillRef.current?.businessCode)
) {
triggerToast("正在加载业务链条,请稍候…");
return;
}
// 先把草稿落地拿到 draft_id,推荐历史才有归属;失败也照常打开弹窗
// (弹窗在无 draftId 时退化为「直接推荐、不存历史」)。
void drafts.ensureDraft(1).finally(() => setIsRecommendDialogOpen(true));
},
onManualSave: drafts.handleManualSave,
onTriggerToast: triggerToast,
submitIntentClarifications: step1.submitIntentClarifications,
};
// 定制 chrome 仅服务第一阶段;第二、三阶段继续保留原有步骤导航与操作,
// 避免独立业务页为了复刻第一步视觉而收窄后续流程的可达性。
const useCustomStep1Chrome = currentStep === 1 && Boolean(headerLeftSlot);
// ─── Render ──────────────────────────────────────────────
return (
<div
data-roundtable-step={currentStep}
className={cn(
"roundtable-planning theme-transition relative flex h-full min-h-0 flex-col bg-[#fef6f6] font-sans text-slate-800 selection:bg-red-200",
isDark && "roundtable-planning--dark",
@@ -2022,13 +2202,15 @@ export default function RoundtablePlanningPage({
{/* HEADER */}
<header ref={headerDzRef} className="relative z-[40] flex shrink-0 items-center justify-between gap-4 border-b border-slate-100 bg-white px-6 py-3 shadow-[0_1px_3px_rgba(0,0,0,0.02)] dark:border-border dark:bg-card dark:shadow-none">
<StepNavigation
currentStep={currentStep}
intentReady={step1.intentReady}
hasConsensus={step2.hasConsensus}
onStepClick={(s) => setCurrentStep(s)}
triggerToast={triggerToast}
/>
{useCustomStep1Chrome ? headerLeftSlot : (
<StepNavigation
currentStep={currentStep}
intentReady={step1.intentReady}
hasConsensus={step2.hasConsensus}
onStepClick={(s) => setCurrentStep(s)}
triggerToast={triggerToast}
/>
)}
<div className="flex items-center gap-2">
{toolbarSlot}
@@ -2077,18 +2259,20 @@ export default function RoundtablePlanningPage({
className="bg-emerald-600 text-white hover:bg-emerald-700 text-xs font-bold px-3 py-2 rounded-xl flex items-center gap-1.5 transition-all shadow-sm shadow-emerald-500/10 active:scale-95"
>
<Sparkles className="w-3.5 h-3.5" />
新建任务
{useCustomStep1Chrome ? (newTaskLabel ?? "新建任务") : "新建任务"}
</button>
)}
<button
id="save-draft-header-btn"
onClick={drafts.handleManualSave}
className="bg-white hover:bg-slate-50 border border-[#e2e8f0] text-[#334155] text-xs font-bold px-3 py-2 rounded-xl flex items-center gap-1.5 transition dark:border-border dark:bg-secondary dark:text-foreground dark:hover:bg-accent"
>
<Save className="w-3.5 h-3.5 text-slate-500" />
保存草稿
</button>
{(!hideHeaderSaveDraft || !useCustomStep1Chrome) && (
<button
id="save-draft-header-btn"
onClick={drafts.handleManualSave}
className="bg-white hover:bg-slate-50 border border-[#e2e8f0] text-[#334155] text-xs font-bold px-3 py-2 rounded-xl flex items-center gap-1.5 transition dark:border-border dark:bg-secondary dark:text-foreground dark:hover:bg-accent"
>
<Save className="w-3.5 h-3.5 text-slate-500" />
保存草稿
</button>
)}
{/* 「导入会商」按钮先隐藏(保留代码 + 弹窗逻辑,便于以后恢复)。 */}
{false && (
@@ -2126,22 +2310,26 @@ export default function RoundtablePlanningPage({
onCancelRename={drafts.handleCancelRename}
/>
<div className="h-6 w-[1px] bg-slate-200 mx-1" />
{useCustomStep1Chrome && headerActionAfterHistory}
<button
id="settings-btn"
onClick={handleSettingsClick}
title={
inWujieEmbed
? "回传各智能体交付结果给宿主"
: getStoredAuth()?.system_role === "admin"
? "会商诊断日志"
: "系统设置"
}
className="p-1.5 text-slate-400 hover:text-slate-600 rounded-lg hover:bg-slate-100 transition-colors dark:text-muted-foreground dark:hover:bg-accent dark:hover:text-foreground"
>
<Settings className="w-4 h-4" />
</button>
{(!hideHeaderSettings || !useCustomStep1Chrome) && <div className="h-6 w-[1px] bg-slate-200 mx-1" />}
{(!hideHeaderSettings || !useCustomStep1Chrome) && (
<button
id="settings-btn"
onClick={handleSettingsClick}
title={
inWujieEmbed
? "回传各智能体交付结果给宿主"
: getStoredAuth()?.system_role === "admin"
? "会商诊断日志"
: "系统设置"
}
className="p-1.5 text-slate-400 hover:text-slate-600 rounded-lg hover:bg-slate-100 transition-colors dark:text-muted-foreground dark:hover:bg-accent dark:hover:text-foreground"
>
<Settings className="w-4 h-4" />
</button>
)}
{compactChrome && (
<>
<ThemeSwitcher tone="content" />
@@ -2157,7 +2345,10 @@ export default function RoundtablePlanningPage({
sidebarExpanded={step2SidebarExpanded}
setSidebarExpanded={setStep2SidebarExpanded}
/>
<main className="relative z-0 flex-1 overflow-hidden p-4 flex gap-4 min-h-0">
<main
data-step2-sidebar-expanded={currentStep === 2 ? String(step2SidebarExpanded) : undefined}
className="relative z-0 flex-1 overflow-hidden p-4 flex gap-4 min-h-0"
>
{/* LEFT SIDEBAR */}
<LeftSidebar
currentStep={currentStep}
@@ -2176,12 +2367,15 @@ export default function RoundtablePlanningPage({
onSelectChain={handleSelectChainFromSidebar}
onOpenDraft={drafts.handleLoadDraft}
activeDraftId={drafts.currentDraftId}
entryRailTaskId={recordTaskIdOf(prefillRef.current)}
entryRailTaskId={entryRailTaskId ?? recordTaskIdOf(prefillRef.current)}
historyTaskId={draftStorageTaskId}
entryRailBusinessCode={prefillRef.current?.businessCode ?? null}
lockedChainId={lockedChainId}
lockBusinessChain={lockBusinessChain}
entryRailBusinessChainSlot={entryRailBusinessChainSlot}
entryRailTaskCardSlot={entryRailTaskCardSlot}
renderStep1LeftSidebar={renderStep1LeftSidebar}
renderStep2LeftSidebarHeader={renderStep2LeftSidebarHeader}
/>
{/* MIDDLE VIEWPORT */}
@@ -2190,7 +2384,13 @@ export default function RoundtablePlanningPage({
className="flex-1 flex flex-col min-w-0 bg-white border border-slate-200/50 rounded-2xl shadow-[0_4px_16px_rgba(0,0,0,0.015)] overflow-hidden"
>
{currentStep === 1 && (
<Step1Panel
renderStep1 ? renderStep1({
...step1PanelProps,
selectedModel,
modelOptions: availableModels.map((model) => ({ label: model.name, value: model.name })),
onSelectedModelChange: setSelectedModel,
}) : <Step1Panel
{...step1PanelProps}
headerLeftSlot={
// 「原任务已有结果」(目的树预览)只在 rwfx-A(6BF)模式显示;xdfx/3qfx 不显示。
prefillRef.current?.taskId &&
@@ -2203,57 +2403,12 @@ export default function RoundtablePlanningPage({
/>
) : undefined
}
interactiveMessages={step1.interactiveMessages}
intentThreadId={step1.intentThreadId}
intentReady={step1.intentReady}
isIntentSending={step1.isIntentSending}
pendingIntentClarificationMsgId={step1.pendingIntentClarificationMsgId}
intentClarificationSelections={step1.intentClarificationSelections}
setIntentClarificationSelections={step1.setIntentClarificationSelections}
onSendMessage={(text, files) => void step1.sendIntentMessage(text, files)}
onStopIntent={step1.stopIntentStream}
onSkipQuestion={handleSkipQuestion}
onFollowUp={handleAgentFollowUp}
onEnterRoundtable={() => {
if (!step1.intentReady) {
triggerToast("请先与任务理解智能体完成澄清,生成任务摘要后再进入");
return;
}
// 会商自动化串联(模块 C):若任务页带来的业务链已解析成功,跳过选链弹窗,
// 直接据该业务链进入 Step 2 研讨(用户无需再手选)。
if (autoChainResult) {
const result = autoChainResult;
void drafts.ensureDraft(1).finally(() => handleConfirmAgents(result));
return;
}
if (lockBusinessChain) {
triggerToast(
autoChainLoading
? "正在加载管理员配置的业务链,请稍候…"
: "管理员尚未配置可用的业务链,请联系管理员处理",
);
return;
}
if (
inWujieEmbed &&
autoChainLoading &&
(prefillRef.current?.chainId || prefillRef.current?.businessCode)
) {
triggerToast("正在加载业务链条,请稍候…");
return;
}
// 先把草稿落地拿到 draft_id,推荐历史才有归属;失败也照常打开弹窗
// (弹窗在无 draftId 时退化为「直接推荐、不存历史」)。
void drafts.ensureDraft(1).finally(() => setIsRecommendDialogOpen(true));
}}
onManualSave={drafts.handleManualSave}
onTriggerToast={triggerToast}
submitIntentClarifications={step1.submitIntentClarifications}
/>
)}
{currentStep === 2 && (
<Step2Panel
useStep1InputAppearance={rootClassName?.includes("eightbf-roundtable") ?? false}
step2RoundtableDialogues={step2.step2RoundtableDialogues}
isInitializing={step2.isInitializing}
isRoundTableRunning={step2.isRoundTableRunning}
@@ -2339,7 +2494,7 @@ export default function RoundtablePlanningPage({
</section>
{/* RIGHT SIDEBAR */}
<RightSidebar
{!hideDefaultStep1RightSidebar && <RightSidebar
currentStep={currentStep}
selectedModel={selectedModel}
setSelectedModel={setSelectedModel}
@@ -2349,7 +2504,7 @@ export default function RoundtablePlanningPage({
timelineEntries={step1.timelineEntries}
triggerToast={triggerToast}
onSaveIntent={handleSaveIntent}
/>
/>}
</main>
{/* 深链 taskId 模式:底部「快捷跳转」栏(与单智能体问答共用,按业务取按钮管理配置)。 */}
@@ -2743,6 +2898,15 @@ interface LeftSidebarProps {
lockedChainId?: string | null;
/** 第一阶段替换默认业务链选择区的自定义内容。 */
entryRailBusinessChainSlot?: ReactNode;
/** 第一阶段由独立业务页提供的自定义任务卡片。 */
entryRailTaskCardSlot?: ReactNode;
/** 独立业务页替换第一步左侧工作区;仅提供已解析的意图状态。 */
renderStep1LeftSidebar?: (context: { intentReady: object | null }) => ReactNode;
/** 独立业务页在第二步左栏顶部复用任务与意图区。 */
renderStep2LeftSidebarHeader?: (context: {
intentReady: object | null;
onCollapse: () => void;
}) => ReactNode;
}
function Step2SandboxSidebarSync({
@@ -2794,6 +2958,9 @@ function LeftSidebar({
lockBusinessChain,
lockedChainId,
entryRailBusinessChainSlot,
entryRailTaskCardSlot,
renderStep1LeftSidebar,
renderStep2LeftSidebarHeader,
}: LeftSidebarProps) {
// 整片脱敏:左侧面板(任务概览/参与角色/人工参与等)的所有文本——标题、动态内容、
// 嵌套子组件文本一并脱敏。消息列表与沙箱是另外的容器,不挂此 Hook,保持原文。
@@ -2811,6 +2978,7 @@ function LeftSidebar({
<section
ref={dzRef}
id="left-sidebar"
data-sidebar-expanded={isStep2 ? String(sidebarExpanded) : undefined}
className={cn(
"min-h-0 shrink-0 flex flex-col gap-3 pr-1 transition-[width] duration-200 ease-out",
isStep2 ? "overflow-y-auto" : "overflow-hidden",
@@ -2832,15 +3000,20 @@ function LeftSidebar({
{currentStep === 2 ? (
sidebarExpanded ? (
<>
<IntentSummaryCard
intentReady={intentReady}
title="意图理解"
collapsible
sidebarToggle={{
expanded: sidebarExpanded,
onToggle: () => setSidebarExpanded((v) => !v),
}}
/>
{renderStep2LeftSidebarHeader?.({
intentReady,
onCollapse: () => setSidebarExpanded(false),
}) ?? (
<IntentSummaryCard
intentReady={intentReady}
title="意图理解"
collapsible
sidebarToggle={{
expanded: sidebarExpanded,
onToggle: () => setSidebarExpanded((v) => !v),
}}
/>
)}
<div className="bg-white border border-slate-200/50 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)] flex-1 flex flex-col min-h-[240px]">
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-3">
@@ -2926,18 +3099,21 @@ function LeftSidebar({
)
) : (
// Step 1(落地,未开始研讨):左栏改为业务链 + 分析记录入口(需求 0616 #2)。
<RoundtableEntryRail
selectedChainId={selectedChainId}
onSelectChain={onSelectChain}
onOpenDraft={onOpenDraft}
activeDraftId={activeDraftId}
taskId={entryRailTaskId ?? null}
historyTaskId={historyTaskId ?? null}
businessCode={entryRailBusinessCode ?? null}
lockBusinessChain={lockBusinessChain}
lockedChainId={lockedChainId ?? null}
businessChainSlot={entryRailBusinessChainSlot}
/>
renderStep1LeftSidebar?.({ intentReady }) ?? (
<RoundtableEntryRail
selectedChainId={selectedChainId}
onSelectChain={onSelectChain}
onOpenDraft={onOpenDraft}
activeDraftId={activeDraftId}
taskId={entryRailTaskId ?? null}
historyTaskId={historyTaskId ?? null}
businessCode={entryRailBusinessCode ?? null}
lockBusinessChain={lockBusinessChain}
lockedChainId={lockedChainId ?? null}
businessChainSlot={entryRailBusinessChainSlot}
taskCardSlot={entryRailTaskCardSlot}
/>
)
)}
</section>
);
@@ -2992,6 +3168,7 @@ function RoundtableEntryRail({
lockedChainId,
lockBusinessChain = false,
businessChainSlot,
taskCardSlot,
}: {
selectedChainId: string | null;
onSelectChain: (chain: BusinessChain) => void;
@@ -3007,6 +3184,7 @@ function RoundtableEntryRail({
lockedChainId?: string | null;
lockBusinessChain?: boolean;
businessChainSlot?: ReactNode;
taskCardSlot?: ReactNode;
}) {
const [chains, setChains] = useState<BusinessChain[]>([]);
const [chainSectionExpanded, setChainSectionExpanded] = useState(true);
@@ -3093,7 +3271,12 @@ function RoundtableEntryRail({
)}
>
{/* 深链接(taskId)模式:左上角展示「当前任务」(rwfx/3qfx)或「当前行动」(xdfx),替代业务链选择。 */}
{deeplink ? (
{taskCardSlot ? (
<>
{taskCardSlot}
{lockBusinessChain && businessChainSlot}
</>
) : deeplink ? (
isXdfx ? (
<div className="bg-white border border-[#ECE7E4] rounded-2xl p-4 shadow-[0_4px_20px_rgba(0,0,0,0.02)]">
<div className="flex items-center gap-2 mb-3">
@@ -17,6 +17,7 @@ import {
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import { createClientId } from "@/lib/client-id";
import type {
DocumentRewriteSummary,
DocumentRewriteStreamEvent,
@@ -355,7 +356,7 @@ export async function startJob(
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
request_id: opts.requestId ?? crypto.randomUUID(),
request_id: opts.requestId ?? createClientId(),
entry: opts.entry ?? "legacy",
config: opts.config ?? null,
}),