diff --git a/frontend-web/package.json b/frontend-web/package.json
index fb189ccb..28755b89 100644
--- a/frontend-web/package.json
+++ b/frontend-web/package.json
@@ -11,6 +11,7 @@
"build:network": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build --mode network",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
+ "test:report-collaboration": "npx esbuild src/report-collaboration/tests/event-reducer.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/event-reducer.test.mjs && npx esbuild src/report-collaboration/tests/message-adapter.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/message-adapter.test.mjs && node --test .runtime/unit-tests/event-reducer.test.mjs .runtime/unit-tests/message-adapter.test.mjs",
"check:chrome109-css": "node scripts/check-chrome109-css.mjs",
"check:legacy-chrome-css": "node scripts/check-chrome109-css.mjs"
},
diff --git a/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx b/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx
index 0b23eaf7..fd51b928 100644
--- a/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx
+++ b/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx
@@ -85,7 +85,7 @@ import {
import { isArtifactApiPath } from "@/core/artifacts/streaming-write-preview";
import { useI18n } from "@/core/i18n/hooks";
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
-import { streamdownPlugins } from "@/core/streamdown";
+import { streamdownPlugins, useStreamdownMermaidPngDownload } from "@/core/streamdown";
import {
checkCodeFile,
getFileName,
@@ -93,6 +93,7 @@ import {
} from "@/core/utils/files";
import {
downloadMermaidSvgFile,
+ downloadSvgElementAsPng,
isMermaidFile,
openMermaidSvgInNewTab,
renderMermaidToSvg,
@@ -428,15 +429,15 @@ export function ArtifactFileDetail({
if (streamPerfEnabled && isLiveStreaming) return raw;
return desensitize(raw);
}, [disableDesensitize, displayContent, isLiveStreaming, streamPerfEnabled]);
- const artifactContentPending =
- isCodeFile &&
- !useWriteFileStreaming &&
- content === undefined &&
- isArtifactContentLoading;
- // `write_file` 的 path 会先于正文抵达。它仍是正常的流式过程,不能显示成一块
- // 空白编辑器,更不能误导用户去点击重试真实文件;下一帧内容到达后会自然切换为代码视图。
- const streamingWriteContentPending =
- useWriteFileStreaming && writeFileInFlight && !content;
+ const artifactContentPending =
+ isCodeFile &&
+ !useWriteFileStreaming &&
+ content === undefined &&
+ isArtifactContentLoading;
+ // `write_file` 的 path 会先于正文抵达。它仍是正常的流式过程,不能显示成一块
+ // 空白编辑器,更不能误导用户去点击重试真实文件;下一帧内容到达后会自然切换为代码视图。
+ const streamingWriteContentPending =
+ useWriteFileStreaming && writeFileInFlight && !content;
const artifactContentUnavailable =
isCodeFile &&
!useWriteFileStreaming &&
@@ -1770,10 +1771,10 @@ export function ArtifactFileDetail({
activeCursor={isApplyingEdit}
/>
)}
- {(artifactContentPending || streamingWriteContentPending) && viewMode === "code" && (
-
-
- {streamingWriteContentPending ? "正在接收文件内容…" : "正在加载文档内容…"}
+ {(artifactContentPending || streamingWriteContentPending) && viewMode === "code" && (
+
+
+ {streamingWriteContentPending ? "正在接收文件内容…" : "正在加载文档内容…"}
)}
{artifactContentUnavailable && viewMode === "code" && (
@@ -1791,7 +1792,7 @@ export function ArtifactFileDetail({
)}
- {isCodeFile && viewMode === "code" && !artifactContentPending && !streamingWriteContentPending && !artifactContentUnavailable && (
+ {isCodeFile && viewMode === "code" && !artifactContentPending && !streamingWriteContentPending && !artifactContentUnavailable && (
();
const [mermaidSvg, setMermaidSvg] = useState(null);
const [mermaidError, setMermaidError] = useState(null);
+ const markdownPreviewRef = useRef(null);
+ const mermaidChartRef = useRef(null);
+ useStreamdownMermaidPngDownload(markdownPreviewRef);
const researchSourcesQuery = useResearchSources(
language === "markdown" ? (researchSessionId ?? null) : null,
);
@@ -2135,6 +2139,7 @@ export function ArtifactFilePreview({
if (language === "markdown") {
return (
{mermaidSvg ? (
-
+
+
+
+
+
+
+
) : mermaidError ? (
diff --git a/frontend-web/src/components/workspace/messages/markdown-content.tsx b/frontend-web/src/components/workspace/messages/markdown-content.tsx
index 9288b3a2..78e3999e 100644
--- a/frontend-web/src/components/workspace/messages/markdown-content.tsx
+++ b/frontend-web/src/components/workspace/messages/markdown-content.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useMemo } from "react";
+import { useMemo, useRef } from "react";
import type {
AnchorHTMLAttributes,
HTMLAttributes,
@@ -12,7 +12,7 @@ import {
MessageResponse,
type MessageResponseProps,
} from "@/components/ai-elements/message";
-import { streamdownPlugins } from "@/core/streamdown";
+import { streamdownPlugins, useStreamdownMermaidPngDownload } from "@/core/streamdown";
import {
createMarkdownAnchorResolver,
isInPageAnchor,
@@ -57,7 +57,9 @@ export function MarkdownContent({
linkClassName,
onLinkClick,
}: MarkdownContentProps) {
+ const rootRef = useRef
(null);
const anchorResolver = useMemo(() => createMarkdownAnchorResolver(content), [content]);
+ useStreamdownMermaidPngDownload(rootRef);
const components = useMemo(() => {
const heading = (level: 1 | 2 | 3 | 4 | 5 | 6) => {
const Tag = `h${level}` as const;
@@ -138,7 +140,7 @@ export function MarkdownContent({
if (!content) return null;
return (
-
+
React.ReactNode;
+ /**
+ * 通用业务卡插槽(报告协作工作台等):宿主向 displayThread 注入带
+ * `additional_kwargs.report_collaboration_card` 的虚拟 ai 消息(方案组 /
+ * 命令确认 / 报告入口),由本插槽渲染。不传该 slot 时(普通对话等)该
+ * 分组不渲染,零行为变化;卡 payload 结构由各页面在 message-adapter
+ * 层定义,MessageList 不感知具体业务字段。
+ */
+ businessCardSlot?: (message: Message) => React.ReactNode;
onOpenArtifact?: (filepath: string) => void;
/** Host override for downloading virtual/session-owned files in a present-files card. */
onDownloadArtifact?: (filepath: string) => void | Promise;
@@ -300,17 +309,17 @@ export function MessageList({
}
return map;
}, [messages]);
-
- const [settings] = useLocalSettings();
- const { data: citationDisplay } = useCitationDisplaySettings();
- const referenceModeEntryEnabled =
- citationDisplay?.reference_mode_enabled ?? false;
- const knowledgeReferencesEnabled =
- threadContext?.knowledgeReferencesEnabled === true;
- const ragModeEnabled =
- (referenceModeEntryEnabled && settings.display.ragModeEnabled) ||
- isThreadRagModeLocked(threadId) ||
- knowledgeReferencesEnabled;
+
+ const [settings] = useLocalSettings();
+ const { data: citationDisplay } = useCitationDisplaySettings();
+ const referenceModeEntryEnabled =
+ citationDisplay?.reference_mode_enabled ?? false;
+ const knowledgeReferencesEnabled =
+ threadContext?.knowledgeReferencesEnabled === true;
+ const ragModeEnabled =
+ (referenceModeEntryEnabled && settings.display.ragModeEnabled) ||
+ isThreadRagModeLocked(threadId) ||
+ knowledgeReferencesEnabled;
const hasSingleRoundRag = useMemo(
() =>
messages.some(
@@ -692,6 +701,14 @@ export function MessageList({
{taskReportImportSlot(message)}
) : null;
+ } else if (group.type === "assistant:business-card") {
+ // 通用业务卡(报告协作):宿主不传渲染函数时该分组不渲染。
+ const message = group.messages[0];
+ return businessCardSlot && message ? (
+
+ {businessCardSlot(message)}
+
+ ) : null;
} else if (group.type === "assistant:present-files") {
const files: string[] = [];
for (const message of group.messages) {
@@ -786,13 +803,13 @@ export function MessageList({
)) {
if (hasReasoning(message)) {
results.push(
-
,
+
,
);
}
results.push(
@@ -840,12 +857,12 @@ export function MessageList({
.find((message) => message.type === "ai");
return (
-
+
pathname.startsWith("/page/workspace/deep-research"),
},
+ {
+ id: "workspace-report-collaboration",
+ label: "报告协作",
+ icon: FileText,
+ path: "/page/workspace/report-collaboration",
+ match: (pathname) => pathname.startsWith("/page/workspace/report-collaboration"),
+ },
// {
// id: "workspace-enterprise-research",
// label: "企业深度研究",
diff --git a/frontend-web/src/core/messages/utils.ts b/frontend-web/src/core/messages/utils.ts
index 5e3369b8..211259e9 100644
--- a/frontend-web/src/core/messages/utils.ts
+++ b/frontend-web/src/core/messages/utils.ts
@@ -28,6 +28,8 @@ interface AssistantResearchApprovalGroup extends GenericMessageGroup<"assistant:
interface AssistantTaskReportImportGroup extends GenericMessageGroup<"assistant:task-report-import"> {}
+interface AssistantBusinessCardGroup extends GenericMessageGroup<"assistant:business-card"> {}
+
interface AssistantSubagentGroup extends GenericMessageGroup<"assistant:subagent"> {}
type MessageGroup =
@@ -41,6 +43,7 @@ type MessageGroup =
| AssistantSetupReportStructureApprovalGroup
| AssistantResearchApprovalGroup
| AssistantTaskReportImportGroup
+ | AssistantBusinessCardGroup
| AssistantSubagentGroup;
export function groupMessages(
@@ -65,7 +68,8 @@ export function groupMessages(
last.type !== "assistant:setup-agent-approval" &&
last.type !== "assistant:setup-writing-approval" &&
last.type !== "assistant:setup-report-structure-approval" &&
- last.type !== "assistant:research-approval"
+ last.type !== "assistant:research-approval" &&
+ last.type !== "assistant:business-card"
) {
return last;
}
@@ -159,6 +163,17 @@ export function groupMessages(
});
continue;
}
+ // 通用业务卡(报告协作方案组/命令确认/报告入口等):虚拟 ai 消息
+ // (additional_kwargs.report_collaboration_card),由宿主注入并经
+ // businessCardSlot 渲染;不传插槽的页面零渲染、零行为变化。
+ if (hasBusinessCard(message)) {
+ groups.push({
+ id: message.id,
+ type: "assistant:business-card",
+ messages: [message],
+ });
+ continue;
+ }
if (hasPresentFiles(message)) {
groups.push({
id: message.id,
@@ -487,6 +502,19 @@ export function hasTaskReportImportApproval(message: Message) {
);
}
+/**
+ * 通用业务卡(报告协作等页面):宿主注入的虚拟 ai 消息
+ * (additional_kwargs.report_collaboration_card),经 MessageList 的
+ * businessCardSlot 渲染。集中在此识别,业务卡 payload 结构由各页面自定义。
+ */
+export function hasBusinessCard(message: Message) {
+ return (
+ message.type === "ai" &&
+ typeof message.additional_kwargs?.report_collaboration_card === "object" &&
+ message.additional_kwargs.report_collaboration_card !== null
+ );
+}
+
export function extractPresentFilesFromMessage(message: Message) {
if (message.type !== "ai" || !hasPresentFiles(message)) {
return [];
diff --git a/frontend-web/src/core/scheduled-tasks/markdown-docx.ts b/frontend-web/src/core/scheduled-tasks/markdown-docx.ts
index 4116c66d..0ae977bd 100644
--- a/frontend-web/src/core/scheduled-tasks/markdown-docx.ts
+++ b/frontend-web/src/core/scheduled-tasks/markdown-docx.ts
@@ -10,6 +10,7 @@
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import { OptType, writeYLog } from "@/core/log/y-log";
+import { replaceMermaidFencesForWordExport } from "@/core/utils/mermaid";
const DOCX_MEDIA_TYPE =
"application/vnd.openxmlformats-officedocument.wordprocessingml.document";
@@ -38,13 +39,15 @@ export async function markdownToDocxBlob(
const source = markdown?.trim();
if (!source) throw new Error("文档内容为空");
+ const prepared = await replaceMermaidFencesForWordExport(source);
const endpoint = `${getBackendBaseURL()}/api/writing/export/docx`;
const response = await apiFetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: title?.trim() || undefined,
- markdown: source,
+ markdown: prepared.markdown,
+ embedded_images: prepared.embeddedImages,
}),
});
if (!response.ok) throw await responseError(response);
diff --git a/frontend-web/src/core/streamdown/index.ts b/frontend-web/src/core/streamdown/index.ts
index 2b034c65..f15b987d 100644
--- a/frontend-web/src/core/streamdown/index.ts
+++ b/frontend-web/src/core/streamdown/index.ts
@@ -1 +1,2 @@
export * from "./plugins";
+export * from "./mermaid-png-download";
diff --git a/frontend-web/src/core/streamdown/mermaid-png-download.ts b/frontend-web/src/core/streamdown/mermaid-png-download.ts
new file mode 100644
index 00000000..33eddc51
--- /dev/null
+++ b/frontend-web/src/core/streamdown/mermaid-png-download.ts
@@ -0,0 +1,56 @@
+import { useEffect, type RefObject } from "react";
+import { toast } from "sonner";
+
+import { downloadSvgElementAsPng } from "@/core/utils/mermaid";
+
+function findStreamdownMermaidDownloadBlock(target: EventTarget | null): HTMLElement | null {
+ if (!(target instanceof Element)) return null;
+ const button = target.closest("button");
+ if (!(button instanceof HTMLButtonElement)) return null;
+ if (button.getAttribute("title") !== "Download file") return null;
+ const block = button.closest('[data-streamdown="mermaid-block"]');
+ return block instanceof HTMLElement ? block : null;
+}
+
+function findRenderedMermaidChartSvg(block: HTMLElement): SVGSVGElement | null {
+ const fromChart = block.querySelector('[aria-label="Mermaid chart"] svg, [role="img"] svg');
+ if (fromChart instanceof SVGSVGElement && !fromChart.closest("button")) {
+ return fromChart;
+ }
+ for (const svg of block.querySelectorAll("svg")) {
+ if (svg instanceof SVGSVGElement && !svg.closest("button")) {
+ return svg;
+ }
+ }
+ return null;
+}
+
+/** Intercept Streamdown mermaid download buttons and save a PNG of the chart. */
+export function useStreamdownMermaidPngDownload(rootRef: RefObject) {
+ useEffect(() => {
+ const onClickCapture = (event: MouseEvent) => {
+ const root = rootRef.current;
+ if (!root || !root.contains(event.target as Node)) return;
+ const block = findStreamdownMermaidDownloadBlock(event.target);
+ if (!block) return;
+
+ event.preventDefault();
+ event.stopPropagation();
+ event.stopImmediatePropagation();
+
+ const svg = findRenderedMermaidChartSvg(block);
+ if (!svg) {
+ toast.error("流程图尚未渲染完成,请稍后再下载");
+ return;
+ }
+
+ void downloadSvgElementAsPng(svg, "flowchart.png").catch((error) => {
+ console.error("Failed to download mermaid image:", error);
+ toast.error(error instanceof Error ? error.message : "下载流程图失败");
+ });
+ };
+
+ document.addEventListener("click", onClickCapture, true);
+ return () => document.removeEventListener("click", onClickCapture, true);
+ }, [rootRef]);
+}
diff --git a/frontend-web/src/core/utils/mermaid-sanitize.test.ts b/frontend-web/src/core/utils/mermaid-sanitize.test.ts
index ea7099d2..e0be6809 100644
--- a/frontend-web/src/core/utils/mermaid-sanitize.test.ts
+++ b/frontend-web/src/core/utils/mermaid-sanitize.test.ts
@@ -1,7 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";
-import { sanitizeMermaidSource } from "./mermaid";
+import { findMermaidFenceMatches, getMermaidImageFileName, sanitizeMermaidSource } from "./mermaid";
test("inserts a missing arrow before a bare edge label", () => {
const input = "E --> G|价值输出 V_s| H[PPO 损失]";
@@ -69,3 +69,17 @@ test("repairs the DAC-PPO architecture diagram class block", () => {
assert.doesNotMatch(output, /class,/);
assert.doesNotMatch(output, /input,module/);
});
+
+test("turns mermaid source filenames into png downloads", () => {
+ assert.equal(getMermaidImageFileName("outputs/图2.mmd"), "图2.png");
+ assert.equal(getMermaidImageFileName("chart.mermaid", "png"), "chart.png");
+ assert.equal(getMermaidImageFileName("flowchart.png"), "flowchart.png");
+});
+
+test("finds mermaid fences for Word image replacement", () => {
+ const markdown = "前文\n```mermaid\nflowchart TB\nA-->B\n```\n后文\n```mmd\ngraph LR\nC-->D\n```";
+ const fences = findMermaidFenceMatches(markdown);
+ assert.equal(fences.length, 2);
+ assert.equal(fences[0].source, "flowchart TB\nA-->B");
+ assert.equal(fences[1].source, "graph LR\nC-->D");
+});
diff --git a/frontend-web/src/core/utils/mermaid.ts b/frontend-web/src/core/utils/mermaid.ts
index 9891b3ed..a7f059c8 100644
--- a/frontend-web/src/core/utils/mermaid.ts
+++ b/frontend-web/src/core/utils/mermaid.ts
@@ -96,12 +96,209 @@ function sanitizeMermaidClassStatements(source: string): string {
);
}
+const MERMAID_FILE_EXT = /\.(mmd|mermaid)$/i;
+
export function isMermaidFile(filepath: string): boolean {
- return /\.mmd$/i.test(filepath);
+ return MERMAID_FILE_EXT.test(filepath);
}
-export function getMermaidImageFileName(filepath: string, extension = "svg"): string {
- return getFileName(filepath).replace(/\.mmd$/i, `.${extension}`);
+export function getMermaidImageFileName(filepath: string, extension = "png"): string {
+ const name = getFileName(filepath) || "mermaid-diagram";
+ if (MERMAID_FILE_EXT.test(name)) {
+ return name.replace(MERMAID_FILE_EXT, `.${extension}`);
+ }
+ if (/\.(svg|png|jpe?g|webp)$/i.test(name)) {
+ return name.replace(/\.(svg|png|jpe?g|webp)$/i, `.${extension}`);
+ }
+ return `${name}.${extension}`;
+}
+
+function triggerBlobDownload(blob: Blob, filename: string): void {
+ const objectUrl = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = objectUrl;
+ a.download = filename;
+ a.rel = "noopener";
+ document.body.appendChild(a);
+ a.click();
+ a.remove();
+ window.setTimeout(() => URL.revokeObjectURL(objectUrl), 30_000);
+}
+
+function readPositiveNumber(value: string | null): number | null {
+ if (!value) return null;
+ const parsed = Number.parseFloat(value);
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
+}
+
+function getSvgExportSize(svg: SVGSVGElement): { width: number; height: number } {
+ const viewBox = svg.viewBox?.baseVal;
+ if (viewBox && viewBox.width > 0 && viewBox.height > 0) {
+ return { width: viewBox.width, height: viewBox.height };
+ }
+ const attrWidth = readPositiveNumber(svg.getAttribute("width"));
+ const attrHeight = readPositiveNumber(svg.getAttribute("height"));
+ if (attrWidth && attrHeight) {
+ return { width: attrWidth, height: attrHeight };
+ }
+ try {
+ const box = svg.getBBox();
+ if (box.width > 0 && box.height > 0) {
+ return { width: box.width, height: box.height };
+ }
+ } catch {
+ // Detached SVGs may throw on getBBox().
+ }
+ return {
+ width: Math.max(1, svg.clientWidth),
+ height: Math.max(1, svg.clientHeight),
+ };
+}
+
+function serializeSvg(svg: SVGSVGElement): string {
+ const clone = svg.cloneNode(true) as SVGSVGElement;
+ if (!clone.getAttribute("xmlns")) {
+ clone.setAttribute("xmlns", "http://www.w3.org/2000/svg");
+ }
+ if (!clone.getAttribute("xmlns:xlink")) {
+ clone.setAttribute("xmlns:xlink", "http://www.w3.org/1999/xlink");
+ }
+ return new XMLSerializer().serializeToString(clone);
+}
+
+export async function renderSvgElementToPngBlob(svg: SVGSVGElement): Promise {
+ const { width, height } = getSvgExportSize(svg);
+ const scale = 2;
+ const markup = serializeSvg(svg);
+ const blob = new Blob([markup], { type: "image/svg+xml;charset=utf-8" });
+ const objectUrl = URL.createObjectURL(blob);
+
+ try {
+ const image = new Image();
+ await new Promise((resolve, reject) => {
+ image.onload = () => resolve();
+ image.onerror = () => reject(new Error("流程图转图片失败"));
+ image.src = objectUrl;
+ });
+
+ const canvas = document.createElement("canvas");
+ canvas.width = Math.max(1, Math.ceil(width * scale));
+ canvas.height = Math.max(1, Math.ceil(height * scale));
+ const context = canvas.getContext("2d");
+ if (!context) {
+ throw new Error("无法创建画布");
+ }
+ context.fillStyle = "#ffffff";
+ context.fillRect(0, 0, canvas.width, canvas.height);
+ context.drawImage(image, 0, 0, canvas.width, canvas.height);
+
+ const png = await new Promise((resolve, reject) => {
+ canvas.toBlob(
+ (result) => (result ? resolve(result) : reject(new Error("生成 PNG 失败"))),
+ "image/png",
+ );
+ });
+ return png;
+ } finally {
+ URL.revokeObjectURL(objectUrl);
+ }
+}
+
+export async function downloadSvgElementAsPng(
+ svg: SVGSVGElement,
+ filename: string,
+): Promise {
+ const png = await renderSvgElementToPngBlob(svg);
+ triggerBlobDownload(png, getMermaidImageFileName(filename, "png"));
+}
+
+function blobToBase64(blob: Blob): Promise {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => {
+ const result = String(reader.result || "");
+ const comma = result.indexOf(",");
+ resolve(comma >= 0 ? result.slice(comma + 1) : result);
+ };
+ reader.onerror = () => reject(new Error("图片编码失败"));
+ reader.readAsDataURL(blob);
+ });
+}
+
+const MERMAID_FENCE_RE = /```(?:mermaid|mmd)[^\n]*\r?\n([\s\S]*?)```/gi;
+
+export function findMermaidFenceMatches(markdown: string): Array<{
+ index: number;
+ length: number;
+ source: string;
+}> {
+ const matches: Array<{ index: number; length: number; source: string }> = [];
+ const matcher = new RegExp(MERMAID_FENCE_RE.source, MERMAID_FENCE_RE.flags);
+ let match: RegExpExecArray | null;
+ while ((match = matcher.exec(markdown)) !== null) {
+ matches.push({
+ index: match.index,
+ length: match[0].length,
+ source: (match[1] ?? "").trim(),
+ });
+ }
+ return matches;
+}
+
+export async function renderMermaidToPngBlob(source: string): Promise {
+ const markup = await renderMermaidToSvg(source);
+ const host = document.createElement("div");
+ host.style.position = "fixed";
+ host.style.left = "-10000px";
+ host.style.top = "-10000px";
+ host.style.pointerEvents = "none";
+ host.style.opacity = "0";
+ host.innerHTML = markup;
+ document.body.appendChild(host);
+ try {
+ const svg = host.querySelector("svg");
+ if (!(svg instanceof SVGSVGElement)) {
+ throw new Error("无法生成流程图图片");
+ }
+ return await renderSvgElementToPngBlob(svg);
+ } finally {
+ host.remove();
+ }
+}
+
+export async function replaceMermaidFencesForWordExport(markdown: string): Promise<{
+ markdown: string;
+ embeddedImages: Record;
+}> {
+ const fences = findMermaidFenceMatches(markdown);
+ if (fences.length === 0) {
+ return { markdown, embeddedImages: {} };
+ }
+
+ const embeddedImages: Record = {};
+ const replacements: Array<{ index: number; length: number; text: string }> = [];
+
+ for (const [offset, fence] of fences.entries()) {
+ if (!fence.source) continue;
+ try {
+ const png = await renderMermaidToPngBlob(fence.source);
+ const id = `mermaid-${offset + 1}`;
+ embeddedImages[id] = await blobToBase64(png);
+ replacements.push({
+ index: fence.index,
+ length: fence.length,
+ text: `\n\n`,
+ });
+ } catch (error) {
+ console.error("Failed to render mermaid for Word export:", error);
+ }
+ }
+
+ let next = markdown;
+ for (const piece of replacements.sort((left, right) => right.index - left.index)) {
+ next = `${next.slice(0, piece.index)}${piece.text}${next.slice(piece.index + piece.length)}`;
+ }
+ return { markdown: next, embeddedImages };
}
async function getMermaidModule() {
@@ -152,15 +349,6 @@ export async function openMermaidSvgInNewTab(source: string): Promise {
}
export async function downloadMermaidSvgFile(source: string, filepath: string): Promise {
- const svg = await renderMermaidToSvg(source);
- const blob = new Blob([svg], { type: "image/svg+xml;charset=utf-8" });
- const objectUrl = URL.createObjectURL(blob);
- const a = document.createElement("a");
- a.href = objectUrl;
- a.download = getMermaidImageFileName(filepath);
- a.rel = "noopener";
- document.body.appendChild(a);
- a.click();
- a.remove();
- window.setTimeout(() => URL.revokeObjectURL(objectUrl), 30_000);
+ const png = await renderMermaidToPngBlob(source);
+ triggerBlobDownload(png, getMermaidImageFileName(filepath, "png"));
}
diff --git a/frontend-web/src/pages/WorkspaceRoutes.tsx b/frontend-web/src/pages/WorkspaceRoutes.tsx
index 8c573aef..ae35ab98 100644
--- a/frontend-web/src/pages/WorkspaceRoutes.tsx
+++ b/frontend-web/src/pages/WorkspaceRoutes.tsx
@@ -77,6 +77,10 @@ import BusinessChainEditorPage from '@/roundtable-planning/pages/BusinessChainEd
import BusinessChainListPage from '@/roundtable-planning/pages/BusinessChainListPage';
import DispatchChainPreviewPage from '@/roundtable-planning/pages/DispatchChainPreviewPage';
import '@/roundtable-planning/styles/roundtable-planning.css';
+import ReportCollaborationPage, {
+ ReportCollaborationSessionPage,
+} from '@/report-collaboration/pages/ReportCollaborationPage';
+import '@/report-collaboration/styles/report-collaboration.css';
import { cn } from '@/lib/utils';
function NotebookDetailPageWithKey() {
@@ -90,6 +94,29 @@ function NewAgentPageWithKey() {
return ;
}
+// 报告协作会话页:按 sessionId 重挂(旧 SSE/状态不串台)。
+function ReportCollaborationSessionRoute({
+ layoutProps,
+}: {
+ layoutProps: {
+ pageSidebarCollapsed: boolean;
+ pageSidebarHidden: boolean;
+ workspaceSidebarHidden: boolean;
+ headerHidden: boolean;
+ compactActionsHidden: boolean;
+ compactWorkspaceSidebar: boolean;
+ pageSidebarDark: boolean;
+ forceWorkspaceSidebarCollapsed: boolean;
+ };
+}) {
+ const { sessionId } = useParams<{ sessionId: string }>();
+ return (
+
+
+
+ );
+}
+
function shouldShowCompactTopActions(pathname: string): boolean {
return (
pathname === '/page/workspace/agents' ||
@@ -728,6 +755,20 @@ export default function WorkspaceRoutes({
}
/>
+ {/* AgentScope 报告协作工作台:左对话(复用 MessageList)/右画布的独立新页面,
+ 不与旧多智能体会商共享状态。key 绑定 sessionId——切换会话重挂干净的页面。 */}
+
+
+
+ }
+ />
+ }
+ />
{/* Phase 7 临时预览入口:派活链路流程图弹窗(假数据自测,接通后台后可删) */}
|]/g, "-")
@@ -416,6 +422,71 @@ function countCollectorMaterials(messages: readonly Message[] | undefined): numb
return unique.size;
}
+/** Freeze a compact evidence pool for a worker-independent report job. */
+function snapshotCollectorMaterials(messages: readonly Message[] | undefined): Record[] {
+ if (!messages?.length) return [];
+ const frozen: Record[] = [];
+ const seen = new Set();
+ const text = (value: unknown) => typeof value === "string" ? value.trim() : "";
+ const addCandidate = (candidate: unknown, toolName: string, index: number) => {
+ if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) return;
+ const item = candidate as Record;
+ const url = text(item.url) || text(item.source_url) || text(item.link);
+ const id = text(item.id) || text(item.recUuid) || text(item.rec_uuid) || text(item.uuid);
+ const title = text(item.title) || text(item.name) || `检索结果 ${index + 1}`;
+ const content = text(item.content) || text(item.raw_content) || text(item.snippet)
+ || text(item.content_preview) || text(item.text);
+ if (!url && !id && !content) return;
+ const key = id || url || `${title}\n${content.slice(0, 240)}`;
+ if (seen.has(key) || frozen.length >= 80) return;
+ seen.add(key);
+ frozen.push({
+ id: id || undefined,
+ title: title.slice(0, 500),
+ url: url.slice(0, 2_000) || undefined,
+ raw_content: content.slice(0, 6_000),
+ snippet: content.slice(0, 500) || undefined,
+ source: text(item.source) || toolName || "collector",
+ source_type: toolName === "web_search" ? "web" : "other",
+ published_at: text(item.publish_time) || text(item.published_at) || text(item.time) || undefined,
+ rec_uuid: text(item.recUuid) || text(item.rec_uuid) || undefined,
+ });
+ };
+ const visitPayload = (payload: unknown, toolName: string) => {
+ if (typeof payload === "string") {
+ try {
+ visitPayload(JSON.parse(payload), toolName);
+ } catch {
+ // A plain-text tool result is represented by the collector summary.
+ }
+ return;
+ }
+ if (Array.isArray(payload)) {
+ payload.forEach((item, index) => addCandidate(item, toolName, index));
+ return;
+ }
+ if (!payload || typeof payload !== "object") return;
+ const record = payload as Record;
+ for (const key of ["results", "items", "records", "documents", "hits", "data", "sources"]) {
+ if (Array.isArray(record[key])) {
+ record[key].forEach((item, index) => addCandidate(item, toolName, index));
+ return;
+ }
+ }
+ addCandidate(record, toolName, 0);
+ };
+ for (const message of messages) {
+ if (message.type === "tool") {
+ visitPayload(extractToolResultPayload(message), message.name || "collector_tool");
+ }
+ }
+ // Do not disguise the collector's narrative conclusion as a source. A
+ // summary-only snapshot used to look like one successful material, which
+ // prevented the backend from trying the checkpoint/durable fallback that
+ // actually contains the source rows.
+ return frozen;
+}
+
function countEventMaterials(events: ProgressEvent[]): number {
const unique = new Set();
for (const event of events) {
@@ -585,7 +656,7 @@ function DeepResearchWorkbenchInner() {
: "";
// 全文改写启动后,右侧只渲染临时草稿:首个 token 到达前保持空白,
// 不把旧报告混在新稿前面。真实 report.md 仍只在后台校验 + CAS 成功后替换。
- const displayedReport = reportDocumentRewrite.isActive
+ const rawDisplayedReport = reportDocumentRewrite.isActive
? reportDocumentRewrite.run.draft
: reportRewritePreview
|| (viewingLiveGeneration
@@ -593,6 +664,52 @@ function DeepResearchWorkbenchInner() {
: viewingParentReport
? (session?.reportMarkdown || "")
: sandboxVariantSession?.reportMarkdown || "");
+ // Never hide a backend response. Hiding refusal prose also removed the
+ // virtual report file and its sandbox-open trigger, leaving operators with
+ // neither a report nor an error. The backend now replaces refusal output at
+ // its final persistence gate; this client-side signal remains visible when
+ // a worker is stale or a new provider wording escapes that guard.
+ const refusalReportDetected = isRefusalReportText(rawDisplayedReport);
+ const hasUsableDisplayedReport = Boolean(rawDisplayedReport.trim()) && !refusalReportDetected;
+ // A completed session may retain an old diagnostic in `session.error` even
+ // after the backend has produced a usable fallback report. That historical
+ // diagnostic must not be appended to an otherwise normal report.
+ const showRunDiagnostic = Boolean(
+ refusalReportDetected
+ || (!hasUsableDisplayedReport && (session?.error || session?.status === "failed")),
+ );
+ const runtimeDiagnosticText = JSON.stringify(
+ session?.runtimeDiagnostics ?? {
+ revision: "frontend-fallback-diagnostic",
+ session: {
+ id: session?.id,
+ status: session?.status,
+ sourceCount: session?.sourceCount ?? 0,
+ activeJobId: session?.activeJobId,
+ },
+ error: session?.error ?? "报告正文被识别为拒绝型输出",
+ invalidOutputPreview: refusalReportDetected ? rawDisplayedReport.slice(0, 1_200) : undefined,
+ },
+ null,
+ 2,
+ );
+ const diagnosticMarkdown = showRunDiagnostic
+ ? [
+ "## 系统运行说明",
+ "",
+ "> 本次报告生成链路出现异常。系统不能据此判断为‘没有收集到素材’,已有素材不会被删除。请将下面的诊断信息复制给管理员排查。",
+ "",
+ "```json",
+ runtimeDiagnosticText,
+ "```",
+ ].join("\n")
+ : "";
+ // Invalid refusal prose (or a failed run with no report) is replaced instead
+ // of repeated to the user. Any usable normal/fallback/partial report is shown
+ // as-is and never receives diagnostic text.
+ const displayedReport = showRunDiagnostic
+ ? `# 报告生成异常\n\n${diagnosticMarkdown}`
+ : rawDisplayedReport;
const updateFullReportRewritePresentation = useCallback(
(presentationId: string | null, event: DocumentRewriteStreamEvent) => {
@@ -1329,6 +1446,7 @@ function DeepResearchWorkbenchInner() {
continue;
}
const version = typeof event.payload.reportVersion === "number" ? event.payload.reportVersion : reportVersion;
+ if (version < reportVersion) continue;
const target = typeof event.payload.target === "string" ? event.payload.target : "report";
const currentTarget = reportTargets[target];
if (!currentTarget || version >= currentTarget.version) {
@@ -1858,15 +1976,20 @@ function DeepResearchWorkbenchInner() {
try {
await actions.startJob.mutateAsync({
sessionId: session.id,
+ // First writing must harvest the collector thread before entering the
+ // shared static-material report engine. Structural regeneration can
+ // read already-persisted sources, but a first report cannot assume
+ // that selected source rows exist yet.
entry: "chat",
config: { ...config, ...modelConfig },
+ collectorMaterials: snapshotCollectorMaterials(collectorThread.messages),
});
setSandboxOpen(false);
toast.success("开始撰写研究报告");
} catch (error) {
toast.error(error instanceof Error ? error.message : "启动报告撰写失败");
}
- }, [actions.startJob, selectedModel, session]);
+ }, [actions.startJob, collectorThread.messages, selectedModel, session]);
const handleContinueCollecting = useCallback(() => {
document.getElementById("deep-research-composer")?.focus();
@@ -2739,7 +2862,7 @@ function ResearchConversation({
}) {
const active = session.status === "running" || session.status === "awaiting_input";
const hasTrace = active || events.length > 0;
- const streamedSourceCount = events.reduce((count, event) => count + (event.type === "source_added" ? 1 : 0), 0);
+ const streamedSourceCount = countEventMaterials(events);
const liveSourceCount = Math.max(sourceCount, streamedSourceCount);
const requestedImageCount = requestedReportImageCount(session.config);
return (
@@ -2847,6 +2970,7 @@ type TraceSnapshot = {
images: TraceImage[];
imagePhaseFinished: boolean;
warnings: string[];
+ diagnostics: Record[];
};
function traceRecord(value: unknown): Record {
@@ -2884,6 +3008,8 @@ function buildTraceSnapshot(events: ProgressEvent[]): TraceSnapshot {
const selectedIds = new Set();
const removedIds = new Set();
const warnings: string[] = [];
+ const diagnostics: Record[] = [];
+ const diagnosticKeys = new Set();
let curationReason: string | undefined;
let compressedSourceCount: number | undefined;
let reportCompleted = false;
@@ -2995,6 +3121,19 @@ function buildTraceSnapshot(events: ProgressEvent[]): TraceSnapshot {
if (event.type === "warning" || event.type === "error") {
const message = traceString(payload.message) ?? traceString(payload.detail);
if (message && !warnings.includes(message)) warnings.push(message);
+ const diagnostic = traceRecord(payload.diagnostic);
+ if (Object.keys(diagnostic).length > 0) {
+ const item = {
+ code: traceString(payload.code) ?? event.type,
+ ...(message ? { message } : {}),
+ ...diagnostic,
+ };
+ const key = JSON.stringify(item);
+ if (!diagnosticKeys.has(key)) {
+ diagnosticKeys.add(key);
+ diagnostics.push(item);
+ }
+ }
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);
@@ -3027,6 +3166,7 @@ function buildTraceSnapshot(events: ProgressEvent[]): TraceSnapshot {
images: [...imageMap.values()].sort((left, right) => left.position - right.position),
imagePhaseFinished,
warnings,
+ diagnostics,
};
}
@@ -3123,6 +3263,7 @@ const ResearchTrace = memo(function ResearchTrace({ events, phase, planText, pla
}
{snapshot.warnings.length > 0 && 过程提示
{snapshot.warnings.map((warning) =>
{warning}
)}
}
+ {snapshot.diagnostics.length > 0 && 运行诊断(用于排查,可复制)
{snapshot.diagnostics.map((item) => JSON.stringify(item, null, 2)).join("\n\n")} }
{error && 实时连接提示:{error}。研究仍会继续,页面会从已保存的事件记录恢复。
}
}
diff --git a/frontend-web/src/pages/deep-research/useReportSandbox.ts b/frontend-web/src/pages/deep-research/useReportSandbox.ts
index edf7500e..918f3643 100644
--- a/frontend-web/src/pages/deep-research/useReportSandbox.ts
+++ b/frontend-web/src/pages/deep-research/useReportSandbox.ts
@@ -36,6 +36,14 @@ export type ReportStubAiMessage = {
}[];
};
+export type ReportStubToolMessage = {
+ id: string;
+ type: "tool";
+ name: "write_file";
+ tool_call_id: string;
+ content: string;
+};
+
interface UseReportSandboxOptions {
/** stub 线程标识(建议用 sessionId/jobId 派生,切换会话即切换 stub)。 */
threadKey: string;
@@ -87,9 +95,14 @@ export function useReportSandbox({
wasWritingRef.current = writing;
}, [writing]);
- // 每帧 upsert stub(带防抹空:新帧为空而旧内容非空时保留旧内容)。
+ // 每帧 upsert stub。普通空帧不抹掉已渲染的文件,但写作过程中的
+ // 空正文表示后端要求重置报告版本(report_reset)或新稿尚未出首字,
+ // 此时必须清掉旧版,避免把首版失败文本继续展示在沙箱里。
useEffect(() => {
- if (!report) return;
+ if (!report) {
+ if (writing) setStubMessage(null);
+ return;
+ }
setStubMessage((prev) => {
const prevArgs = prev?.tool_calls?.[0]?.args as { content?: unknown } | undefined;
const prevContent = typeof prevArgs?.content === "string" ? prevArgs.content : "";
@@ -106,7 +119,7 @@ export function useReportSandbox({
tool_calls: [{ id: toolCallId, name: "write_file", args: { path: REPORT_OUTPUT_PATH, content } }],
};
});
- }, [messageId, report, toolCallId]);
+ }, [messageId, report, toolCallId, writing]);
// 报告首帧到达且仍在写入 → 自动展开右栏并选中虚拟 URL。
// openStreamedArtifact 尊重用户手动关闭(suppress),不会强行弹回。
@@ -132,8 +145,23 @@ export function useReportSandbox({
return {
virtualUrl,
stubMessages: useMemo(
- () => (currentStubMessage ? [currentStubMessage] : []),
- [currentStubMessage],
+ () => {
+ if (!currentStubMessage) return [];
+ if (writing) return [currentStubMessage];
+ // ArtifactFileDetail determines whether a write is still streaming by
+ // matching the write_file call with its tool result. `isLoading=false`
+ // alone is intentionally ignored there, so completed and historical
+ // virtual reports must include this synthetic completion receipt.
+ const completedWrite: ReportStubToolMessage = {
+ id: `${toolCallId}-result`,
+ type: "tool",
+ name: "write_file",
+ tool_call_id: toolCallId,
+ content: "ok",
+ };
+ return [currentStubMessage, completedWrite];
+ },
+ [currentStubMessage, toolCallId, writing],
),
openReport,
};
diff --git a/frontend-web/src/report-collaboration/README.md b/frontend-web/src/report-collaboration/README.md
new file mode 100644
index 00000000..72955059
--- /dev/null
+++ b/frontend-web/src/report-collaboration/README.md
@@ -0,0 +1,66 @@
+# 报告协作工作台(AgentScope 多智能体)—— 前端模块
+
+实现计划:`docs/agentscope-多智能体报告协作工作台-前端实施计划.md`
+(配套后端计划:`docs/agentscope-多智能体报告协作工作台-后端实施计划.md`)。
+
+## 结构
+
+```text
+src/report-collaboration/
+ api/ # 契约类型 + REST/SSE 客户端 + Mock 驱动
+ types.ts # 前后端共享领域类型(冻结契约,字段与后端 Pydantic 一致)
+ client.ts # rcFetch:鉴权、X-Idempotency-Key、X-Expected-Revision、409/422/429/5xx 错误映射
+ sessions.ts / plans.ts / runs.ts / reports.ts # 端点函数
+ driver.ts # 真实 / Mock 统一门面(getReportCollaborationDriver)
+ mock.ts # 本地演示驱动:澄清→3 方案→并行执行→报告版本,全走同一事件协议
+ state/
+ event-normalizer.ts # SSE/REST 事件防御性归一化
+ event-reducer.ts # durable 事件 reducer(seq 去重、乱序缓冲、节点状态机守卫、乐观消息替换)
+ selectors.ts # 页面状态投影(并行聚焦过滤、画布方案、阶段推导)
+ message-adapter.ts # CollaborationMessage → LangGraph Message + BaseStream 外观 + 虚拟业务卡
+ hooks/
+ useReportCollaborationSession.ts # 快照→SSE→合帧 reducer→动作(send/select/startRun/commands)
+ useReportCollaborationStream.ts # SSE 连接/指数退避重连/after_seq 续接
+ useReportCollaborationMessages.ts # 消息组装(聚焦过滤 + 业务卡 + thread 外观)
+ useReportPanelLayout.ts # 两栏宽度(持久化)、窄屏切换、键盘分隔条
+ useReportPlanSelection.ts # 预览(纯前端)与选定(服务器事实)分离
+ components/ # Header / 两栏 Layout / 会话面板(复用 MessageList)/ 方案卡 /
+ # 画布(@xyflow/react)/ 节点抽屉 / Composer / 干预卡 / 报告预览 / 历史 / 状态栏
+ pages/ReportCollaborationPage.tsx
+ styles/report-collaboration.css # --rc-* 语义 token(Chrome 109 兼容,无 color-mix)
+ tests/ # node:test 单测(reducer / adapter),运行:pnpm test:report-collaboration
+```
+
+## 路由与入口
+
+- `/page/workspace/report-collaboration` 新会话;`/page/workspace/report-collaboration/:sessionId` 会话页(按 sessionId 重挂)。
+- 侧边栏导航项「报告协作」(`workspace-nav-chat-list.tsx`)。
+- `WorkspaceRoutes.tsx` 引入页面与 CSS。
+
+## 关键约束(与计划一致)
+
+- 消息区**复用** `MessageList`:AgentScope 文本 / TeamSay / 工具调用与返回经
+ `message-adapter.ts` 一对一映射,经 `ThreadContext.Provider` + 合成 `BaseStream` 渲染;
+ 不另做摘要卡或第二套渲染器。
+- MessageList 新增的**通用业务卡插槽** `businessCardSlot`(分组 `assistant:business-card`,
+ 判据 `additional_kwargs.report_collaboration_card`,见 `core/messages/utils.ts`)只在本页
+ 传入;其他页面零行为变化。
+- 服务器快照 + durable SSE 是唯一事实源;`previewPlanId` / 聚焦成员 / 面板宽度均为纯展示状态。
+- 节点抽屉只读合同;重跑/纠偏经对话命令(`targetNodeId`/`agentRunId`)。
+
+## Mock 演示(后端未就绪时)
+
+开启方式任一:
+
+- `localStorage.setItem('deerflow.rc-mock','1')` 后刷新;
+- 环境变量 `VITE_RC_MOCK=1`。
+
+Mock 走与真实后端相同的驱动接口与事件协议(seq 单调、可重放),可完整演示:
+输入需求 → 澄清 → 回答 → 3 个候选方案 → 预览/采用 → 开始协作 → 并行成员流式输出 +
+工具调用 → 命令确认卡(输入「重新检索…」)→ 报告版本 → 预览/下载/恢复。
+
+## 后端契约冻结说明
+
+`api/types.ts` 按后端计划 §5/§7 编写;快照端点约定 `GET /sessions/{id}` 返回
+`SessionSnapshot`(session/messages/plans/run/nodes/agent_runs/commands/report_versions/last_seq)。
+后端实现时如有出入,以 OpenAPI 为准同步本文件与两份计划文档。
diff --git a/frontend-web/src/report-collaboration/api/client.ts b/frontend-web/src/report-collaboration/api/client.ts
new file mode 100644
index 00000000..c3224ac6
--- /dev/null
+++ b/frontend-web/src/report-collaboration/api/client.ts
@@ -0,0 +1,142 @@
+/**
+ * 报告协作 API 基础客户端:鉴权、幂等键、乐观锁与统一错误映射。
+ *
+ * - 写请求自动携带 `X-Idempotency-Key` 与 `X-Expected-Revision`。
+ * - 409 → ConflictError(刷新快照后由用户重新确认);422 → ValidationError;
+ * 429 → RateLimitError(预算/并发限制,不自动重试);5xx → ServerError
+ * (保留输入,允许同一幂等键重试)。
+ */
+
+import { apiFetch } from "@/core/api/fetch-client";
+import { getBackendBaseURL } from "@/core/config";
+
+import type { ApiErrorDetail } from "./types";
+
+export const REPORT_COLLABORATION_BASE = () =>
+ `${getBackendBaseURL()}/api/report-collaboration`;
+
+export class ApiError extends Error {
+ readonly status: number;
+ readonly code?: string;
+ readonly detail?: unknown;
+
+ constructor(status: number, message: string, code?: string, detail?: unknown) {
+ super(message);
+ this.name = "ApiError";
+ this.status = status;
+ this.code = code;
+ this.detail = detail;
+ }
+}
+
+/** 409:期望 revision 已过期(或 active run 冲突),不能静默覆盖。 */
+export class ConflictError extends ApiError {}
+/** 422:请求参数/语义错误,需在对应操作附近展示。 */
+export class ValidationError extends ApiError {}
+/** 429:预算、并发或速率限制,不自动重试。 */
+export class RateLimitError extends ApiError {}
+/** 5xx:服务端错误,可带同一幂等键重试。 */
+export class ServerError extends ApiError {}
+/** 网络层错误(fetch reject)。 */
+export class NetworkError extends ApiError {
+ constructor(message: string) {
+ super(0, message, "network");
+ }
+}
+
+export function newIdempotencyKey(): string {
+ if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
+ return `rc-${crypto.randomUUID()}`;
+ }
+ return `rc-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
+}
+
+async function parseErrorDetail(res: Response): Promise {
+ try {
+ const data = (await res.json()) as ApiErrorDetail | { detail?: unknown };
+ const source = "message" in data ? data : null;
+ if (source) return source;
+ if (typeof data.detail === "string") {
+ return { message: data.detail };
+ }
+ return null;
+ } catch {
+ return null;
+ }
+}
+
+function errorForStatus(res: Response, message: string, code?: string, detail?: unknown) {
+ if (res.status === 409) return new ConflictError(409, message, code, detail);
+ if (res.status === 422) return new ValidationError(422, message, code, detail);
+ if (res.status === 429) return new RateLimitError(429, message, code, detail);
+ if (res.status >= 500) return new ServerError(res.status, message, code, detail);
+ return new ApiError(res.status, message, code, detail);
+}
+
+export interface RcRequestInit extends Omit {
+ body?: unknown;
+ idempotencyKey?: string;
+ expectedRevision?: number | null;
+ /** 404 时返回 null 而不是抛错。 */
+ nullOn404?: boolean;
+ signal?: AbortSignal;
+}
+
+export async function rcRequest(path: string, init: RcRequestInit = {}): Promise {
+ const headers = new Headers();
+ headers.set("Accept", "application/json");
+ if (init.body !== undefined) {
+ headers.set("Content-Type", "application/json");
+ }
+ if (init.idempotencyKey) {
+ headers.set("X-Idempotency-Key", init.idempotencyKey);
+ }
+ if (init.expectedRevision !== undefined && init.expectedRevision !== null) {
+ headers.set("X-Expected-Revision", String(init.expectedRevision));
+ }
+
+ let res: Response;
+ try {
+ res = await apiFetch(`${REPORT_COLLABORATION_BASE()}${path}`, {
+ method: init.method ?? (init.body !== undefined ? "POST" : "GET"),
+ headers,
+ body: init.body !== undefined ? JSON.stringify(init.body) : undefined,
+ credentials: "include",
+ signal: init.signal,
+ });
+ } catch (err) {
+ throw new NetworkError(err instanceof Error ? err.message : String(err));
+ }
+
+ if (res.status === 404 && init.nullOn404) {
+ return null;
+ }
+
+ if (!res.ok) {
+ const detail = await parseErrorDetail(res);
+ throw errorForStatus(
+ res,
+ detail?.message ?? `request failed (${res.status})`,
+ detail?.code,
+ detail?.detail,
+ );
+ }
+
+ if (res.status === 204) {
+ return null;
+ }
+ const text = await res.text();
+ if (!text) {
+ return null;
+ }
+ return JSON.parse(text) as T;
+}
+
+/** GET 便捷封装(非空断言版)。 */
+export async function rcGet(path: string, signal?: AbortSignal): Promise {
+ const data = await rcRequest(path, { method: "GET", signal });
+ if (data === null) {
+ throw new ApiError(404, `resource not found: ${path}`);
+ }
+ return data;
+}
diff --git a/frontend-web/src/report-collaboration/api/driver.ts b/frontend-web/src/report-collaboration/api/driver.ts
new file mode 100644
index 00000000..09835123
--- /dev/null
+++ b/frontend-web/src/report-collaboration/api/driver.ts
@@ -0,0 +1,231 @@
+/**
+ * 驱动接口:真实后端 / 本地 Mock 的统一门面。
+ *
+ * Mock 开关(任一为真即 Mock,便于后端未就绪时演示与联调自测):
+ * - 环境变量 `VITE_RC_MOCK=1`;
+ * - localStorage `deerflow.rc-mock` = `"1"`(浏览器里临时打开:
+ * `localStorage.setItem('deerflow.rc-mock','1')` 后刷新)。
+ */
+
+import * as plansApi from "@/report-collaboration/api/plans";
+import * as reportsApi from "@/report-collaboration/api/reports";
+import * as runsApi from "@/report-collaboration/api/runs";
+import * as sessionsApi from "@/report-collaboration/api/sessions";
+import { mockDriver } from "@/report-collaboration/api/mock";
+import { ApiError } from "@/report-collaboration/api/client";
+import type {
+ CollaborationCommand,
+ CollaborationEvent,
+ CollaborationMessage,
+ CreateCommandPayload,
+ CreateRewritePayload,
+ NodeContractDetail,
+ ReportRewriteProposal,
+ ReportRun,
+ ReportSession,
+ ReportSessionSummary,
+ ReportVersion,
+ SendMessagePayload,
+ SessionSnapshot,
+ StartRunPayload,
+} from "@/report-collaboration/api/types";
+
+export interface OpenStreamHandlers {
+ onEvent: (event: CollaborationEvent) => void;
+ onOpen?: () => void;
+ onClose?: () => void;
+ onError?: (message: string) => void;
+}
+
+export interface ReportCollaborationDriver {
+ listSessions(signal?: AbortSignal): Promise;
+ getSnapshot(sessionId: string, signal?: AbortSignal): Promise;
+
+ createSession(title?: string): Promise;
+ updateSession(
+ sessionId: string,
+ patch: { title?: string },
+ expectedRevision?: number | null,
+ ): Promise;
+ deleteSession(sessionId: string): Promise;
+
+ sendMessage(
+ sessionId: string,
+ payload: SendMessagePayload,
+ idempotencyKey: string,
+ ): Promise;
+
+ requestPlans(sessionId: string, expectedRevision?: number | null): Promise;
+ selectPlan(
+ sessionId: string,
+ planId: string,
+ expectedRevision?: number | null,
+ ): Promise;
+ startRun(
+ sessionId: string,
+ payload: StartRunPayload,
+ expectedRevision?: number | null,
+ ): Promise;
+
+ getRun(runId: string, signal?: AbortSignal): Promise;
+ listRunEvents(runId: string, afterSeq: number, signal?: AbortSignal): Promise;
+ openRunStream(
+ runId: string,
+ afterSeq: number,
+ handlers: OpenStreamHandlers,
+ ): () => void;
+ cancelRun(runId: string): Promise;
+
+ createCommand(
+ runId: string,
+ payload: CreateCommandPayload,
+ idempotencyKey: string,
+ ): Promise;
+ confirmCommand(runId: string, commandId: string): Promise;
+ cancelCommand(runId: string, commandId: string): Promise;
+
+ getNodeContract(runId: string, nodeId: string, signal?: AbortSignal): Promise;
+
+ listReportVersions(sessionId: string, signal?: AbortSignal): Promise;
+ createRewrite(sessionId: string, payload: CreateRewritePayload): Promise;
+ applyRewrite(sessionId: string, rewriteId: string): Promise;
+ restoreReportVersion(sessionId: string, versionId: string): Promise;
+}
+
+const realDriver: ReportCollaborationDriver = {
+ listSessions: sessionsApi.listSessions,
+ getSnapshot: sessionsApi.getSessionSnapshot,
+ createSession: (title) => sessionsApi.createSession(title ? { title } : {}),
+ updateSession: sessionsApi.updateSession,
+ deleteSession: sessionsApi.deleteSession,
+ sendMessage: sessionsApi.sendMessage,
+ requestPlans: (sessionId, expectedRevision) =>
+ plansApi.requestPlans(sessionId, undefined, expectedRevision),
+ selectPlan: async (sessionId, planId, expectedRevision) => {
+ await plansApi.selectPlan(sessionId, planId, expectedRevision);
+ },
+ startRun: runsApi.startRun,
+ getRun: runsApi.getRun,
+ listRunEvents: runsApi.listRunEvents,
+ openRunStream: runsApi.openRunStream,
+ cancelRun: runsApi.cancelRun,
+ createCommand: runsApi.createCommand,
+ confirmCommand: runsApi.confirmCommand,
+ cancelCommand: runsApi.cancelCommand,
+ getNodeContract: runsApi.getNodeContract,
+ listReportVersions: reportsApi.listReportVersions,
+ createRewrite: reportsApi.createRewrite,
+ applyRewrite: reportsApi.applyRewrite,
+ restoreReportVersion: reportsApi.restoreReportVersion,
+};
+
+let cachedDriver: ReportCollaborationDriver | null = null;
+/** DEV 下探测到后端无 /api/report-collaboration(404/网络失败/非 JSON)后自动切 Mock。 */
+let devFallbackToMock = false;
+
+export function isMockEnabled(): boolean {
+ try {
+ if (import.meta.env.VITE_RC_MOCK === "1") return true;
+ if (typeof window !== "undefined") {
+ return window.localStorage.getItem("deerflow.rc-mock") === "1";
+ }
+ } catch {
+ // localStorage 不可用时按真实后端处理。
+ }
+ return false;
+}
+
+/** 显式关闭(localStorage "0"),优先级高于 DEV 自动回退。 */
+function isMockExplicitlyDisabled(): boolean {
+ try {
+ return typeof window !== "undefined" && window.localStorage.getItem("deerflow.rc-mock") === "0";
+ } catch {
+ return false;
+ }
+}
+
+/** 后端尚未实现该路由的特征:404/405/501、网络失败、或 SPA fallback 返回的非 JSON。 */
+function isFeatureMissingError(err: unknown): boolean {
+ if (err instanceof ApiError) {
+ // 405:网关对未知 POST 路由的响应形态;404/501:路由未注册。
+ return err.status === 404 || err.status === 405 || err.status === 501 || err.status === 0;
+ }
+ // rcRequest 对 200+HTML(dev server SPA fallback)会抛 JSON.parse 的 SyntaxError。
+ return err instanceof SyntaxError;
+}
+
+async function withDevFallback(op: () => Promise, retry: () => Promise): Promise {
+ try {
+ return await op();
+ } catch (err) {
+ if (!canDevFallback(err)) throw err;
+ enableMockFallback();
+ return retry();
+ }
+}
+
+function canDevFallback(err: unknown): boolean {
+ return (
+ import.meta.env.DEV &&
+ !devFallbackToMock &&
+ !isMockExplicitlyDisabled() &&
+ isFeatureMissingError(err)
+ );
+}
+
+function enableMockFallback(): void {
+ devFallbackToMock = true;
+ console.info(
+ "[report-collaboration] 后端 /api/report-collaboration 尚不可用,开发模式自动切换到本地 Mock 演示驱动(localStorage deerflow.rc-mock=0 可强制关闭)。",
+ );
+}
+
+/** DEV 自动回退驱动:命中回退标志后所有方法直达 Mock;否则先走真实后端。 */
+const devFallbackEntries: Partial = {
+ async listSessions(signal) {
+ if (devFallbackToMock) return mockDriver.listSessions(signal);
+ return withDevFallback(
+ () => realDriver.listSessions(signal),
+ () => mockDriver.listSessions(signal),
+ );
+ },
+ async getSnapshot(sessionId, signal) {
+ if (devFallbackToMock) return mockDriver.getSnapshot(sessionId, signal);
+ const snapshot = await realDriver.getSnapshot(sessionId, signal);
+ // 真实后端 404 且 DEV 未定态时,可能是功能未部署:探测一次并转 Mock。
+ if (
+ snapshot === null &&
+ import.meta.env.DEV &&
+ !isMockExplicitlyDisabled() &&
+ !devFallbackToMock
+ ) {
+ enableMockFallback();
+ return mockDriver.getSnapshot(sessionId, signal);
+ }
+ return snapshot;
+ },
+ async createSession(title) {
+ if (devFallbackToMock) return mockDriver.createSession(title);
+ return withDevFallback(
+ () => realDriver.createSession(title),
+ () => mockDriver.createSession(title),
+ );
+ },
+};
+
+const devFallbackDriver: ReportCollaborationDriver = new Proxy(devFallbackEntries, {
+ get(target, prop, receiver) {
+ if (Reflect.has(target, prop)) {
+ return Reflect.get(target, prop, receiver);
+ }
+ const active = devFallbackToMock ? mockDriver : realDriver;
+ const value = Reflect.get(active, prop);
+ return typeof value === "function" ? value.bind(active) : value;
+ },
+}) as ReportCollaborationDriver;
+
+export function getReportCollaborationDriver(): ReportCollaborationDriver {
+ if (cachedDriver) return cachedDriver;
+ cachedDriver = isMockEnabled() ? mockDriver : devFallbackDriver;
+ return cachedDriver;
+}
diff --git a/frontend-web/src/report-collaboration/api/mock.ts b/frontend-web/src/report-collaboration/api/mock.ts
new file mode 100644
index 00000000..387f58b7
--- /dev/null
+++ b/frontend-web/src/report-collaboration/api/mock.ts
@@ -0,0 +1,1055 @@
+/**
+ * 本地 Mock 驱动:后端(/api/report-collaboration)未就绪时的完整演示与联调自测。
+ *
+ * 通过 driver.ts 的开关启用;实现与真实驱动相同的 ReportCollaborationDriver
+ * 接口 + 相同的 durable 事件协议(seq 单调、事件先落日志再广播、支持
+ * after_seq 重放),因此前端的快照/SSE/reducer 全链路走的是与真实后端
+ * 一致的代码路径。
+ *
+ * 演示脚本(后端 POC 固定任务):输入任意报告需求 → 澄清一次 → 生成 3 个
+ * 候选方案 → 选择并开始 → 并行研究(TeamSay/工具调用/流式回复)→ 核验 →
+ * 写作(report.delta → 正式版本)→ 审稿 → 完成;运行中支持命令确认卡演示。
+ */
+
+import type { ReportCollaborationDriver } from "@/report-collaboration/api/driver";
+import type {
+ AgentRun,
+ CollaborationCommand,
+ CollaborationEvent,
+ CollaborationEventType,
+ CollaborationMessage,
+ CollaborationNodeState,
+ PlanNode,
+ ReportPlanCandidate,
+ ReportRun,
+ ReportSession,
+ ReportVersion,
+} from "@/report-collaboration/api/types";
+
+// ── 内存存储 ─────────────────────────────────────────────────────────────────
+
+interface MockStore {
+ session: ReportSession;
+ run: ReportRun;
+ messages: CollaborationMessage[];
+ events: CollaborationEvent[];
+ subscribers: Set<(event: CollaborationEvent) => void>;
+ seq: number;
+ plans: ReportPlanCandidate[];
+ commands: CollaborationCommand[];
+ nodes: Record;
+ agentRuns: Record;
+ reportVersions: ReportVersion[];
+ timers: number[];
+ /** 已创建的成果物 id(演示里按节点顺序对应)。 */
+ artifactIds: string[];
+ /** 会话脚本位置:是否已澄清过一轮。 */
+ clarified: boolean;
+ versionCounter: number;
+}
+
+const stores = new Map();
+let storeCounter = 0;
+
+function uid(prefix: string): string {
+ storeCounter += 1;
+ return `${prefix}_mock_${storeCounter.toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
+}
+
+function getStore(sessionId: string): MockStore | null {
+ return stores.get(sessionId) ?? null;
+}
+
+function emit(store: MockStore, type: CollaborationEventType, data: unknown): CollaborationEvent {
+ store.seq += 1;
+ const event: CollaborationEvent = {
+ eventId: `evt_${store.seq}`,
+ sessionId: store.session.id,
+ runId: store.run.id,
+ seq: store.seq,
+ type,
+ timestamp: new Date().toISOString(),
+ // 深拷贝:真实后端经 JSON 序列化下发,订阅方拿到的必须是不可变快照,
+ // 不能与 mock 的存活对象共享引用(否则脚本后续变异会穿透进前端状态)。
+ data: JSON.parse(JSON.stringify(data ?? {})) as unknown,
+ };
+ store.events.push(event);
+ for (const subscriber of store.subscribers) {
+ subscriber(event);
+ }
+ return event;
+}
+
+function later(store: MockStore, ms: number, fn: () => void): void {
+ const timer = window.setTimeout(() => {
+ if (store.run.status === "cancelled" && store.session.status === "cancelled") return;
+ fn();
+ }, ms);
+ store.timers.push(timer);
+}
+
+function now(): string {
+ return new Date().toISOString();
+}
+
+// ── 消息辅助 ─────────────────────────────────────────────────────────────────
+
+function humanMessage(text: string, idempotencyKey?: string): CollaborationMessage {
+ return {
+ message_id: uid("msg"),
+ role: "human",
+ content: text,
+ visibility: "visible",
+ metadata: idempotencyKey ? { idempotency_key: idempotencyKey } : null,
+ created_at: now(),
+ completed_at: now(),
+ };
+}
+
+function aiMessage(
+ name: string,
+ agentRunId: string | null,
+ content = "",
+ nodeRunId?: string | null,
+): CollaborationMessage {
+ return {
+ message_id: uid("msg"),
+ role: "ai",
+ content,
+ name,
+ agent_run_id: agentRunId,
+ node_run_id: nodeRunId ?? null,
+ phase_id: agentRunId ? "phase_parallel_research" : null,
+ visibility: "visible",
+ created_at: now(),
+ completed_at: null,
+ };
+}
+
+/** 以 token delta 流式输出一条 assistant / TeamSay 消息。 */
+function streamAssistant(
+ store: MockStore,
+ message: CollaborationMessage,
+ text: string,
+ teamMessage = false,
+): void {
+ const createdType = teamMessage ? "team_message.created" : "message.created";
+ const deltaType = teamMessage ? "team_message.delta" : "message.delta";
+ const completedType = teamMessage ? "team_message.completed" : "message.completed";
+
+ store.messages.push(message);
+ emit(store, createdType, { message });
+ const chunkSize = 18;
+ const chunks: string[] = [];
+ for (let i = 0; i < text.length; i += chunkSize) {
+ chunks.push(text.slice(i, i + chunkSize));
+ }
+ chunks.forEach((chunk, index) => {
+ later(store, 70 * (index + 1), () => {
+ message.content += chunk;
+ emit(store, deltaType, {
+ message_id: message.message_id,
+ agent_run_id: message.agent_run_id,
+ delta: chunk,
+ });
+ if (index === chunks.length - 1) {
+ message.completed_at = now();
+ emit(store, completedType, {
+ message_id: message.message_id,
+ agent_run_id: message.agent_run_id,
+ });
+ }
+ });
+ });
+}
+
+// ── 演示内容 ─────────────────────────────────────────────────────────────────
+
+const CLARIFY_TEXT =
+ "收到您的报告需求。为了给出最合适的协作方案,请先确认两个问题:\n\n1. 这份报告的主要读者是谁?(例如企业战略决策层 / 投资机构 / 业务部门)\n2. 是否有必须覆盖的分析角度或资料范围限制?\n\n也可以直接回复「直接开始」,我会按默认假设(面向战略决策人员、覆盖市场/竞争/政策/风险四个角度)生成候选方案。";
+
+function planNode(
+ id: string,
+ label: string,
+ role: string,
+ dependsOn: string[],
+ mission: string,
+ output: string,
+ tools: string[],
+ acceptance: string[],
+): PlanNode {
+ return {
+ id,
+ label,
+ role_key: role,
+ role_display_name: role,
+ depends_on: dependsOn,
+ mission,
+ output_artifact_type: output,
+ input_artifact_types: dependsOn.length ? [`${dependsOn[0]}:${output}`] : [],
+ allowed_tools: tools,
+ acceptance_criteria: acceptance,
+ };
+}
+
+function buildDemoPlans(requirementRevision: number): ReportPlanCandidate[] {
+ const groupId = uid("group");
+ const mk = (
+ id: string,
+ strategy: string,
+ title: string,
+ summary: string,
+ rationale: string,
+ duration: number,
+ cost: "low" | "medium" | "high",
+ nodes: PlanNode[],
+ recommended = false,
+ ): ReportPlanCandidate => ({
+ id,
+ proposal_group_id: groupId,
+ title,
+ strategy,
+ summary,
+ rationale,
+ recommended,
+ estimated_duration_seconds: duration,
+ estimated_cost_level: cost,
+ requirement_revision: requirementRevision,
+ roles: Array.from(
+ new Set(nodes.map((node) => node.role_display_name ?? "")),
+ )
+ .filter(Boolean)
+ .map((displayName) => ({
+ key: displayName,
+ display_name: displayName,
+ responsibility: `承担「${displayName}」相关节点的职责`,
+ })),
+ nodes,
+ edges: nodes.flatMap((node) =>
+ node.depends_on.map((source) => ({ source, target: node.id, label: null })),
+ ),
+ quality_gates: [
+ { id: `${id}-g1`, label: "来源核验:关键事实需可溯源" },
+ { id: `${id}-g2`, label: "独立审稿:结论与证据一致" },
+ ],
+ report_structure_id: null,
+ validation: { ok: true, errors: [] },
+ revision: 1,
+ status: "recommended" as unknown as ReportPlanCandidate["status"],
+ });
+
+ return [
+ mk(
+ "plan-balanced",
+ "balanced_review",
+ "均衡协作 · 研究核验写作审稿全链路",
+ "两条并行研究线(市场规模 / 品牌竞争)+ 交叉核验 + 综合分析 + 单人写作 + 独立审稿,质量与成本均衡。",
+ "在覆盖主要角度的同时控制团队规模,是大多数正式报告的稳妥选择。",
+ 1500,
+ "medium",
+ [
+ planNode("n-req", "需求理解", "协调者", [], "固化需求快照与模板约束", "RequirementSnapshot", [], ["需求字段完整"]),
+ planNode("n-research-market", "市场规模研究", "研究员", ["n-req"], "检索并整理市场规模、份额与增速证据", "EvidenceBundle", ["web_search"], ["≥8 条来源且半数在 90 天内"]),
+ planNode("n-research-brand", "品牌竞争研究", "研究员", ["n-req"], "检索主要品牌动态、价格策略与技术路线", "EvidenceBundle", ["web_search"], ["≥8 条来源,覆盖 ≥3 个品牌"]),
+ planNode("n-verify", "交叉核验", "核验员", ["n-research-market", "n-research-brand"], "核对数字口径、时效与冲突来源", "VerificationResult", [], ["关键数字均有支持/反对证据标注"]),
+ planNode("n-analysis", "综合分析", "分析员", ["n-verify"], "形成各角度结论与风险机会判断", "AngleAnalysis", [], ["结论引用核验后的证据 ID"]),
+ planNode("n-write", "报告撰写", "撰写员", ["n-analysis"], "按模板产出带引用的完整报告", "ReportVersion", ["write_file"], ["模板章节完整、关键事实带引用"]),
+ planNode("n-review", "独立审稿", "审稿员", ["n-write"], "评审结论-证据一致性与结构完整性", "ReviewDecision", [], ["问题可映射到具体节点/章节"]),
+ ],
+ true,
+ ),
+ mk(
+ "plan-depth",
+ "parallel_depth",
+ "深度并行 · 四角度双重核验",
+ "四条并行研究线(市场 / 品牌 / 海外 / 政策)+ 双重核验 + 分角度分析 + 综合 + 写作 + 审稿 + 定向修订,最全面也最贵。",
+ "面向高风险决策场景:角度覆盖与证据强度优先,接受更高成本与时长。",
+ 2400,
+ "high",
+ [
+ planNode("n-req", "需求理解", "协调者", [], "固化需求快照与模板约束", "RequirementSnapshot", [], ["需求字段完整"]),
+ planNode("n-research-market", "市场规模研究", "研究员", ["n-req"], "市场规模、份额与增速证据", "EvidenceBundle", ["web_search"], ["≥10 条来源"]),
+ planNode("n-research-brand", "品牌竞争研究", "研究员", ["n-req"], "品牌动态、价格策略与技术路线", "EvidenceBundle", ["web_search"], ["覆盖 ≥4 个品牌"]),
+ planNode("n-research-oversea", "海外进展研究", "研究员", ["n-req"], "出海布局与海外政策", "EvidenceBundle", ["web_search"], ["≥8 条来源,含近 30 天动态"]),
+ planNode("n-research-policy", "政策环境研究", "研究员", ["n-req"], "补贴、准入与法规变化", "EvidenceBundle", ["web_search"], ["≥6 条权威来源"]),
+ planNode("n-verify-1", "一次核验", "核验员", ["n-research-market", "n-research-brand"], "口径与时效核对", "VerificationResult", [], ["冲突来源标记"]),
+ planNode("n-verify-2", "二次核验", "核验员", ["n-research-oversea", "n-research-policy"], "交叉复核高风险结论", "VerificationResult", [], ["高风险结论双人复核"]),
+ planNode("n-analysis", "综合分析", "分析员", ["n-verify-1", "n-verify-2"], "分角度结论与 12-24 个月展望", "AngleAnalysis", [], ["结论引用证据 ID"]),
+ planNode("n-write", "报告撰写", "撰写员", ["n-analysis"], "完整报告", "ReportVersion", ["write_file"], ["章节与模板一致"]),
+ planNode("n-review", "独立审稿", "审稿员", ["n-write"], "一致性与完整性评审", "ReviewDecision", [], ["问题可定位"]),
+ planNode("n-revise", "定向修订", "修订员", ["n-review"], "按审稿意见返工目标节点", "ReportVersion", ["write_file"], ["仅修改指定问题"]),
+ ],
+ ),
+ mk(
+ "plan-fast",
+ "focused_fast",
+ "快速聚焦 · 窄范围短链路",
+ "单研究线 + 核验 + 分析 + 写作 + 审稿,范围收窄但保留引用核验与审稿,最快出稿。",
+ "适合时间紧、角度已知、需要可信结论而非全面覆盖的场景。",
+ 800,
+ "low",
+ [
+ planNode("n-req", "需求理解", "协调者", [], "固化需求快照", "RequirementSnapshot", [], ["需求字段完整"]),
+ planNode("n-research-market", "聚焦研究", "研究员", ["n-req"], "按指定角度检索证据", "EvidenceBundle", ["web_search"], ["≥6 条来源"]),
+ planNode("n-verify", "核验", "核验员", ["n-research-market"], "核对关键事实", "VerificationResult", [], ["关键数字有来源"]),
+ planNode("n-analysis", "分析", "分析员", ["n-verify"], "形成结论", "AngleAnalysis", [], ["结论有证据支撑"]),
+ planNode("n-write", "撰写", "撰写员", ["n-analysis"], "产出报告", "ReportVersion", ["write_file"], ["模板章节完整"]),
+ planNode("n-review", "审稿", "审稿员", ["n-write"], "快速评审", "ReviewDecision", [], ["无高危问题"]),
+ ],
+ ),
+ ].map((plan) => ({ ...plan, status: "proposed" }));
+}
+
+const DEMO_REPORT_MARKDOWN = `# 新能源汽车主要品牌市场趋势分析报告
+
+> 版本 v1 · 由报告协作工作台(Mock 演示)生成 · 关键数字均附来源
+
+## 一、市场规模与份额
+
+2024 年以来中国新能源汽车市场保持较快增长,头部集中度持续提升。比亚迪、特斯拉与吉利/长安等传统转型阵营合计占据主要份额,新势力阵营分化明显。
+
+- 2024 年新能源乘用车零售渗透率多个月份超过 50%(来源:乘联会月度数据)
+- 插混与增程增速显著高于纯电(来源:中汽协产销快讯)
+
+## 二、价格策略
+
+价格竞争从「全面降价」转向「增配保价」:头部品牌以智驾下沉与金融方案维持毛利,尾部品牌以限时权益清库。
+
+## 三、技术路线
+
+- 纯电平台 800V 化与快充网络竞赛加速;
+- 插混/增程作为过渡路线的窗口期仍在;
+- 智驾进入「全国都能开」的城市 NOA 竞赛。
+
+## 四、海外进展
+
+东南亚与拉美成为出海重点,欧盟关税促使本地化建厂评估提速。
+
+## 五、政策环境
+
+以旧换新补贴与地方购车指标倾斜延续,行业规范条件收紧尾部产能。
+
+## 六、未来 12–24 个月风险与机会
+
+**风险**:价格战侵蚀毛利、海外贸易壁垒、尾部产能出清的供应链波动。
+**机会**:智驾平权带来的产品溢价、海外本地化产能、商用车电动化第二曲线。
+
+---
+
+*本报告为 Mock 演示内容,用于验证前端全链路(消息镜像 / 工具步骤 / 版本 / 导出)。*`;
+
+// ── 节点执行脚本 ─────────────────────────────────────────────────────────────
+
+function ensureNodeState(store: MockStore, node: PlanNode): CollaborationNodeState {
+ if (!store.nodes[node.id]) {
+ store.nodes[node.id] = {
+ node_id: node.id,
+ label: node.label,
+ role_display_name: node.role_display_name ?? null,
+ angle: node.angle ?? null,
+ status: "planned",
+ attempt: 0,
+ attempts: [],
+ };
+ }
+ return store.nodes[node.id];
+}
+
+function setNodeStatus(
+ store: MockStore,
+ nodeId: string,
+ status: CollaborationNodeState["status"],
+ attempt = 1,
+ extra: Partial = {},
+): void {
+ const node = store.nodes[nodeId];
+ if (!node) return;
+ node.status = status;
+ node.attempt = attempt;
+ node.awaiting_input = extra.awaiting_input ?? null;
+ node.error = extra.error ?? null;
+ emit(store, "node.status.changed", {
+ node_id: nodeId,
+ status,
+ attempt,
+ awaiting_input: node.awaiting_input,
+ error: node.error,
+ });
+}
+
+interface ResearchScript {
+ agentRunId: string;
+ displayName: string;
+ nodeId: string;
+ teamSay: string;
+ searchQuery: string;
+ searchSummary: string;
+ reply: string;
+}
+
+function runResearchAgent(store: MockStore, script: ResearchScript, baseDelay: number): void {
+ const agentRun: AgentRun = {
+ agent_run_id: script.agentRunId,
+ node_run_id: `${script.nodeId}-attempt1`,
+ phase_id: "phase_parallel_research",
+ role: "研究员",
+ display_name: script.displayName,
+ status: "running",
+ started_at: now(),
+ };
+ store.agentRuns[script.agentRunId] = agentRun;
+
+ later(store, baseDelay, () => {
+ streamAssistant(
+ store,
+ aiMessage(script.displayName, script.agentRunId, "", agentRun.node_run_id),
+ script.teamSay,
+ true,
+ );
+ });
+
+ later(store, baseDelay + 500, () => {
+ const callId = `tc_${script.agentRunId}`;
+ emit(store, "tool_call.created", {
+ message_id: `ai-tc-${script.agentRunId}-${callId}`,
+ agent_run_id: script.agentRunId,
+ tool_call: { id: callId, name: "web_search", args: { query: script.searchQuery } },
+ });
+ });
+
+ later(store, baseDelay + 1200, () => {
+ const callId = `tc_${script.agentRunId}`;
+ const toolResultId = `tool-result-${callId}`;
+ const content = JSON.stringify(
+ {
+ query: script.searchQuery,
+ count: 8,
+ results: Array.from({ length: 8 }, (_, i) => ({
+ title: `${script.searchSummary} · 来源 ${i + 1}`,
+ url: `https://example.com/source/${script.agentRunId}/${i + 1}`,
+ published_at: "2026-08-15",
+ })),
+ },
+ null,
+ 0,
+ );
+ store.messages.push({
+ message_id: toolResultId,
+ role: "tool",
+ content,
+ name: "web_search",
+ tool_call_id: callId,
+ agent_run_id: script.agentRunId,
+ node_run_id: agentRun.node_run_id ?? null,
+ phase_id: "phase_parallel_research",
+ visibility: "visible",
+ created_at: now(),
+ completed_at: now(),
+ });
+ emit(store, "tool_result.created", {
+ message_id: toolResultId,
+ tool_call_id: callId,
+ name: "web_search",
+ agent_run_id: script.agentRunId,
+ node_run_id: agentRun.node_run_id,
+ content,
+ });
+ });
+
+ later(store, baseDelay + 1700, () => {
+ streamAssistant(
+ store,
+ aiMessage(script.displayName, script.agentRunId, "", agentRun.node_run_id),
+ script.reply,
+ );
+ });
+
+ later(store, baseDelay + 2600, () => {
+ setNodeStatus(store, script.nodeId, "validating");
+ const artifactId = uid("art");
+ store.artifactIds.push(artifactId);
+ emit(store, "artifact.created", {
+ artifact: {
+ id: artifactId,
+ run_id: store.run.id,
+ node_id: script.nodeId,
+ attempt: 1,
+ artifact_type: "EvidenceBundle",
+ schema_version: 1,
+ validation_status: "pending",
+ summary: "8 条来源,6 条在 90 天内,无冲突",
+ created_at: now(),
+ },
+ });
+ });
+
+ later(store, baseDelay + 3100, () => {
+ setNodeStatus(store, script.nodeId, "completed");
+ agentRun.status = "completed";
+ agentRun.ended_at = now();
+ const artifactId = store.artifactIds.at(-1);
+ if (artifactId) emit(store, "artifact.validated", { artifact_id: artifactId });
+ });
+}
+
+function executePlan(store: MockStore, plan: ReportPlanCandidate): void {
+ for (const node of plan.nodes) {
+ ensureNodeState(store, node);
+ emit(store, "node.created", { node: store.nodes[node.id] });
+ }
+ store.run.status = "running";
+ emit(store, "run.status.changed", { status: "running", phase: "phase_parallel_research" });
+
+ const researchScripts: ResearchScript[] = plan.nodes
+ .filter((node) => node.allowed_tools?.includes("web_search"))
+ .map((node, index) => ({
+ agentRunId: `agent_run_${node.id}`,
+ displayName: `${node.label}师`,
+ nodeId: node.id,
+ teamSay: `我认领「${node.label}」任务,先做一轮公开资料检索,重点看近 90 天的数据。`,
+ searchQuery: `${node.label} 新能源汽车 2026`,
+ searchSummary: node.label,
+ reply: `【${node.label}】初步结论:${node.mission ?? ""}。已收集 8 条来源,其中 6 条为近 90 天信息,暂未发现口径冲突。详细证据已作为 EvidenceBundle 提交核验。`,
+ baseDelayUnused: index,
+ }));
+
+ researchScripts.forEach((script, index) => {
+ setNodeStatus(store, script.nodeId, "queued");
+ later(store, 200 + index * 120, () => setNodeStatus(store, script.nodeId, "running"));
+ runResearchAgent(store, script, 600 + index * 150);
+ });
+
+ const researchIds = new Set(researchScripts.map((script) => script.nodeId));
+ const rest = plan.nodes.filter((node) => !researchIds.has(node.id) && node.id !== "n-req");
+ const coordRun: AgentRun = {
+ agent_run_id: "agent_run_coordinator",
+ node_run_id: "n-req-attempt1",
+ phase_id: "phase_coordinator",
+ role: "协调者",
+ display_name: "协调者",
+ status: "running",
+ started_at: now(),
+ };
+ store.agentRuns[coordRun.agent_run_id] = coordRun;
+
+ // 成员实例创建后经 session.snapshot 广播(协议内的快照事件携带 agent_runs),
+ // 前端据此渲染并行成员选择器。
+ emit(store, "session.snapshot", {
+ session: { ...store.session },
+ run: { ...store.run },
+ agent_runs: Object.values(store.agentRuns).map((run) => ({ ...run })),
+ });
+
+ later(store, 300, () => {
+ setNodeStatus(store, "n-req", "completed");
+ coordRun.status = "completed";
+ });
+
+ let cursor = 4200;
+ for (const node of rest) {
+ const isWriter = node.output_artifact_type === "ReportVersion" && node.id === "n-write";
+ const isReview = node.id === "n-review";
+ const isRevise = node.id === "n-revise";
+ const delay = cursor;
+ later(store, delay, () => setNodeStatus(store, node.id, "queued"));
+ later(store, delay + 200, () => setNodeStatus(store, node.id, "running"));
+ if (isWriter) {
+ later(store, delay + 600, () => {
+ const chunks = DEMO_REPORT_MARKDOWN.match(/[\s\S]{1,60}/g) ?? [];
+ chunks.forEach((chunk, index) => {
+ later(store, 60 * index, () => {
+ emit(store, "report.delta", { draft_id: "draft-v1", delta: chunk });
+ });
+ });
+ });
+ } else {
+ later(store, delay + 600, () => {
+ streamAssistant(
+ store,
+ aiMessage(node.role_display_name ?? node.label, null, "", `${node.id}-attempt1`),
+ `【${node.label}】${node.mission ?? "处理中"}……完成,成果已通过质量门。`,
+ );
+ });
+ }
+ later(store, delay + (isWriter ? 2600 : 1100), () => {
+ setNodeStatus(store, node.id, "validating");
+ });
+ later(store, delay + (isWriter ? 3200 : 1500), () => {
+ setNodeStatus(store, node.id, "completed");
+ if (isWriter) {
+ store.versionCounter += 1;
+ const version: ReportVersion = {
+ id: uid("ver"),
+ session_id: store.session.id,
+ version: store.versionCounter,
+ parent_version_id: null,
+ change_type: "initial",
+ change_summary: "初版报告",
+ markdown: DEMO_REPORT_MARKDOWN,
+ source_index: [],
+ created_at: now(),
+ is_head: true,
+ };
+ for (const old of store.reportVersions) old.is_head = false;
+ store.reportVersions.push(version);
+ store.session.current_report_version_id = version.id;
+ emit(store, "report.version.created", { version });
+ }
+ if (isReview) {
+ emit(store, "review.created", {
+ review: {
+ reviewer: "审稿员",
+ verdict: "pass",
+ issues: [],
+ },
+ });
+ store.run.status = "reviewing";
+ emit(store, "run.status.changed", { status: "reviewing", phase: "phase_review" });
+ }
+ if (isRevise) {
+ store.run.status = "completed";
+ store.session.status = "completed";
+ emit(store, "run.status.changed", { status: "completed" });
+ }
+ });
+ cursor += isWriter ? 3600 : 1900;
+ }
+
+ // 无修订节点的方案:最后一个节点完成后收尾。
+ if (!rest.some((node) => node.id === "n-revise")) {
+ later(store, cursor + 400, () => {
+ if (store.run.status === "cancelled") return;
+ store.run.status = "completed";
+ store.session.status = "completed";
+ emit(store, "run.status.changed", { status: "completed" });
+ streamAssistant(
+ store,
+ aiMessage("协调者", "agent_run_coordinator", "", null),
+ "协作完成:全部节点通过质量门,正式报告 v1 已生成,可在上方报告卡中预览与导出。你可以继续提问、要求重写章节或润色。",
+ );
+ });
+ }
+}
+
+// ── Driver 实现 ──────────────────────────────────────────────────────────────
+
+function snapshotOf(store: MockStore) {
+ return {
+ session: { ...store.session },
+ messages: store.messages.map((message) => ({ ...message })),
+ plans: store.plans.map((plan) => ({ ...plan })),
+ run: { ...store.run },
+ nodes: Object.values(store.nodes).map((node) => ({ ...node })),
+ agent_runs: Object.values(store.agentRuns),
+ commands: Object.values(store.commands),
+ report_versions: store.reportVersions,
+ last_seq: store.seq,
+ };
+}
+
+function classifyCommand(text: string): { intent: CollaborationCommand["intent"]; confirmation: boolean } {
+ if (/重新检索|重新找|资料太旧/.test(text)) return { intent: "research_again", confirmation: true };
+ if (/重写/.test(text)) return { intent: "rewrite_section", confirmation: true };
+ if (/增加|新增/.test(text)) return { intent: "add_angle", confirmation: true };
+ if (/润色|语气|正式/.test(text)) return { intent: "polish_report", confirmation: false };
+ return { intent: "answer_question", confirmation: false };
+}
+
+const mockDriverImpl: ReportCollaborationDriver = {
+ async listSessions() {
+ return [...stores.values()]
+ .map((store) => ({ ...store.session }))
+ .sort((a, b) => b.updated_at.localeCompare(a.updated_at));
+ },
+
+ async getSnapshot(sessionId) {
+ const store = getStore(sessionId);
+ return store ? snapshotOf(store) : null;
+ },
+
+ async createSession(title) {
+ const sessionId = uid("session");
+ const runId = uid("run");
+ const store: MockStore = {
+ session: {
+ id: sessionId,
+ title: title || "报告协作",
+ status: "empty",
+ requirement_revision: 1,
+ selected_plan_id: null,
+ active_run_id: runId,
+ current_report_version_id: null,
+ created_at: now(),
+ updated_at: now(),
+ },
+ run: {
+ id: runId,
+ session_id: sessionId,
+ plan_id: "",
+ status: "queued",
+ cancel_requested: false,
+ next_event_seq: 0,
+ created_at: now(),
+ updated_at: now(),
+ },
+ messages: [],
+ events: [],
+ subscribers: new Set(),
+ seq: 0,
+ plans: [],
+ commands: [],
+ nodes: {},
+ agentRuns: {},
+ reportVersions: [],
+ timers: [],
+ artifactIds: [],
+ clarified: false,
+ versionCounter: 0,
+ };
+ stores.set(sessionId, store);
+ emit(store, "session.snapshot", { session: { ...store.session } });
+ return { ...store.session };
+ },
+
+ async updateSession(sessionId, patch) {
+ const store = getStore(sessionId);
+ if (!store) return null;
+ if (patch.title !== undefined) store.session.title = patch.title;
+ store.session.updated_at = now();
+ emit(store, "session.snapshot", { session: { ...store.session } });
+ return { ...store.session };
+ },
+
+ async deleteSession(sessionId) {
+ const store = getStore(sessionId);
+ if (store) {
+ for (const timer of store.timers) window.clearTimeout(timer);
+ stores.delete(sessionId);
+ }
+ },
+
+ async sendMessage(sessionId, payload, idempotencyKey) {
+ const store = getStore(sessionId);
+ if (!store) throw new Error("session not found");
+ const message = humanMessage(payload.text, idempotencyKey);
+ store.messages.push(message);
+ emit(store, "message.created", { message });
+ store.session.updated_at = now();
+
+ const status = store.session.status;
+ if (status === "empty") {
+ store.session.status = "clarifying";
+ later(store, 400, () => {
+ streamAssistant(store, aiMessage("协调者", "agent_run_coordinator"), CLARIFY_TEXT);
+ });
+ } else if (status === "clarifying") {
+ store.session.status = "planning";
+ emit(store, "session.snapshot", { session: { ...store.session } });
+ const stages = ["解析需求要点", "筛选可用智能体与工具", "装配三种策略的协作图", "校验计划可行性"];
+ stages.forEach((stage, index) => {
+ later(store, 300 + index * 450, () => {
+ emit(store, "plan.progress", { stage, detail: `${stage}……` });
+ });
+ });
+ later(store, 300 + stages.length * 450, () => {
+ store.plans = buildDemoPlans(store.session.requirement_revision);
+ emit(store, "plan.proposed", { plans: store.plans });
+ store.session.status = "proposal_ready";
+ emit(store, "session.snapshot", { session: { ...store.session } });
+ });
+ } else if (status === "proposal_ready") {
+ later(store, 300, () => {
+ streamAssistant(
+ store,
+ aiMessage("协调者", "agent_run_coordinator"),
+ "可以在方案卡中切换预览对应的协作流程;点「采用此方案」后,「开始协作」会正式创建执行。",
+ );
+ });
+ } else if (status === "completed") {
+ later(store, 400, () => {
+ streamAssistant(
+ store,
+ aiMessage("报告助手", null),
+ "基于当前报告版本的回答:本报告覆盖市场、竞争、海外与政策四个角度,结论均绑定核验后的证据。你可以继续追问,或要求重写某一章 / 全文润色。",
+ );
+ });
+ }
+ return message;
+ },
+
+ async requestPlans(sessionId, expectedRevision) {
+ const store = getStore(sessionId);
+ if (!store) throw new Error("session not found");
+ later(store, 600, () => {
+ store.plans = buildDemoPlans(expectedRevision ?? store.session.requirement_revision + 1);
+ emit(store, "plan.proposed", { plans: store.plans });
+ });
+ },
+
+ async selectPlan(sessionId, planId) {
+ const store = getStore(sessionId);
+ if (!store) throw new Error("session not found");
+ store.session.selected_plan_id = planId;
+ store.plans = store.plans.map((plan) => ({
+ ...plan,
+ status: plan.id === planId ? "selected" : "proposed",
+ }));
+ emit(store, "plan.selected", { plan_id: planId });
+ emit(store, "session.snapshot", { session: { ...store.session } });
+ },
+
+ async startRun(sessionId, payload) {
+ const store = getStore(sessionId);
+ if (!store) throw new Error("session not found");
+ const plan = store.plans.find((candidate) => candidate.id === payload.plan_id) ?? store.plans[0];
+ store.run.plan_id = plan.id;
+ store.run.status = "planning";
+ store.session.status = "running";
+ emit(store, "run.status.changed", { status: "planning", phase: "phase_prepare" });
+ later(store, 300, () => executePlan(store, plan));
+ return { ...store.run };
+ },
+
+ async getRun(runId) {
+ for (const store of stores.values()) {
+ if (store.run.id === runId) return { ...store.run };
+ }
+ return null;
+ },
+
+ async listRunEvents(runId, afterSeq) {
+ for (const store of stores.values()) {
+ if (store.run.id === runId) {
+ return store.events.filter((event) => event.seq > afterSeq);
+ }
+ }
+ return [];
+ },
+
+ openRunStream(runId, afterSeq, handlers) {
+ let store: MockStore | null = null;
+ for (const candidate of stores.values()) {
+ if (candidate.run.id === runId) store = candidate;
+ }
+ if (!store) {
+ handlers.onError?.("run not found");
+ return () => undefined;
+ }
+ const subscriber = (event: CollaborationEvent) => handlers.onEvent(event);
+ // 先重放历史(after_seq 之后),再接实时。
+ for (const event of store.events) {
+ if (event.seq > afterSeq) subscriber(event);
+ }
+ handlers.onOpen?.();
+ store.subscribers.add(subscriber);
+ return () => {
+ store?.subscribers.delete(subscriber);
+ };
+ },
+
+ async cancelRun(runId) {
+ for (const store of stores.values()) {
+ if (store.run.id !== runId) continue;
+ for (const timer of store.timers) window.clearTimeout(timer);
+ store.run.status = "cancelled";
+ store.session.status = "cancelled";
+ emit(store, "run.status.changed", { status: "cancelled", reason: "用户取消" });
+ return { ...store.run };
+ }
+ throw new Error("run not found");
+ },
+
+ async createCommand(runId, payload, idempotencyKey) {
+ const store = [...stores.values()].find((candidate) => candidate.run.id === runId);
+ if (!store) throw new Error("run not found");
+ const message = humanMessage(payload.text, idempotencyKey);
+ message.agent_run_id = payload.agent_run_id ?? null;
+ message.node_run_id = payload.target_node_id ?? null;
+ store.messages.push(message);
+ emit(store, "message.created", { message });
+
+ const { intent, confirmation } = classifyCommand(payload.text);
+ const command: CollaborationCommand = {
+ command_id: uid("cmd"),
+ idempotency_key: idempotencyKey,
+ text: payload.text,
+ intent,
+ confidence: 0.86,
+ target_type: payload.target_node_id ? "node" : "run",
+ target_ids: payload.target_node_id ? [payload.target_node_id] : [],
+ normalized_instruction: payload.text,
+ requires_confirmation: confirmation,
+ cost_level: confirmation ? "high" : "low",
+ reason: confirmation ? "该操作将触发重新检索/重写,影响下游成果" : "只读问答或轻量修改",
+ impact_nodes: payload.target_node_id ? [payload.target_node_id] : [],
+ status: "classified",
+ created_at: now(),
+ };
+ store.commands[command.command_id] = command;
+ emit(store, "command.classified", { command });
+
+ if (confirmation) {
+ later(store, 300, () => {
+ command.status = "confirmation_required";
+ emit(store, "command.confirmation.required", { command: { ...command } });
+ });
+ } else {
+ later(store, 300, () => {
+ command.status = "accepted";
+ emit(store, "command.accepted", { command_id: command.command_id, waiting_boundary: false });
+ later(store, 900, () => {
+ command.status = "completed";
+ emit(store, "command.completed", {
+ command_id: command.command_id,
+ result_summary: "已处理",
+ });
+ streamAssistant(
+ store,
+ aiMessage("协调者", "agent_run_coordinator"),
+ intent === "polish_report"
+ ? "已按「更正式的语气」生成新版报告草稿,确认后将作为 v2 生效。"
+ : "已解答。如需修改报告请直接说明章节与要求。",
+ );
+ });
+ });
+ }
+ return { ...command };
+ },
+
+ async confirmCommand(runId, commandId) {
+ const store = [...stores.values()].find((candidate) => candidate.run.id === runId);
+ if (!store) throw new Error("run not found");
+ const command = store.commands[commandId];
+ if (!command) throw new Error("command not found");
+ command.status = "accepted";
+ emit(store, "command.accepted", { command_id: commandId, waiting_boundary: false });
+
+ const targetNode = command.target_ids[0] ?? "n-research-market";
+ later(store, 400, () => {
+ emit(store, "node.attempt.created", { node_id: targetNode, attempt: 2, reason: command.text });
+ setNodeStatus(store, targetNode, "queued", 2);
+ });
+ later(store, 800, () => setNodeStatus(store, targetNode, "running", 2));
+ later(store, 1600, () => {
+ streamAssistant(
+ store,
+ aiMessage(store.nodes[targetNode]?.label ?? "研究员", `agent_run_${targetNode}`, "", `${targetNode}-attempt2`),
+ "已按新要求完成重做,本轮证据/文稿已重新提交质量门。",
+ );
+ });
+ later(store, 2400, () => {
+ setNodeStatus(store, targetNode, "completed", 2);
+ command.status = "completed";
+ emit(store, "command.completed", { command_id: commandId, result_summary: "目标节点已重跑" });
+ });
+ return { ...command };
+ },
+
+ async cancelCommand(runId, commandId) {
+ const store = [...stores.values()].find((candidate) => candidate.run.id === runId);
+ if (!store) throw new Error("run not found");
+ const command = store.commands[commandId];
+ if (!command) throw new Error("command not found");
+ command.status = "cancelled";
+ emit(store, "command.completed", { command_id: commandId, result_summary: "用户取消" });
+ return { ...command };
+ },
+
+ async getNodeContract(runId, nodeId) {
+ const store = [...stores.values()].find((candidate) => candidate.run.id === runId);
+ const planNode = store?.plans
+ .flatMap((plan) => plan.nodes.map((node) => ({ plan, node })))
+ .find((entry) => entry.node.id === nodeId);
+ const nodeState = store?.nodes[nodeId];
+ if (!store || (!nodeState && !planNode)) throw new Error("node not found");
+ return {
+ node_id: nodeId,
+ label: nodeState?.label ?? planNode?.node.label ?? nodeId,
+ role: nodeState?.role_display_name ?? planNode?.node.role_display_name ?? null,
+ angle: nodeState?.angle ?? planNode?.node.angle ?? null,
+ status: nodeState?.status ?? "planned",
+ attempt: nodeState?.attempt ?? 0,
+ mission: planNode?.node.mission ?? "(演示)按计划合同执行本节点职责。",
+ input_artifact_types: planNode?.node.input_artifact_types ?? [],
+ source_policy: "公开网络检索,优先近 90 天,权威来源优先",
+ output_artifact_type: planNode?.node.output_artifact_type ?? "EvidenceBundle",
+ output_structure: null,
+ acceptance_criteria: planNode?.node.acceptance_criteria ?? ["来源可溯源", "结论有证据支撑"],
+ allowed_tools: planNode?.node.allowed_tools ?? ["web_search"],
+ forbidden: ["直接发布报告", "修改已核验证据"],
+ time_budget_seconds: 300,
+ token_budget: 60000,
+ max_attempts: 3,
+ upstream: planNode?.node.depends_on ?? [],
+ downstream:
+ planNode?.plan.edges.filter((edge) => edge.source === nodeId).map((edge) => edge.target) ?? [],
+ };
+ },
+
+ async listReportVersions(sessionId) {
+ const store = getStore(sessionId);
+ return store ? [...store.reportVersions] : [];
+ },
+
+ async createRewrite(sessionId, payload) {
+ const store = getStore(sessionId);
+ if (!store) throw new Error("session not found");
+ return {
+ id: uid("rw"),
+ session_id: sessionId,
+ section: payload.section ?? null,
+ instruction: payload.instruction,
+ status: "proposed",
+ created_at: now(),
+ };
+ },
+
+ async applyRewrite(sessionId, rewriteId) {
+ const store = getStore(sessionId);
+ if (!store) throw new Error("session not found");
+ store.versionCounter += 1;
+ const parent = store.reportVersions.at(-1);
+ const version: ReportVersion = {
+ id: uid("ver"),
+ session_id: sessionId,
+ version: store.versionCounter,
+ parent_version_id: parent?.id ?? null,
+ change_type: "section_rewrite",
+ change_summary: `章节改写(${rewriteId})`,
+ markdown: `${parent?.markdown ?? DEMO_REPORT_MARKDOWN}\n\n`,
+ source_index: [],
+ created_at: now(),
+ is_head: true,
+ };
+ for (const old of store.reportVersions) old.is_head = false;
+ store.reportVersions.push(version);
+ store.session.current_report_version_id = version.id;
+ emit(store, "report.version.created", { version });
+ return version;
+ },
+
+ async restoreReportVersion(sessionId, versionId) {
+ const store = getStore(sessionId);
+ if (!store) throw new Error("session not found");
+ const source = store.reportVersions.find((version) => version.id === versionId);
+ if (!source) throw new Error("version not found");
+ store.versionCounter += 1;
+ const version: ReportVersion = {
+ ...source,
+ id: uid("ver"),
+ version: store.versionCounter,
+ parent_version_id: source.id,
+ change_type: "restore",
+ change_summary: `恢复自 v${source.version}`,
+ created_at: now(),
+ is_head: true,
+ };
+ for (const old of store.reportVersions) old.is_head = false;
+ store.reportVersions.push(version);
+ store.session.current_report_version_id = version.id;
+ emit(store, "report.version.created", { version });
+ return version;
+ },
+};
+
+export const mockDriver = mockDriverImpl;
diff --git a/frontend-web/src/report-collaboration/api/plans.ts b/frontend-web/src/report-collaboration/api/plans.ts
new file mode 100644
index 00000000..2f678241
--- /dev/null
+++ b/frontend-web/src/report-collaboration/api/plans.ts
@@ -0,0 +1,46 @@
+/**
+ * 候选计划:plan request / 查询 / 选定(`/sessions/{id}/plans*`)。
+ *
+ * 选择方案与开始执行是两个独立命令:select 只写 `selected_plan_id`,
+ * 不创建 run。
+ */
+
+import { newIdempotencyKey, rcGet, rcRequest } from "./client";
+import type { ReportPlanCandidate } from "./types";
+
+export async function requestPlans(
+ sessionId: string,
+ idempotencyKey = newIdempotencyKey(),
+ expectedRevision?: number | null,
+): Promise {
+ await rcRequest(`/sessions/${encodeURIComponent(sessionId)}/plan-requests`, {
+ method: "POST",
+ body: {},
+ idempotencyKey,
+ expectedRevision,
+ });
+}
+
+export async function listPlans(
+ sessionId: string,
+ signal?: AbortSignal,
+): Promise {
+ const data = await rcGet<{ plans: ReportPlanCandidate[] }>(
+ `/sessions/${encodeURIComponent(sessionId)}/plans`,
+ signal,
+ );
+ return data.plans ?? [];
+}
+
+/** 采用方案:409 表示方案 revision 过期或会话已选定,需刷新后重新确认。 */
+export async function selectPlan(
+ sessionId: string,
+ planId: string,
+ expectedRevision?: number | null,
+ idempotencyKey = newIdempotencyKey(),
+): Promise<{ selected_plan_id: string }> {
+ return rcRequest<{ selected_plan_id: string }>(
+ `/sessions/${encodeURIComponent(sessionId)}/plans/${encodeURIComponent(planId)}/select`,
+ { method: "POST", body: {}, idempotencyKey, expectedRevision },
+ ).then((res) => res as { selected_plan_id: string });
+}
diff --git a/frontend-web/src/report-collaboration/api/reports.ts b/frontend-web/src/report-collaboration/api/reports.ts
new file mode 100644
index 00000000..fb13a534
--- /dev/null
+++ b/frontend-web/src/report-collaboration/api/reports.ts
@@ -0,0 +1,52 @@
+/**
+ * 报告版本、章节改写候选与版本恢复(`/sessions/{id}/reports*`)。
+ */
+
+import { newIdempotencyKey, rcGet, rcRequest } from "./client";
+import type { CreateRewritePayload, ReportRewriteProposal, ReportVersion } from "./types";
+
+export async function listReportVersions(
+ sessionId: string,
+ signal?: AbortSignal,
+): Promise {
+ const data = await rcGet<{ versions: ReportVersion[] }>(
+ `/sessions/${encodeURIComponent(sessionId)}/reports`,
+ signal,
+ );
+ return data.versions ?? [];
+}
+
+/** 发起章节重写 / 润色:只生成 replacement candidate,不直接覆盖正式版本。 */
+export async function createRewrite(
+ sessionId: string,
+ payload: CreateRewritePayload,
+ idempotencyKey = newIdempotencyKey(),
+): Promise {
+ return rcRequest(
+ `/sessions/${encodeURIComponent(sessionId)}/report-rewrites`,
+ { method: "POST", body: payload, idempotencyKey },
+ ).then((res) => res as ReportRewriteProposal);
+}
+
+export async function applyRewrite(
+ sessionId: string,
+ rewriteId: string,
+ idempotencyKey = newIdempotencyKey(),
+): Promise {
+ return rcRequest(
+ `/sessions/${encodeURIComponent(sessionId)}/report-rewrites/${encodeURIComponent(rewriteId)}/apply`,
+ { method: "POST", body: {}, idempotencyKey },
+ ).then((res) => res as ReportVersion);
+}
+
+/** 版本恢复:创建新的头版本,不删除历史。 */
+export async function restoreReportVersion(
+ sessionId: string,
+ versionId: string,
+ idempotencyKey = newIdempotencyKey(),
+): Promise {
+ return rcRequest(
+ `/sessions/${encodeURIComponent(sessionId)}/report-versions/${encodeURIComponent(versionId)}/restore`,
+ { method: "POST", body: {}, idempotencyKey },
+ ).then((res) => res as ReportVersion);
+}
diff --git a/frontend-web/src/report-collaboration/api/runs.ts b/frontend-web/src/report-collaboration/api/runs.ts
new file mode 100644
index 00000000..30a5cbe4
--- /dev/null
+++ b/frontend-web/src/report-collaboration/api/runs.ts
@@ -0,0 +1,205 @@
+/**
+ * Run 执行与干预:查询、事件回放、SSE 流、命令、取消、节点合同。
+ */
+
+import { apiFetch } from "@/core/api/fetch-client";
+import { REPORT_COLLABORATION_BASE, newIdempotencyKey, rcGet, rcRequest } from "./client";
+import type {
+ CollaborationCommand,
+ CollaborationEvent,
+ CreateCommandPayload,
+ NodeContractDetail,
+ ReportRun,
+ StartRunPayload,
+} from "./types";
+
+export async function startRun(
+ sessionId: string,
+ payload: StartRunPayload,
+ expectedRevision?: number | null,
+ idempotencyKey = newIdempotencyKey(),
+): Promise {
+ return rcRequest(`/sessions/${encodeURIComponent(sessionId)}/runs`, {
+ method: "POST",
+ body: payload,
+ idempotencyKey,
+ expectedRevision,
+ }).then((res) => res as ReportRun);
+}
+
+export async function getRun(runId: string, signal?: AbortSignal): Promise {
+ return rcRequest(`/runs/${encodeURIComponent(runId)}`, {
+ method: "GET",
+ signal,
+ nullOn404: true,
+ });
+}
+
+/** 历史事件回放(断线补拉 / 序号缺口补齐)。 */
+export async function listRunEvents(
+ runId: string,
+ afterSeq: number,
+ signal?: AbortSignal,
+): Promise {
+ const data = await rcGet<{ events: CollaborationEvent[] }>(
+ `/runs/${encodeURIComponent(runId)}/events?after_seq=${Math.max(0, afterSeq)}`,
+ signal,
+ );
+ return data.events ?? [];
+}
+
+export async function cancelRun(
+ runId: string,
+ idempotencyKey = newIdempotencyKey(),
+): Promise {
+ return rcRequest(`/runs/${encodeURIComponent(runId)}/cancel`, {
+ method: "POST",
+ body: {},
+ idempotencyKey,
+ }).then((res) => res as ReportRun);
+}
+
+export async function getNodeContract(
+ runId: string,
+ nodeId: string,
+ signal?: AbortSignal,
+): Promise {
+ return rcGet(
+ `/runs/${encodeURIComponent(runId)}/nodes/${encodeURIComponent(nodeId)}/contract`,
+ signal,
+ );
+}
+
+// ── 用户命令(全过程干预)────────────────────────────────────────────────────
+
+export async function createCommand(
+ runId: string,
+ payload: CreateCommandPayload,
+ idempotencyKey = newIdempotencyKey(),
+): Promise {
+ return rcRequest(`/runs/${encodeURIComponent(runId)}/commands`, {
+ method: "POST",
+ body: payload,
+ idempotencyKey,
+ }).then((res) => res as CollaborationCommand);
+}
+
+export async function confirmCommand(
+ runId: string,
+ commandId: string,
+ idempotencyKey = newIdempotencyKey(),
+): Promise {
+ return rcRequest(
+ `/runs/${encodeURIComponent(runId)}/commands/${encodeURIComponent(commandId)}/confirm`,
+ { method: "POST", body: {}, idempotencyKey },
+ ).then((res) => res as CollaborationCommand);
+}
+
+export async function cancelCommand(
+ runId: string,
+ commandId: string,
+ idempotencyKey = newIdempotencyKey(),
+): Promise {
+ return rcRequest(
+ `/runs/${encodeURIComponent(runId)}/commands/${encodeURIComponent(commandId)}/cancel`,
+ { method: "POST",
+ body: {},
+ idempotencyKey },
+ ).then((res) => res as CollaborationCommand);
+}
+
+// ── SSE 订阅 ─────────────────────────────────────────────────────────────────
+
+export interface RunStreamHandlers {
+ onEvent: (event: CollaborationEvent) => void;
+ onOpen?: () => void;
+ /** 服务端正常关流(终态)。 */
+ onClose?: () => void;
+ /** 传输层异常(触发重连)。 */
+ onError?: (message: string) => void;
+}
+
+/**
+ * 订阅 run 的 durable SSE 流。支持 `after_seq` 与 `Last-Event-ID`。
+ *
+ * 返回 close 函数:幂等,调用后中断连接且不再触发回调。
+ * 注意:fetch 流式读取无法携带 EventSource 的自动重连,重连由
+ * useReportCollaborationStream 以指数退避驱动。
+ */
+export function openRunStream(
+ runId: string,
+ afterSeq: number,
+ handlers: RunStreamHandlers,
+): () => void {
+ const controller = new AbortController();
+ let closed = false;
+
+ const close = () => {
+ if (closed) return;
+ closed = true;
+ controller.abort();
+ };
+
+ void (async () => {
+ try {
+ const res = await apiFetch(
+ `${REPORT_COLLABORATION_BASE()}/runs/${encodeURIComponent(runId)}/stream?after_seq=${Math.max(0, afterSeq)}`,
+ { method: "GET", signal: controller.signal, headers: { Accept: "text/event-stream" } },
+ );
+ if (!res.ok || !res.body) {
+ if (!closed) handlers.onError?.(`stream failed (${res.status})`);
+ return;
+ }
+ handlers.onOpen?.();
+
+ const reader = res.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = "";
+ let dataLines: string[] = [];
+ let lastEventId: string | undefined;
+
+ const flushData = () => {
+ if (dataLines.length === 0) return;
+ const json = dataLines.join("\n").trim();
+ dataLines = [];
+ if (!json) return;
+ try {
+ const event = JSON.parse(json) as CollaborationEvent;
+ if (lastEventId) event.eventId = event.eventId || lastEventId;
+ handlers.onEvent(event);
+ } catch {
+ // 非 JSON 帧(如注释/心跳文本)忽略,不中断连接。
+ }
+ };
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ buffer += decoder.decode(value, { stream: true });
+ const lines = buffer.split("\n");
+ buffer = lines.pop() ?? "";
+ for (const raw of lines) {
+ const line = raw.replace(/\r$/, "");
+ if (line === "") {
+ flushData();
+ continue;
+ }
+ if (line.startsWith("id:")) {
+ lastEventId = line.slice(3).trim() || undefined;
+ } else if (line.startsWith("data:")) {
+ dataLines.push(line.slice(5).trimStart());
+ }
+ // `event:` / `:` 注释行不影响 data 解析。
+ }
+ }
+ flushData();
+ if (!closed) handlers.onClose?.();
+ } catch (err) {
+ if (!closed && !controller.signal.aborted) {
+ handlers.onError?.(err instanceof Error ? err.message : String(err));
+ }
+ }
+ })();
+
+ return close;
+}
diff --git a/frontend-web/src/report-collaboration/api/sessions.ts b/frontend-web/src/report-collaboration/api/sessions.ts
new file mode 100644
index 00000000..c8093e90
--- /dev/null
+++ b/frontend-web/src/report-collaboration/api/sessions.ts
@@ -0,0 +1,84 @@
+/**
+ * 会话 CRUD 与消息读写(`/api/report-collaboration/sessions`)。
+ */
+
+import { newIdempotencyKey, rcGet, rcRequest } from "./client";
+import type {
+ CollaborationMessage,
+ CreateSessionPayload,
+ ReportSession,
+ ReportSessionSummary,
+ SendMessagePayload,
+ SessionSnapshot,
+ UpdateSessionPayload,
+} from "./types";
+
+export async function listSessions(signal?: AbortSignal): Promise {
+ const data = await rcGet<{ sessions: ReportSessionSummary[] }>("/sessions", signal);
+ return data.sessions ?? [];
+}
+
+export async function getSessionSnapshot(
+ sessionId: string,
+ signal?: AbortSignal,
+): Promise {
+ return rcRequest(`/sessions/${encodeURIComponent(sessionId)}`, {
+ method: "GET",
+ signal,
+ nullOn404: true,
+ });
+}
+
+export async function createSession(
+ payload: CreateSessionPayload = {},
+ idempotencyKey = newIdempotencyKey(),
+): Promise {
+ return rcRequest("/sessions", {
+ method: "POST",
+ body: payload,
+ idempotencyKey,
+ }).then((res) => res as ReportSession);
+}
+
+export async function updateSession(
+ sessionId: string,
+ patch: UpdateSessionPayload,
+ expectedRevision?: number | null,
+ idempotencyKey = newIdempotencyKey(),
+): Promise {
+ return rcRequest(`/sessions/${encodeURIComponent(sessionId)}`, {
+ method: "PATCH",
+ body: patch,
+ idempotencyKey,
+ expectedRevision,
+ nullOn404: true,
+ });
+}
+
+export async function deleteSession(sessionId: string): Promise {
+ await rcRequest(`/sessions/${encodeURIComponent(sessionId)}`, {
+ method: "DELETE",
+ });
+}
+
+export async function listMessages(
+ sessionId: string,
+ signal?: AbortSignal,
+): Promise {
+ const data = await rcGet<{ messages: CollaborationMessage[] }>(
+ `/sessions/${encodeURIComponent(sessionId)}/messages`,
+ signal,
+ );
+ return data.messages ?? [];
+}
+
+export async function sendMessage(
+ sessionId: string,
+ payload: SendMessagePayload,
+ idempotencyKey = newIdempotencyKey(),
+): Promise {
+ return rcRequest(
+ `/sessions/${encodeURIComponent(sessionId)}/messages`,
+ { method: "POST", body: payload, idempotencyKey },
+ ).then((res) => res as CollaborationMessage);
+}
diff --git a/frontend-web/src/report-collaboration/api/types.ts b/frontend-web/src/report-collaboration/api/types.ts
new file mode 100644
index 00000000..3e03908c
--- /dev/null
+++ b/frontend-web/src/report-collaboration/api/types.ts
@@ -0,0 +1,699 @@
+/**
+ * 报告协作工作台(AgentScope 多智能体)—— 前后端共享领域类型。
+ *
+ * 契约来源:`docs/agentscope-多智能体报告协作工作台-后端实施计划.md` §5/§7。
+ * 字段与后端 Pydantic 合同保持 snake_case 一一对应;前端只在 selector/adapter
+ * 层做投影,不再二次推断状态。
+ *
+ * 后端尚未实现时本层即为冻结契约(RC-FE-004 前置条件),修改字段需同步两份计划文档。
+ */
+
+// ── 会话 ─────────────────────────────────────────────────────────────────────
+
+/** 会话状态机(后端 §6 sessions.status)。 */
+export type ReportSessionStatus =
+ | "empty"
+ | "clarifying"
+ | "planning"
+ | "proposal_ready"
+ | "running"
+ | "awaiting_input"
+ | "reviewing"
+ | "completed"
+ | "failed"
+ | "cancelled";
+
+export interface ReportSession {
+ id: string;
+ title: string;
+ status: ReportSessionStatus;
+ requirement_revision: number;
+ selected_plan_id: string | null;
+ active_run_id: string | null;
+ current_report_version_id: string | null;
+ created_at: string;
+ updated_at: string;
+}
+
+/** 会话列表条目(轻量,无 messages)。 */
+export type ReportSessionSummary = ReportSession;
+
+// ── 需求快照 ─────────────────────────────────────────────────────────────────
+
+export interface ReportRequirementSnapshot {
+ topic: string;
+ objective?: string | null;
+ audience?: string | null;
+ language?: string | null;
+ time_range?: string | null;
+ geography?: string | null;
+ required_angles: string[];
+ excluded_angles: string[];
+ source_constraints?: string | null;
+ report_structure_id?: string | null;
+ length_target?: string | null;
+ tone?: string | null;
+ deadline_hint?: string | null;
+ quality_priority?: string | null;
+ revision: number;
+}
+
+// ── 消息镜像(ConversationReplica)──────────────────────────────────────────
+
+export type CollaborationMessageRole = "human" | "ai" | "tool";
+
+/** ai 消息上的工具调用(args 流式期间为部分 JSON 字符串,完成后为对象)。 */
+export interface CollaborationToolCall {
+ id: string;
+ name: string;
+ args: string | Record;
+}
+
+/**
+ * 后端 `report_collaboration_messages` 行的镜像:用户可见的 assistant 文本、
+ * TeamSay、`tool_calls` 与 `tool` 返回都落在这同一张时间线上。
+ */
+export interface CollaborationMessage {
+ message_id: string;
+ role: CollaborationMessageRole;
+ content: string;
+ /** 成员显示名(TeamSay / 成员回复 / tool 名)。 */
+ name?: string | null;
+ tool_calls?: CollaborationToolCall[];
+ tool_call_id?: string | null;
+ /** 并行阶段稳定归属;协调者广播可为 null(共享上下文)。 */
+ agent_run_id?: string | null;
+ node_run_id?: string | null;
+ phase_id?: string | null;
+ visibility: "visible" | "internal";
+ metadata?: Record | null;
+ command_id?: string | null;
+ created_at: string;
+ /** null = 仍在流式输出。 */
+ completed_at?: string | null;
+}
+
+// ── 候选计划 ─────────────────────────────────────────────────────────────────
+
+export type ReportPlanStatus =
+ | "proposed"
+ | "selected"
+ | "superseded"
+ | "rejected"
+ | "executing"
+ | "completed";
+
+export type ReportCostLevel = "low" | "medium" | "high";
+
+export interface PlanRole {
+ key: string;
+ display_name: string;
+ responsibility: string;
+ angle?: string | null;
+}
+
+export interface QualityGate {
+ id: string;
+ label: string;
+ description?: string | null;
+}
+
+export interface PlanValidation {
+ ok: boolean;
+ errors: string[];
+}
+
+/** 计划图节点:节点合同的用户可见摘要(不含执行结果)。 */
+export interface PlanNode {
+ id: string;
+ label: string;
+ role_key?: string | null;
+ role_display_name?: string | null;
+ angle?: string | null;
+ depends_on: string[];
+ /** 合同摘要;完整合同走 `GET /runs/{run_id}/nodes/{node_id}/contract`。 */
+ mission?: string | null;
+ output_artifact_type?: string | null;
+ input_artifact_types?: string[];
+ allowed_tools?: string[];
+ acceptance_criteria?: string[];
+}
+
+export interface PlanEdge {
+ source: string;
+ target: string;
+ label?: string | null;
+}
+
+export interface ReportPlanCandidate {
+ id: string;
+ proposal_group_id: string;
+ title: string;
+ strategy: string;
+ summary: string;
+ rationale: string;
+ recommended?: boolean;
+ estimated_duration_seconds: number;
+ estimated_cost_level: ReportCostLevel;
+ requirement_revision: number;
+ roles: PlanRole[];
+ nodes: PlanNode[];
+ edges: PlanEdge[];
+ quality_gates: QualityGate[];
+ report_structure_id: string | null;
+ validation: PlanValidation;
+ revision: number;
+ status: ReportPlanStatus;
+}
+
+// ── 节点运行状态 ─────────────────────────────────────────────────────────────
+
+/** 后端 §5.5 状态机:completed 只能经 reopened 重新入队。 */
+export type CollaborationNodeStatus =
+ | "planned"
+ | "queued"
+ | "running"
+ | "validating"
+ | "completed"
+ | "failed"
+ | "awaiting_input"
+ | "cancel_requested"
+ | "cancelled"
+ | "superseded"
+ | "reopened"
+ | "interrupted";
+
+export interface NodeAwaitingInput {
+ reason: string;
+ question: string;
+ impact?: string[];
+}
+
+export interface NodeAttemptState {
+ attempt: number;
+ status: CollaborationNodeStatus;
+ started_at?: string | null;
+ ended_at?: string | null;
+ error?: string | null;
+}
+
+export interface CollaborationNodeState {
+ node_id: string;
+ label: string;
+ role_display_name?: string | null;
+ angle?: string | null;
+ status: CollaborationNodeStatus;
+ attempt: number;
+ progress?: string | null;
+ awaiting_input?: NodeAwaitingInput | null;
+ error?: string | null;
+ attempts: NodeAttemptState[];
+}
+
+// ── 智能体成员(并行阶段稳定身份)────────────────────────────────────────────
+
+export type AgentRunStatus =
+ | "pending"
+ | "running"
+ | "waiting_input"
+ | "completed"
+ | "failed"
+ | "cancelled";
+
+export interface AgentRun {
+ agent_run_id: string;
+ node_run_id?: string | null;
+ phase_id?: string | null;
+ role: string;
+ display_name: string;
+ avatar_key?: string | null;
+ status: AgentRunStatus;
+ started_at?: string | null;
+ ended_at?: string | null;
+}
+
+// ── Run ──────────────────────────────────────────────────────────────────────
+
+export type ReportRunStatus =
+ | "queued"
+ | "planning"
+ | "running"
+ | "awaiting_input"
+ | "reviewing"
+ | "completed"
+ | "failed"
+ | "cancelled"
+ | "recovering";
+
+export interface ReportRun {
+ id: string;
+ session_id: string;
+ plan_id: string;
+ status: ReportRunStatus;
+ cancel_requested: boolean;
+ next_event_seq: number;
+ current_phase?: string | null;
+ created_at: string;
+ updated_at: string;
+}
+
+// ── 用户命令(全过程干预)────────────────────────────────────────────────────
+
+export type CommandIntent =
+ | "answer_question"
+ | "update_requirements"
+ | "add_angle"
+ | "research_again"
+ | "reanalyze"
+ | "rerun_node"
+ | "rewrite_section"
+ | "polish_report"
+ | "ask_report"
+ | "replan"
+ | "cancel";
+
+export type CommandStatus =
+ | "pending"
+ | "classified"
+ | "confirmation_required"
+ | "accepted"
+ | "accepted_waiting_boundary"
+ | "executing"
+ | "completed"
+ | "failed"
+ | "cancelled"
+ | "rejected";
+
+export interface CollaborationCommand {
+ command_id: string;
+ idempotency_key: string;
+ text: string;
+ intent?: CommandIntent | null;
+ confidence?: number | null;
+ target_type?: string | null;
+ target_ids: string[];
+ normalized_instruction?: string | null;
+ requires_confirmation: boolean;
+ cost_level?: ReportCostLevel | null;
+ reason?: string | null;
+ impact_nodes?: string[];
+ status: CommandStatus;
+ expected_revision?: number | null;
+ error?: string | null;
+ created_at: string;
+ updated_at?: string | null;
+}
+
+// ── 成果物 / 来源 / 报告版本 ────────────────────────────────────────────────
+
+export type ArtifactValidationStatus =
+ | "pending"
+ | "validated"
+ | "rejected"
+ | "superseded";
+
+export interface CollaborationArtifact {
+ id: string;
+ run_id: string;
+ node_id: string;
+ attempt: number;
+ artifact_type: string;
+ schema_version: number;
+ validation_status: ArtifactValidationStatus;
+ /** 用户可见摘要(如「12 条来源,8 条近 90 天」);不含正文。 */
+ summary?: string | null;
+ created_at: string;
+}
+
+export interface ReportSource {
+ id: string;
+ url?: string | null;
+ title: string;
+ published_at?: string | null;
+ added_at: string;
+ agent_run_id?: string | null;
+}
+
+export type ReportChangeType =
+ | "initial"
+ | "section_rewrite"
+ | "polish"
+ | "restore"
+ | "revision";
+
+export interface ReportVersion {
+ id: string;
+ session_id: string;
+ version: number;
+ parent_version_id: string | null;
+ change_type: ReportChangeType;
+ change_summary?: string | null;
+ markdown: string;
+ source_index?: ReportSource[];
+ created_at: string;
+ is_head?: boolean;
+}
+
+export interface ReportRewriteProposal {
+ id: string;
+ session_id: string;
+ section?: string | null;
+ instruction: string;
+ status: "proposed" | "applied" | "dismissed";
+ markdown?: string | null;
+ created_at: string;
+}
+
+// ── 澄清 ─────────────────────────────────────────────────────────────────────
+
+export interface ClarificationRecord {
+ id: string;
+ questions: string[];
+ impact?: string[];
+ resolved: boolean;
+ created_at: string;
+}
+
+// ── SSE 事件协议 ─────────────────────────────────────────────────────────────
+
+export type CollaborationEventType =
+ // 会话与消息镜像
+ | "session.snapshot"
+ | "message.created"
+ | "message.delta"
+ | "message.completed"
+ | "tool_call.created"
+ | "tool_call.delta"
+ | "tool_call.completed"
+ | "tool_result.created"
+ | "tool_result.delta"
+ | "tool_result.completed"
+ | "team_message.created"
+ | "team_message.delta"
+ | "team_message.completed"
+ // 需求与澄清
+ | "requirement.updated"
+ | "clarification.requested"
+ | "clarification.resolved"
+ // 计划
+ | "plan.progress"
+ | "plan.proposed"
+ | "plan.selected"
+ // Run / 节点
+ | "run.status.changed"
+ | "node.created"
+ | "node.status.changed"
+ | "node.progress"
+ | "node.attempt.created"
+ // 成果与来源
+ | "artifact.created"
+ | "artifact.validated"
+ | "artifact.rejected"
+ | "source.added"
+ // 命令
+ | "command.classified"
+ | "command.confirmation.required"
+ | "command.accepted"
+ | "command.completed"
+ | "command.failed"
+ // 报告与评审
+ | "report.delta"
+ | "report.version.created"
+ | "review.created"
+ // 其他
+ | "error"
+ | "heartbeat";
+
+export interface CollaborationEvent {
+ eventId: string;
+ sessionId: string;
+ runId?: string | null;
+ seq: number;
+ type: CollaborationEventType;
+ timestamp: string;
+ data: T;
+}
+
+// ── 事件 payload ─────────────────────────────────────────────────────────────
+
+export interface MessageEventPayload {
+ message: CollaborationMessage;
+}
+
+export interface MessageDeltaPayload {
+ message_id: string;
+ agent_run_id?: string | null;
+ node_run_id?: string | null;
+ delta: string;
+}
+
+export interface MessageCompletedPayload {
+ message_id: string;
+ agent_run_id?: string | null;
+ content?: string;
+ completed_at?: string;
+}
+
+export interface ToolCallCreatedPayload {
+ message_id: string;
+ agent_run_id?: string | null;
+ tool_call: CollaborationToolCall;
+}
+
+export interface ToolCallDeltaPayload {
+ message_id: string;
+ tool_call_id: string;
+ args_delta: string;
+}
+
+export interface ToolCallCompletedPayload {
+ message_id: string;
+ tool_call: CollaborationToolCall;
+}
+
+export interface ToolResultCreatedPayload {
+ /** 工具返回作为独立 tool 消息的稳定 id(缺省时前端以 `tool:{tool_call_id}` 派生)。 */
+ message_id?: string;
+ tool_call_id: string;
+ name: string;
+ agent_run_id?: string | null;
+ node_run_id?: string | null;
+ phase_id?: string | null;
+ content?: string;
+}
+
+export interface ToolResultDeltaPayload {
+ message_id?: string;
+ tool_call_id: string;
+ delta: string;
+}
+
+export interface ToolResultCompletedPayload {
+ message_id?: string;
+ tool_call_id: string;
+ content?: string;
+}
+
+export interface RequirementUpdatedPayload {
+ requirement: ReportRequirementSnapshot;
+}
+
+export interface ClarificationRequestedPayload {
+ clarification: ClarificationRecord;
+ /** 同时镜像为 ask_clarification tool 消息(由后端 ConversationReplica 发出)。 */
+ message?: CollaborationMessage;
+}
+
+export interface ClarificationResolvedPayload {
+ clarification_id: string;
+}
+
+export interface PlanProgressPayload {
+ stage: string;
+ detail?: string | null;
+}
+
+export interface PlanProposedPayload {
+ plans: ReportPlanCandidate[];
+}
+
+export interface PlanSelectedPayload {
+ plan_id: string;
+}
+
+export interface RunStatusChangedPayload {
+ status: ReportRunStatus;
+ phase?: string | null;
+ reason?: string | null;
+}
+
+export interface NodeCreatedPayload {
+ node: CollaborationNodeState;
+}
+
+export interface NodeStatusChangedPayload {
+ node_id: string;
+ status: CollaborationNodeStatus;
+ attempt: number;
+ awaiting_input?: NodeAwaitingInput | null;
+ error?: string | null;
+}
+
+export interface NodeProgressPayload {
+ node_id: string;
+ progress: string;
+}
+
+export interface NodeAttemptCreatedPayload {
+ node_id: string;
+ attempt: number;
+ reason?: string | null;
+}
+
+export interface ArtifactCreatedPayload {
+ artifact: CollaborationArtifact;
+}
+
+export interface ArtifactValidatedPayload {
+ artifact_id: string;
+}
+
+export interface ArtifactRejectedPayload {
+ artifact_id: string;
+ reasons: string[];
+}
+
+export interface SourceAddedPayload {
+ source: ReportSource;
+}
+
+export interface CommandClassifiedPayload {
+ command: CollaborationCommand;
+}
+
+export interface CommandConfirmationRequiredPayload {
+ command: CollaborationCommand;
+}
+
+export interface CommandAcceptedPayload {
+ command_id: string;
+ waiting_boundary: boolean;
+ impact_nodes?: string[];
+}
+
+export interface CommandCompletedPayload {
+ command_id: string;
+ result_summary?: string | null;
+ report_version_id?: string | null;
+}
+
+export interface CommandFailedPayload {
+ command_id: string;
+ error: string;
+}
+
+export interface ReportDeltaPayload {
+ draft_id?: string | null;
+ delta: string;
+}
+
+export interface ReportVersionCreatedPayload {
+ version: ReportVersion;
+}
+
+export interface ReviewCreatedPayload {
+ review: {
+ reviewer: string;
+ verdict: "pass" | "revise";
+ issues: { severity: string; target: string; requirement: string }[];
+ };
+}
+
+export interface ErrorEventPayload {
+ message: string;
+ recover_hint?: string | null;
+ node_id?: string | null;
+}
+
+export interface HeartbeatPayload {
+ ts?: string;
+}
+
+// ── 快照 / 请求体 ────────────────────────────────────────────────────────────
+
+/** `GET /sessions/{id}` 返回的完整快照:前端唯一事实源投影。 */
+export interface SessionSnapshot {
+ session: ReportSession;
+ messages: CollaborationMessage[];
+ plans: ReportPlanCandidate[];
+ run: ReportRun | null;
+ nodes: CollaborationNodeState[];
+ agent_runs: AgentRun[];
+ commands: CollaborationCommand[];
+ report_versions: ReportVersion[];
+ /** 已持久化事件的最大 seq;SSE 以 after_seq=last_seq 续接。 */
+ last_seq: number;
+}
+
+export interface CreateSessionPayload {
+ title?: string;
+}
+
+export interface UpdateSessionPayload {
+ title?: string;
+}
+
+export interface SendMessagePayload {
+ text: string;
+ /** 从节点抽屉 / 并行选择器带出的目标上下文。 */
+ target_node_id?: string | null;
+ agent_run_id?: string | null;
+}
+
+export interface StartRunPayload {
+ plan_id: string;
+}
+
+export interface CreateCommandPayload {
+ text: string;
+ target_node_id?: string | null;
+ agent_run_id?: string | null;
+}
+
+export interface CreateRewritePayload {
+ instruction: string;
+ section?: string | null;
+}
+
+/** 统一错误模型(client 层抛出的 ApiError 携带这些字段)。 */
+export interface ApiErrorDetail {
+ code?: string;
+ message: string;
+ detail?: unknown;
+}
+
+/**
+ * `GET /runs/{run_id}/nodes/{node_id}/contract` 返回的节点合同详情。
+ * 只含职责与输入输出合同,绝不含执行结果正文(后端 §7.1)。
+ */
+export interface NodeContractDetail {
+ node_id: string;
+ label: string;
+ role: string | null;
+ angle: string | null;
+ status: CollaborationNodeStatus;
+ attempt: number;
+ mission: string;
+ input_artifact_types: string[];
+ source_policy?: string | null;
+ output_artifact_type: string;
+ output_structure?: string | null;
+ acceptance_criteria: string[];
+ allowed_tools: string[];
+ forbidden?: string[];
+ time_budget_seconds?: number | null;
+ token_budget?: number | null;
+ max_attempts: number;
+ upstream: string[];
+ downstream: string[];
+}
diff --git a/frontend-web/src/report-collaboration/components/AgentStageSelector.tsx b/frontend-web/src/report-collaboration/components/AgentStageSelector.tsx
new file mode 100644
index 00000000..0c5f9b47
--- /dev/null
+++ b/frontend-web/src/report-collaboration/components/AgentStageSelector.tsx
@@ -0,0 +1,103 @@
+"use client";
+
+import { memo } from "react";
+import {
+ AlertTriangle,
+ Check,
+ Circle,
+ Loader2,
+ MessagesSquare,
+ X,
+} from "lucide-react";
+
+import type { AgentRun } from "@/report-collaboration/api/types";
+import { cn } from "@/lib/utils";
+
+interface AgentStageSelectorProps {
+ /** 并行阶段标签(协调者首位);空数组时不渲染。 */
+ tabs: AgentRun[];
+ phaseLabel?: string | null;
+ focusedAgentRunId: string | null;
+ onFocus: (agentRunId: string | null) => void;
+ /** 正在流式输出的成员。 */
+ streamingAgentRunIds: Set;
+ /** 请求用户协助(待回复)的成员。 */
+ awaitingAgentRunIds: Set;
+}
+
+function statusIcon(agentRun: AgentRun, streaming: boolean, awaiting: boolean) {
+ if (awaiting) {
+ return ;
+ }
+ if (agentRun.status === "completed") {
+ return ;
+ }
+ if (agentRun.status === "failed" || agentRun.status === "cancelled") {
+ return ;
+ }
+ if (agentRun.status === "running" || streaming) {
+ return ;
+ }
+ return ;
+}
+
+function statusText(agentRun: AgentRun, streaming: boolean, awaiting: boolean): string {
+ if (awaiting) return "待回复";
+ if (agentRun.status === "completed") return "已完成";
+ if (agentRun.status === "failed") return "失败";
+ if (agentRun.status === "cancelled") return "已取消";
+ if (agentRun.status === "running" || streaming) return "进行中";
+ return "排队中";
+}
+
+/**
+ * 并行阶段成员选择器:只切换同一消息流的聚焦范围,不创建第二套执行视图。
+ * 用户手动切换后保持选择;未聚焦成员的事件继续接收并持久更新。
+ */
+export const AgentStageSelector = memo(function AgentStageSelector({
+ tabs,
+ phaseLabel,
+ focusedAgentRunId,
+ onFocus,
+ streamingAgentRunIds,
+ awaitingAgentRunIds,
+}: AgentStageSelectorProps) {
+ if (tabs.length === 0) return null;
+ const awaitingCount = tabs.filter((tab) => awaitingAgentRunIds.has(tab.agent_run_id)).length;
+ const doneCount = tabs.filter((tab) => tab.status === "completed").length;
+
+ return (
+
+
+
+ {phaseLabel ?? "并行协作"}
+
+ {doneCount}/{tabs.length}
+
+ {awaitingCount > 0 && {awaitingCount}}
+
+ {tabs.map((tab) => {
+ const streaming = streamingAgentRunIds.has(tab.agent_run_id);
+ const awaiting = awaitingAgentRunIds.has(tab.agent_run_id);
+ const active = (focusedAgentRunId ?? tabs[0]?.agent_run_id) === tab.agent_run_id;
+ return (
+
+ );
+ })}
+
+ );
+});
diff --git a/frontend-web/src/report-collaboration/components/ConnectionStatusBar.tsx b/frontend-web/src/report-collaboration/components/ConnectionStatusBar.tsx
new file mode 100644
index 00000000..19fb7fef
--- /dev/null
+++ b/frontend-web/src/report-collaboration/components/ConnectionStatusBar.tsx
@@ -0,0 +1,41 @@
+"use client";
+
+import { useI18n } from "@/core/i18n/hooks";
+
+import type { RcConnectionStatus } from "@/report-collaboration/hooks/useReportCollaborationStream";
+
+const STATUS_TEXT: Record = {
+ idle: "未连接",
+ connecting: "连接中…",
+ live: "实时",
+ reconnecting: "重连中…",
+ closed: "已关闭",
+ error: "连接异常",
+};
+
+/** 底部连接状态栏:连接态 + run + 事件游标(aria-live 播报)。 */
+export function ConnectionStatusBar({
+ status,
+ runId,
+ lastSeq,
+ message,
+}: {
+ status: RcConnectionStatus;
+ runId: string | null;
+ lastSeq: number;
+ message?: string;
+}) {
+ const { t } = useI18n();
+ void t;
+ return (
+
+ );
+}
diff --git a/frontend-web/src/report-collaboration/components/InterventionCard.tsx b/frontend-web/src/report-collaboration/components/InterventionCard.tsx
new file mode 100644
index 00000000..315cf3dc
--- /dev/null
+++ b/frontend-web/src/report-collaboration/components/InterventionCard.tsx
@@ -0,0 +1,73 @@
+"use client";
+
+import { AlertTriangle, Check, Loader2, X } from "lucide-react";
+
+import type { CollaborationCommand } from "@/report-collaboration/api/types";
+
+const INTENT_LABELS: Record = {
+ answer_question: "只读问答",
+ update_requirements: "更新需求",
+ add_angle: "新增分析角度",
+ research_again: "重新检索",
+ reanalyze: "重新分析",
+ rerun_node: "重跑节点",
+ rewrite_section: "重写章节",
+ polish_report: "全文润色",
+ ask_report: "报告问答",
+ replan: "重新规划",
+ cancel: "取消",
+};
+
+interface InterventionCardProps {
+ command: CollaborationCommand;
+ busy: boolean;
+ onConfirm: (commandId: string) => void;
+ onCancel: (commandId: string) => void;
+}
+
+/**
+ * 命令确认卡:高成本 / 低置信度命令需要用户显式确认。
+ * 确认/取消都只是提交命令,不改变节点完成状态(状态只由后端状态机驱动)。
+ */
+export function InterventionCard({ command, busy, onConfirm, onCancel }: InterventionCardProps) {
+ return (
+
+
+
+
+
需要确认:{INTENT_LABELS[command.intent ?? ""] ?? "修改操作"}
+
+ 「{command.text}」
+
+
+
+
+ -
+ 影响范围:
+ {command.impact_nodes && command.impact_nodes.length > 0
+ ? command.impact_nodes.join("、") + " 及其下游"
+ : "由协调者判定"}
+
+ -
+ 成本:{command.cost_level === "high" ? "高(触发重新检索/重写)" : command.cost_level === "low" ? "低" : "中"}
+
+ {command.reason ? - 理由:{command.reason}
: null}
+
+
+
+
+
+
+ );
+}
diff --git a/frontend-web/src/report-collaboration/components/NodeInspectorDrawer.tsx b/frontend-web/src/report-collaboration/components/NodeInspectorDrawer.tsx
new file mode 100644
index 00000000..068e5aa5
--- /dev/null
+++ b/frontend-web/src/report-collaboration/components/NodeInspectorDrawer.tsx
@@ -0,0 +1,207 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { ArrowDown, ArrowUp, Ban, Hammer, Target, X } from "lucide-react";
+
+import { getReportCollaborationDriver } from "@/report-collaboration/api/driver";
+import type { NodeContractDetail } from "@/report-collaboration/api/types";
+
+interface NodeInspectorDrawerProps {
+ open: boolean;
+ nodeId: string | null;
+ runId: string | null;
+ /** 节点基础信息(画布侧已有,用于头部展示,不发起结果请求)。 */
+ fallback?: { label: string; status: string } | null;
+ onClose: () => void;
+ /** 「在对话中对此节点提出意见」:关闭抽屉 + 聚焦 Composer + 绑定目标。 */
+ onComment: (nodeId: string, nodeName: string) => void;
+}
+
+/**
+ * 只读节点说明抽屉:覆盖在画布右侧,不占第三栏。
+ * 只读取节点合同(职责/输入/产出/工具/验收),不请求节点执行结果;
+ * 执行过程统一在左侧 MessageList 查看。
+ */
+export function NodeInspectorDrawer({
+ open,
+ nodeId,
+ runId,
+ fallback,
+ onClose,
+ onComment,
+}: NodeInspectorDrawerProps) {
+ const [contract, setContract] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ if (!open || !nodeId || !runId) {
+ setContract(null);
+ setError(null);
+ return;
+ }
+ let cancelled = false;
+ setLoading(true);
+ setError(null);
+ getReportCollaborationDriver()
+ .getNodeContract(runId, nodeId)
+ .then((detail) => {
+ if (!cancelled) setContract(detail);
+ })
+ .catch((err: unknown) => {
+ if (!cancelled) setError(err instanceof Error ? err.message : String(err));
+ })
+ .finally(() => {
+ if (!cancelled) setLoading(false);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [open, nodeId, runId]);
+
+ if (!open || !nodeId) return null;
+
+ const label = contract?.label ?? fallback?.label ?? nodeId;
+ const status = contract?.status ?? fallback?.status ?? "planned";
+
+ return (
+ <>
+
+
+ >
+ );
+}
+
+function statusText(status: string): string {
+ const map: Record = {
+ planned: "规划中",
+ queued: "排队中",
+ running: "运行中",
+ validating: "校验中",
+ completed: "已完成",
+ failed: "失败",
+ awaiting_input: "等待用户",
+ cancel_requested: "取消中",
+ cancelled: "已取消",
+ superseded: "已被替代",
+ reopened: "已重开",
+ interrupted: "已中断",
+ };
+ return map[status] ?? status;
+}
diff --git a/frontend-web/src/report-collaboration/components/ReportCollaborationHeader.tsx b/frontend-web/src/report-collaboration/components/ReportCollaborationHeader.tsx
new file mode 100644
index 00000000..0d2a5567
--- /dev/null
+++ b/frontend-web/src/report-collaboration/components/ReportCollaborationHeader.tsx
@@ -0,0 +1,77 @@
+"use client";
+
+import { ArrowLeft, FileText, MessageSquarePlus, Square } from "lucide-react";
+
+import type { PagePhase } from "@/report-collaboration/state/selectors";
+
+const PHASE_BADGES: Record = {
+ empty: { label: "新会话", color: "var(--rc-text-secondary)", bg: "transparent" },
+ clarifying: { label: "澄清需求", color: "var(--rc-waiting)", bg: "rgba(217, 119, 6, 0.1)" },
+ planning: { label: "生成方案", color: "var(--rc-running)", bg: "rgba(37, 99, 235, 0.1)" },
+ proposal_ready: { label: "待确认方案", color: "var(--rc-accent)", bg: "rgb(var(--rc-accent-rgb) / 10%)" },
+ running: { label: "协作中", color: "var(--rc-running)", bg: "rgba(37, 99, 235, 0.1)" },
+ awaiting_input: { label: "等待输入", color: "var(--rc-waiting)", bg: "rgba(217, 119, 6, 0.1)" },
+ reviewing: { label: "审稿中", color: "var(--rc-running)", bg: "rgba(37, 99, 235, 0.1)" },
+ completed: { label: "已完成", color: "var(--rc-success)", bg: "rgba(22, 163, 74, 0.1)" },
+ failed: { label: "失败", color: "var(--rc-danger)", bg: "rgba(220, 38, 38, 0.1)" },
+ cancelled: { label: "已取消", color: "var(--rc-text-secondary)", bg: "transparent" },
+};
+
+interface ReportCollaborationHeaderProps {
+ title: string;
+ phase: PagePhase;
+ onBack: () => void;
+ onNewSession: () => void;
+ onOpenReport: () => void;
+ hasReport: boolean;
+ running: boolean;
+ onCancelRun: () => void;
+ extra?: React.ReactNode;
+}
+
+/** 顶栏:返回 / 标题+状态 / 报告 / 停止 / 新建(右侧由布局注入窄屏切换等)。 */
+export function ReportCollaborationHeader({
+ title,
+ phase,
+ onBack,
+ onNewSession,
+ onOpenReport,
+ hasReport,
+ running,
+ onCancelRun,
+ extra,
+}: ReportCollaborationHeaderProps) {
+ const badge = PHASE_BADGES[phase] ?? PHASE_BADGES.empty;
+ return (
+
+ );
+}
diff --git a/frontend-web/src/report-collaboration/components/ReportCollaborationLayout.tsx b/frontend-web/src/report-collaboration/components/ReportCollaborationLayout.tsx
new file mode 100644
index 00000000..9d5d1888
--- /dev/null
+++ b/frontend-web/src/report-collaboration/components/ReportCollaborationLayout.tsx
@@ -0,0 +1,166 @@
+"use client";
+
+import { useCallback, useRef } from "react";
+import { MessageSquare, Workflow } from "lucide-react";
+
+import {
+ CANVAS_MIN_WIDTH,
+ PANEL_MIN_WIDTH,
+ RESIZE_KEYBOARD_STEP,
+ type FocusMode,
+ type NarrowView,
+ type UseReportPanelLayoutResult,
+} from "@/report-collaboration/hooks/useReportPanelLayout";
+
+interface ReportCollaborationLayoutProps {
+ layout: UseReportPanelLayoutResult;
+ header: React.ReactNode;
+ conversation: React.ReactNode;
+ canvas: React.ReactNode;
+ statusBar: React.ReactNode;
+}
+
+/**
+ * 固定两栏骨架:左对话 / 右画布 + 可拖拽分隔条(pointer + 方向键 + Home/End)
+ * + 窄屏单面板切换 + 「专注」模式。节点抽屉与预览 Dialog 由画布侧覆盖渲染。
+ */
+export function ReportCollaborationLayout({
+ layout,
+ header,
+ conversation,
+ canvas,
+ statusBar,
+}: ReportCollaborationLayoutProps) {
+ const { conversationWidth, setConversationWidth, nudgeConversationWidth, isNarrow, narrowView, setNarrowView, focusMode, bodyRef } =
+ layout;
+
+ const dragStart = useRef<{ x: number; width: number } | null>(null);
+
+ const onPointerDown = useCallback(
+ (event: React.PointerEvent) => {
+ event.currentTarget.setPointerCapture(event.pointerId);
+ dragStart.current = { x: event.clientX, width: conversationWidth };
+ },
+ [conversationWidth],
+ );
+
+ const onPointerMove = useCallback(
+ (event: React.PointerEvent) => {
+ if (!dragStart.current || event.buttons !== 1) return;
+ setConversationWidth(dragStart.current.width + (event.clientX - dragStart.current.x));
+ },
+ [setConversationWidth],
+ );
+
+ const onPointerUp = useCallback((event: React.PointerEvent) => {
+ if (event.currentTarget.hasPointerCapture(event.pointerId)) {
+ event.currentTarget.releasePointerCapture(event.pointerId);
+ }
+ dragStart.current = null;
+ }, []);
+
+ const onKeyDown = useCallback(
+ (event: React.KeyboardEvent) => {
+ const max = Math.max(
+ PANEL_MIN_WIDTH,
+ Math.min(720, (bodyRef.current?.clientWidth ?? 720) - CANVAS_MIN_WIDTH),
+ );
+ if (event.key === "ArrowLeft") {
+ event.preventDefault();
+ nudgeConversationWidth(-RESIZE_KEYBOARD_STEP);
+ } else if (event.key === "ArrowRight") {
+ event.preventDefault();
+ nudgeConversationWidth(RESIZE_KEYBOARD_STEP);
+ } else if (event.key === "Home") {
+ event.preventDefault();
+ setConversationWidth(PANEL_MIN_WIDTH);
+ } else if (event.key === "End") {
+ event.preventDefault();
+ setConversationWidth(max);
+ }
+ },
+ [bodyRef, nudgeConversationWidth, setConversationWidth],
+ );
+
+ const showConversation = isNarrow ? narrowView === "conversation" : focusMode !== "canvas";
+ const showCanvas = isNarrow ? narrowView === "canvas" : focusMode !== "conversation";
+
+ return (
+
+ {header}
+
+ {showConversation ? (
+
+ ) : null}
+ {!isNarrow && showConversation && showCanvas ? (
+
+ ) : null}
+ {showCanvas ? (
+
+ {isNarrow ? (
+
+
+
+ ) : null}
+ {canvas}
+
+ ) : null}
+
+ {statusBar}
+
+ );
+}
+
+export function NarrowSwitch({
+ narrowView,
+ setNarrowView,
+}: {
+ narrowView: NarrowView;
+ setNarrowView: (view: NarrowView) => void;
+}) {
+ return (
+
+
+
+
+ );
+}
+
+export type { FocusMode };
diff --git a/frontend-web/src/report-collaboration/components/ReportComposer.tsx b/frontend-web/src/report-collaboration/components/ReportComposer.tsx
new file mode 100644
index 00000000..b4badd95
--- /dev/null
+++ b/frontend-web/src/report-collaboration/components/ReportComposer.tsx
@@ -0,0 +1,154 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import { CornerDownLeft, Loader2, Send, Target, X } from "lucide-react";
+
+import type { PagePhase } from "@/report-collaboration/state/selectors";
+
+export interface ComposerTarget {
+ nodeId: string;
+ nodeName: string;
+ agentRunId?: string | null;
+}
+
+interface ReportComposerProps {
+ phase: PagePhase;
+ busy: boolean;
+ disabled?: boolean;
+ target: ComposerTarget | null;
+ onClearTarget: () => void;
+ onSend: (text: string) => void;
+ /** 递增以请求聚焦(节点抽屉「在对话中提出意见」)。 */
+ focusSignal: number;
+}
+
+const PHASE_HINTS: Record = {
+ empty: {
+ placeholder: "描述你的报告需求,例如:撰写一份新能源汽车主要品牌市场趋势分析报告…",
+ hint: "输入需求后回车发送",
+ },
+ clarifying: {
+ placeholder: "回答澄清问题,或补充你的要求…",
+ hint: "优先回答上方的澄清问题",
+ tone: "warning",
+ },
+ planning: {
+ placeholder: "补充要求(将在规划安全点生效)…",
+ hint: "正在生成候选方案,补充要求会在安全点应用",
+ },
+ proposal_ready: {
+ placeholder: "可以先预览方案;确认后点「开始协作」…",
+ hint: "点击方案卡可在右侧预览对应协作流程",
+ },
+ running: {
+ placeholder: "运行中可补充要求:增加角度 / 重新检索 / 重写章节…",
+ hint: "协调者正在跟踪运行,你的输入会被分类并在安全点生效",
+ },
+ awaiting_input: {
+ placeholder: "回答等待中的问题…",
+ hint: "有节点正在等待你的输入,优先处理等待卡",
+ tone: "warning",
+ },
+ reviewing: {
+ placeholder: "审稿进行中,可补充要求…",
+ hint: "审稿意见会出现在时间线中",
+ },
+ completed: {
+ placeholder: "报告已完成:继续问答、重写章节、全文润色…",
+ hint: "修改会生成新版本,不直接覆盖正式报告",
+ },
+ failed: {
+ placeholder: "通过对话重试,或取消后从已有成果继续…",
+ hint: "运行失败,可在对话中要求重试",
+ tone: "danger",
+ },
+ cancelled: {
+ placeholder: "可从已有成果继续,或生成新方案…",
+ hint: "运行已取消,已完成的成果仍然可用",
+ },
+};
+
+/** 统一输入框:按页面状态提示;Enter 发送 / Shift+Enter 换行。 */
+export function ReportComposer({
+ phase,
+ busy,
+ disabled = false,
+ target,
+ onClearTarget,
+ onSend,
+ focusSignal,
+}: ReportComposerProps) {
+ const [value, setValue] = useState("");
+ const textareaRef = useRef(null);
+ const hint = PHASE_HINTS[phase] ?? PHASE_HINTS.empty;
+
+ useEffect(() => {
+ if (focusSignal > 0) {
+ textareaRef.current?.focus();
+ }
+ }, [focusSignal]);
+
+ const submit = () => {
+ const trimmed = value.trim();
+ if (!trimmed || busy || disabled) return;
+ onSend(trimmed);
+ setValue("");
+ };
+
+ return (
+
+
+ {busy ? : null}
+ {busy ? "正在识别修改范围…" : hint.hint}
+
+ {target ? (
+
+
+
+ 对节点「{target.nodeName}」提出意见
+
+
+
+ ) : null}
+
+
+
+
+ Enter 发送 · Shift+Enter 换行
+
+
+ );
+}
diff --git a/frontend-web/src/report-collaboration/components/ReportConversationPanel.tsx b/frontend-web/src/report-collaboration/components/ReportConversationPanel.tsx
new file mode 100644
index 00000000..56dd51ae
--- /dev/null
+++ b/frontend-web/src/report-collaboration/components/ReportConversationPanel.tsx
@@ -0,0 +1,76 @@
+"use client";
+
+import { useMemo } from "react";
+import { MessagesSquare } from "lucide-react";
+import type { Message } from "@langchain/langgraph-sdk";
+
+import { MessageList } from "@/components/workspace/messages/message-list";
+import { ThreadContext } from "@/components/workspace/messages/context";
+import { ArtifactsProvider } from "@/components/workspace/artifacts";
+import { SubtasksProvider } from "@/core/tasks/context";
+import type { AgentThreadState } from "@/core/threads";
+import type { BaseStream } from "@langchain/langgraph-sdk/react";
+
+interface ReportConversationPanelProps {
+ sessionId: string;
+ thread: BaseStream;
+ businessCardSlot: (message: Message) => React.ReactNode;
+ stageSelector?: React.ReactNode;
+ composer: React.ReactNode;
+ loading: boolean;
+ emptyHint?: React.ReactNode;
+}
+
+/**
+ * 左侧对话面板:外层结构参考 Coze,内部严格复用 DeerFlow `MessageList`
+ * (原生 assistant / tool_calls / tool 消息、引用与协助卡渲染)。
+ */
+export function ReportConversationPanel({
+ sessionId,
+ thread,
+ businessCardSlot,
+ stageSelector,
+ composer,
+ loading,
+ emptyHint,
+}: ReportConversationPanelProps) {
+ const hasMessages = thread.messages.length > 0;
+
+ const content = useMemo(() => {
+ if (loading && !hasMessages) {
+ return (
+
+ );
+ }
+ if (!hasMessages) {
+ return {emptyHint ?? "输入报告需求开始协作"}
;
+ }
+ return (
+
+
+
+ );
+ }, [businessCardSlot, emptyHint, hasMessages, loading, sessionId, thread]);
+
+ return (
+
+
+
+ {stageSelector}
+ {content}
+ {composer}
+
+
+
+ );
+}
diff --git a/frontend-web/src/report-collaboration/components/ReportFlowCanvas.tsx b/frontend-web/src/report-collaboration/components/ReportFlowCanvas.tsx
new file mode 100644
index 00000000..3d569657
--- /dev/null
+++ b/frontend-web/src/report-collaboration/components/ReportFlowCanvas.tsx
@@ -0,0 +1,187 @@
+"use client";
+
+import { useEffect, useMemo, useRef, useState } from "react";
+import {
+ Background,
+ BackgroundVariant,
+ Controls,
+ MiniMap,
+ ReactFlow,
+ ReactFlowProvider,
+ type Edge,
+ type Node,
+ type NodeTypes,
+ useReactFlow,
+} from "@xyflow/react";
+import "@xyflow/react/dist/style.css";
+import { Workflow } from "lucide-react";
+
+import type {
+ CollaborationNodeState,
+ ReportPlanCandidate,
+} from "@/report-collaboration/api/types";
+import {
+ CollaborationFlowNode,
+ type ReportFlowNodeDatum,
+} from "@/report-collaboration/components/ReportFlowNode";
+
+const NODE_TYPES: NodeTypes = { collaboration: CollaborationFlowNode };
+const COLUMN_GAP = 280;
+const ROW_GAP = 130;
+
+interface ReportFlowCanvasProps {
+ plan: ReportPlanCandidate | null;
+ nodeStates: Record;
+ selectedNodeId: string | null;
+ onSelectNode: (nodeId: string | null) => void;
+ /** 空态文案(无方案时)。 */
+ emptyHint?: string;
+}
+
+/** 分层自动布局:depth = 最长依赖链长度;同层纵向排列。 */
+function layoutPlan(plan: ReportPlanCandidate): Map {
+ const depthById = new Map();
+ const nodeIds = new Set(plan.nodes.map((node) => node.id));
+ const visit = (nodeId: string, seen: Set): number => {
+ if (depthById.has(nodeId)) return depthById.get(nodeId)!;
+ if (seen.has(nodeId)) return 0; // 防御环
+ seen.add(nodeId);
+ const node = plan.nodes.find((candidate) => candidate.id === nodeId);
+ const depth =
+ node && node.depends_on.length > 0
+ ? Math.max(...node.depends_on.filter((dep) => nodeIds.has(dep)).map((dep) => visit(dep, seen))) + 1
+ : 0;
+ depthById.set(nodeId, depth);
+ return depth;
+ };
+ for (const node of plan.nodes) visit(node.id, new Set());
+
+ const layers = new Map();
+ for (const node of plan.nodes) {
+ const depth = depthById.get(node.id) ?? 0;
+ layers.set(depth, [...(layers.get(depth) ?? []), node.id]);
+ }
+
+ const positions = new Map();
+ for (const [depth, ids] of layers) {
+ ids.forEach((id, index) => {
+ positions.set(id, { x: depth * COLUMN_GAP, y: index * ROW_GAP });
+ });
+ }
+ return positions;
+}
+
+function CanvasInner({ plan, nodeStates, selectedNodeId, onSelectNode, emptyHint }: ReportFlowCanvasProps) {
+ const rf = useReactFlow();
+ const autoPositions = useMemo(() => (plan ? layoutPlan(plan) : new Map()), [plan]);
+ // 拖动位置是纯浏览器展示状态:per-plan 保存,方案切换即清理。
+ const draggedRef = useRef
-
+ {/* 配置管理(业务映射)目标路由仅放行管理员(非管理员会被重定向到问答页),入口同步只对管理员显示。 */}
+ {isAdmin && (
+
+ )}