From f109a95535aefb71117974aeae4f06f12d1d6fb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Thu, 3 Sep 2026 11:16:50 +0800 Subject: [PATCH 01/18] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=8A=A5?= =?UTF-8?q?=E5=91=8A=E5=8D=8F=E4=BD=9C=E9=A1=B5=EF=BC=8C=E5=B9=B6=E5=BC=BA?= =?UTF-8?q?=E5=8C=96=20Deep=20Research=20=E8=BF=90=E8=A1=8C=E8=AF=8A?= =?UTF-8?q?=E6=96=AD=E4=B8=8E=E6=8A=A5=E5=91=8A=E5=85=9C=E5=BA=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 支持浏览器侧素材快照撰写、拒绝报告读时修复、Mermaid PNG 导出与 Word 导出加固。 Co-authored-by: Cursor --- frontend-web/package.json | 1 + .../artifacts/artifact-file-detail.tsx | 75 +- .../workspace/messages/markdown-content.tsx | 8 +- .../workspace/messages/message-list.tsx | 77 +- .../workspace/workspace-nav-chat-list.tsx | 9 +- frontend-web/src/core/messages/utils.ts | 30 +- .../src/core/scheduled-tasks/markdown-docx.ts | 5 +- frontend-web/src/core/streamdown/index.ts | 1 + .../core/streamdown/mermaid-png-download.ts | 56 + .../src/core/utils/mermaid-sanitize.test.ts | 16 +- frontend-web/src/core/utils/mermaid.ts | 216 +++- frontend-web/src/pages/WorkspaceRoutes.tsx | 41 + .../deep-research/DeepResearchWorkbench.tsx | 147 ++- .../pages/deep-research/useReportSandbox.ts | 38 +- .../src/report-collaboration/README.md | 66 ++ .../src/report-collaboration/api/client.ts | 142 +++ .../src/report-collaboration/api/driver.ts | 231 ++++ .../src/report-collaboration/api/mock.ts | 1055 +++++++++++++++++ .../src/report-collaboration/api/plans.ts | 46 + .../src/report-collaboration/api/reports.ts | 52 + .../src/report-collaboration/api/runs.ts | 205 ++++ .../src/report-collaboration/api/sessions.ts | 84 ++ .../src/report-collaboration/api/types.ts | 699 +++++++++++ .../components/AgentStageSelector.tsx | 103 ++ .../components/ConnectionStatusBar.tsx | 41 + .../components/InterventionCard.tsx | 73 ++ .../components/NodeInspectorDrawer.tsx | 207 ++++ .../components/ReportCollaborationHeader.tsx | 77 ++ .../components/ReportCollaborationLayout.tsx | 166 +++ .../components/ReportComposer.tsx | 154 +++ .../components/ReportConversationPanel.tsx | 76 ++ .../components/ReportFlowCanvas.tsx | 187 +++ .../components/ReportFlowNode.tsx | 81 ++ .../components/ReportPlanCards.tsx | 188 +++ .../components/ReportPreviewDialog.tsx | 184 +++ .../components/SessionHistoryPanel.tsx | 203 ++++ .../hooks/useReportCollaborationMessages.ts | 49 + .../hooks/useReportCollaborationSession.ts | 399 +++++++ .../hooks/useReportCollaborationStream.ts | 110 ++ .../hooks/useReportPanelLayout.ts | 119 ++ .../hooks/useReportPlanSelection.ts | 42 + .../pages/ReportCollaborationPage.tsx | 508 ++++++++ .../state/event-normalizer.ts | 84 ++ .../state/event-reducer.ts | 965 +++++++++++++++ .../state/message-adapter.ts | 191 +++ .../report-collaboration/state/selectors.ts | 145 +++ .../styles/report-collaboration.css | 612 ++++++++++ .../tests/event-reducer.test.ts | 348 ++++++ .../tests/message-adapter.test.ts | 127 ++ .../pages/BusinessChainListPage.tsx | 19 +- .../strategy-components/api/deep-research.ts | 14 +- .../app/gateway/deep_research_job_executor.py | 674 ++++++++++- .../app/gateway/routers/deep_research.py | 252 +++- .../backend/app/gateway/routers/writing.py | 8 +- .../backend/app/gateway/word_export.py | 165 ++- .../deep_research/adapters/event_sink.py | 71 +- .../agents/deep_research/adapters/llm.py | 48 + .../collection/thread_harvester.py | 12 +- .../deerflow/agents/deep_research/engine.py | 27 + .../agents/deep_research/runners/basic.py | 400 ++++++- .../agents/deep_research/runners/deep.py | 50 +- .../agents/deep_research/runners/detailed.py | 31 +- .../deep_research/runners/multi_agent.py | 39 +- .../deerflow/agents/deep_research/types.py | 1 + .../backend/tests/test_word_export.py | 37 + 65 files changed, 10342 insertions(+), 245 deletions(-) create mode 100644 frontend-web/src/core/streamdown/mermaid-png-download.ts create mode 100644 frontend-web/src/report-collaboration/README.md create mode 100644 frontend-web/src/report-collaboration/api/client.ts create mode 100644 frontend-web/src/report-collaboration/api/driver.ts create mode 100644 frontend-web/src/report-collaboration/api/mock.ts create mode 100644 frontend-web/src/report-collaboration/api/plans.ts create mode 100644 frontend-web/src/report-collaboration/api/reports.ts create mode 100644 frontend-web/src/report-collaboration/api/runs.ts create mode 100644 frontend-web/src/report-collaboration/api/sessions.ts create mode 100644 frontend-web/src/report-collaboration/api/types.ts create mode 100644 frontend-web/src/report-collaboration/components/AgentStageSelector.tsx create mode 100644 frontend-web/src/report-collaboration/components/ConnectionStatusBar.tsx create mode 100644 frontend-web/src/report-collaboration/components/InterventionCard.tsx create mode 100644 frontend-web/src/report-collaboration/components/NodeInspectorDrawer.tsx create mode 100644 frontend-web/src/report-collaboration/components/ReportCollaborationHeader.tsx create mode 100644 frontend-web/src/report-collaboration/components/ReportCollaborationLayout.tsx create mode 100644 frontend-web/src/report-collaboration/components/ReportComposer.tsx create mode 100644 frontend-web/src/report-collaboration/components/ReportConversationPanel.tsx create mode 100644 frontend-web/src/report-collaboration/components/ReportFlowCanvas.tsx create mode 100644 frontend-web/src/report-collaboration/components/ReportFlowNode.tsx create mode 100644 frontend-web/src/report-collaboration/components/ReportPlanCards.tsx create mode 100644 frontend-web/src/report-collaboration/components/ReportPreviewDialog.tsx create mode 100644 frontend-web/src/report-collaboration/components/SessionHistoryPanel.tsx create mode 100644 frontend-web/src/report-collaboration/hooks/useReportCollaborationMessages.ts create mode 100644 frontend-web/src/report-collaboration/hooks/useReportCollaborationSession.ts create mode 100644 frontend-web/src/report-collaboration/hooks/useReportCollaborationStream.ts create mode 100644 frontend-web/src/report-collaboration/hooks/useReportPanelLayout.ts create mode 100644 frontend-web/src/report-collaboration/hooks/useReportPlanSelection.ts create mode 100644 frontend-web/src/report-collaboration/pages/ReportCollaborationPage.tsx create mode 100644 frontend-web/src/report-collaboration/state/event-normalizer.ts create mode 100644 frontend-web/src/report-collaboration/state/event-reducer.ts create mode 100644 frontend-web/src/report-collaboration/state/message-adapter.ts create mode 100644 frontend-web/src/report-collaboration/state/selectors.ts create mode 100644 frontend-web/src/report-collaboration/styles/report-collaboration.css create mode 100644 frontend-web/src/report-collaboration/tests/event-reducer.test.ts create mode 100644 frontend-web/src/report-collaboration/tests/message-adapter.test.ts 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" && ( -
-
)} - {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![流程图](embedded:${id})\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 ( +
+ + + SSE:{STATUS_TEXT[status]} + {status === "reconnecting" && message ? `(${message.slice(0, 80)})` : ""} + + run:{runId ? runId.slice(0, 18) : "—"} + 事件 seq:{lastSeq} +
+ ); +} 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 ( +
+
+ + + 报告协作 / {title} + + + {badge.label} + +
+
+ {extra} + + {running ? ( + + ) : null} + +
+
+ ); +} 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 ? ( +
+ {conversation} +
+ ) : 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} +
+