feat(frontend): 上线报告协作与多轮 RAG 消息聚合体验

功能说明:\n- 新增报告协作页(Report Collaboration)核心状态层、消息适配与会话面板,支持按节点/阶段选择和协作上下文展示\n- 补齐 report-collaboration API 与类型定义,并接入页面路由/构建路径,形成可运行的协作链路\n- 重构多轮 RAG 与 write/artefact 消息分组逻辑,优化 AgentChat 与 DeepResearch 场景下的消息展示顺序与可读性\n- 新增/更新 DeepResearch 前端页面(Workbench、报告配置、沙箱联动)以承接报告生成与复用能力\n- 增加报表协作相关单测,覆盖事件归并、消息适配与关键 reducer 行为
This commit is contained in:
董宏凯 committed 2026-09-04 23:18:27 +08:00
1 parent e58da3fbdf
commit 438772f0b7
25 files changed
+1740 -399

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="2064"
height="2591" viewBox="0 0 2064 2591">
<path fill-rule="nonzero"
d="M 68.582031 0 C 66.890625 0 65.300781 1 64.617188 2.640625 C 49.46875 38.800781 33.71875 76.199219 33.628906 106.539062 C 33.550781 134.230469 46.445312 155.730469 59.949219 175.71875 L 59.949219 175.730469 C 52.414062 175.671875 44.992188 175.371094 37.472656 174.171875 C 27.210938 172.53125 16.585938 169.199219 5.585938 165.738281 C 5.15625 165.609375 4.722656 165.539062 4.296875 165.539062 C 2.824219 165.539062 1.417969 166.300781 0.625 167.609375 C -0.402344 169.300781 -0.140625 171.46875 1.257812 172.871094 L 1.261719 172.878906 C 11.230469 182.871094 21.8125 193.53125 36.980469 200.050781 C 51.5625 206.320312 70.167969 208.628906 88.085938 210.78125 L 88.027344 210.78125 C 127.152344 253.410156 166.617188 296.300781 215.761719 334.941406 C 230.558594 346.578125 246.292969 357.878906 261.785156 369 C 277.171875 380.050781 292.441406 391.011719 306.660156 402.191406 C 326.132812 417.511719 343.5625 433.179688 356.773438 450.019531 C 377.808594 476.828125 388.449219 506.960938 395.4375 538.628906 L 395.4375 538.640625 C 372.683594 510.710938 349.328125 483.929688 324.855469 469.339844 C 295.957031 452.121094 265.800781 452 236.960938 451.808594 L 236.933594 451.820312 C 235.792969 451.808594 234.707031 452.261719 233.898438 453.070312 C 233.085938 453.871094 232.632812 454.96875 232.636719 456.121094 C 232.722656 494.011719 232.699219 533.539062 256.304688 567.480469 C 279.351562 600.621094 324.738281 628.019531 369.191406 654.679688 L 369.132812 654.691406 C 371.476562 683.539062 373.40625 710.691406 367.109375 728.03125 C 365.828125 731.558594 364.191406 734.71875 362.058594 738.820312 C 358.910156 744.871094 354.964844 752.441406 350.417969 764.960938 C 342.890625 785.699219 333.769531 819.839844 323.648438 883.109375 C 303.347656 1010.019531 278.722656 1255.949219 308.882812 1409.269531 C 326.265625 1497.660156 361.828125 1555.210938 393.511719 1606.519531 C 417.042969 1644.632812 438.207031 1678.960938 448.835938 1720.09375 C 466.921875 1790.089844 454.648438 1880.246094 444.5 1954.386719 C 440.613281 1982.78125 437.042969 2008.839844 435.726562 2030.261719 C 433.410156 2067.929688 438.066406 2091.179688 442.304688 2112.347656 L 442.304688 2112.355469 C 445.910156 2130.347656 449.152344 2146.589844 447.757812 2169.28125 C 447.429688 2174.613281 446.835938 2180.359375 445.933594 2186.535156 L 445.933594 2186.542969 C 436.582031 2250.328125 393.613281 2362.808594 362.105469 2416.636719 C 339.761719 2454.804688 323.546875 2462.851562 309.597656 2469.925781 C 303.980469 2472.777344 298.261719 2475.660156 292.691406 2480.238281 C 273.378906 2496.113281 256.457031 2532.078125 240.183594 2566.417969 C 239.578125 2567.703125 239.640625 2569.203125 240.363281 2570.429688 C 241.082031 2571.65625 242.359375 2572.449219 243.777344 2572.542969 L 243.789062 2572.542969 L 243.882812 2572.550781 L 243.882812 2572.554688 C 261.582031 2573.746094 279.460938 2574.957031 296.386719 2574.957031 C 323.671875 2574.957031 349.332031 2571.84375 368.355469 2559.785156 C 392.640625 2544.394531 405.109375 2514.960938 414.488281 2493.074219 C 417.011719 2487.183594 419.289062 2481.871094 421.546875 2477.308594 C 432.054688 2456.039062 441.753906 2451.664062 451.855469 2446.960938 C 453.054688 2446.402344 453.929688 2445.320312 454.226562 2444.042969 L 454.238281 2443.996094 L 454.238281 2443.988281 C 476.210938 2348.800781 498.035156 2254.347656 524.300781 2196.363281 L 524.300781 2196.367188 C 536.363281 2169.730469 549.363281 2150.796875 562.695312 2131.402344 C 576.28125 2111.625 590.1875 2091.402344 603.300781 2062.445312 L 603.304688 2062.453125 C 605.183594 2058.292969 607.054688 2053.949219 608.894531 2049.421875 C 625.066406 2009.675781 639.746094 1954.289062 653.585938 1902.101562 C 663.0625 1866.367188 672.15625 1832.09375 681.085938 1805.085938 C 699.308594 1749.992188 716.863281 1725.09375 734.371094 1705.144531 L 734.421875 1705.148438 C 727.285156 1740.746094 721.597656 1776.628906 729.320312 1807.472656 C 733.351562 1823.570312 740.996094 1838.160156 749.066406 1853.613281 C 758.828125 1872.296875 769.433594 1892.636719 776.359375 1919.675781 C 783.289062 1946.71875 786.574219 1980.605469 789.527344 2011.140625 L 789.527344 2011.148438 C 791.875 2035.402344 794.023438 2057.78125 797.746094 2072.316406 L 797.746094 2072.320312 C 797.929688 2073.023438 798.128906 2073.769531 798.316406 2074.441406 L 798.320312 2074.441406 C 802.132812 2087.941406 807.617188 2093.914062 813.515625 2100.324219 C 821.132812 2108.597656 830.351562 2118.441406 839.742188 2151.671875 L 839.738281 2151.675781 C 840.105469 2152.96875 840.402344 2154.039062 840.765625 2155.398438 C 857.484375 2217.601562 874.097656 2354.589844 872.078125 2423.390625 C 870.472656 2478.15625 857.140625 2488.390625 845.339844 2498.136719 C 842.320312 2500.632812 839.085938 2503.296875 836.09375 2506.566406 C 820.847656 2523.230469 812.652344 2554.886719 804.800781 2584.753906 C 804.457031 2586.0625 804.75 2587.453125 805.589844 2588.515625 C 806.40625 Line truncated
</svg>

After

Width:  |  Height:  |  Size: 19 KiB

+6 -1
View File
@@ -3,6 +3,7 @@
import { ChevronRightIcon } from "lucide-react"; import { ChevronRightIcon } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import deerMaskUrl from "@/assets/deer.svg";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { FlickeringGrid } from "@/components/ui/flickering-grid"; import { FlickeringGrid } from "@/components/ui/flickering-grid";
import Galaxy from "@/components/ui/galaxy"; import Galaxy from "@/components/ui/galaxy";
@@ -29,7 +30,11 @@ export function Hero({ className }: { className?: string }) {
/> />
</div> </div>
<FlickeringGrid <FlickeringGrid
className="absolute inset-0 z-0 translate-y-8 mask-[url(/images/deer.svg)] mask-size-[100vw] mask-center mask-no-repeat md:mask-size-[72vh]" className="absolute inset-0 z-0 translate-y-8 mask-size-[100vw] mask-center mask-no-repeat md:mask-size-[72vh]"
style={{
WebkitMaskImage: `url("${deerMaskUrl}")`,
maskImage: `url("${deerMaskUrl}")`,
}}
squareSize={4} squareSize={4}
gridGap={4} gridGap={4}
color={"white"} color={"white"}
@@ -6,7 +6,7 @@ import {
SearchIcon, SearchIcon,
SparklesIcon, SparklesIcon,
} from "lucide-react"; } from "lucide-react";
import { useEffect, useState } from "react"; import { useState } from "react";
import { import {
Conversation, Conversation,
@@ -165,9 +165,6 @@ function MultiRoundRagTraceView({
onSelectRetrieval: (item: MultiRoundRagRetrieval) => void; onSelectRetrieval: (item: MultiRoundRagRetrieval) => void;
}) { }) {
const [open, setOpen] = useState(!trace.thinkingComplete); const [open, setOpen] = useState(!trace.thinkingComplete);
useEffect(() => {
setOpen(!trace.thinkingComplete);
}, [trace.thinkingComplete]);
return ( return (
<Collapsible <Collapsible
@@ -208,6 +205,7 @@ function MultiRoundRagTraceView({
index={index + 1} index={index + 1}
round={round} round={round}
isLoading={isLoading} isLoading={isLoading}
isActiveRound={isLoading && index === trace.rounds.length - 1}
rehypePlugins={rehypePlugins} rehypePlugins={rehypePlugins}
onSelectRetrieval={onSelectRetrieval} onSelectRetrieval={onSelectRetrieval}
/> />
@@ -222,20 +220,22 @@ function MultiRoundRagRoundCard({
index, index,
round, round,
isLoading, isLoading,
isActiveRound,
rehypePlugins, rehypePlugins,
onSelectRetrieval, onSelectRetrieval,
}: { }: {
index: number; index: number;
round: MultiRoundRagRound; round: MultiRoundRagRound;
isLoading: boolean; isLoading: boolean;
isActiveRound: boolean;
rehypePlugins: MessageResponseProps["rehypePlugins"]; rehypePlugins: MessageResponseProps["rehypePlugins"];
onSelectRetrieval: (item: MultiRoundRagRetrieval) => void; onSelectRetrieval: (item: MultiRoundRagRetrieval) => void;
}) { }) {
const searching = Boolean(round.queryLoading); const query = round.query.trim();
const searching = Boolean(query && round.queryLoading);
const showQuery = Boolean(query);
const showRetrievals = showQuery || round.retrievals.length > 0;
const [open, setOpen] = useState(true); const [open, setOpen] = useState(true);
useEffect(() => {
setOpen(searching || !round.query);
}, [round.id, round.query, searching]);
return ( return (
<Collapsible open={open} onOpenChange={setOpen}> <Collapsible open={open} onOpenChange={setOpen}>
@@ -254,7 +254,7 @@ function MultiRoundRagRoundCard({
<Loader2Icon className="size-3 animate-spin" /> <Loader2Icon className="size-3 animate-spin" />
检索中 检索中
</span> </span>
) : ( ) : isActiveRound ? null : (
<span className="text-muted-foreground text-[11px]">已完成</span> <span className="text-muted-foreground text-[11px]">已完成</span>
)} )}
<ChevronDownIcon <ChevronDownIcon
@@ -277,7 +277,7 @@ function MultiRoundRagRoundCard({
/> />
</section> </section>
)} )}
{round.queryShow && (round.query || searching) && ( {showQuery && (
<div <div
className="border-primary/20 bg-primary/5 text-primary inline-flex max-w-full items-center gap-2 rounded-full border px-3 py-1.5" className="border-primary/20 bg-primary/5 text-primary inline-flex max-w-full items-center gap-2 rounded-full border px-3 py-1.5"
aria-live={searching ? "polite" : undefined} aria-live={searching ? "polite" : undefined}
@@ -291,11 +291,11 @@ function MultiRoundRagRoundCard({
{round.queryTitle || "检索词"} {round.queryTitle || "检索词"}
</span> </span>
<span className="min-w-0 truncate text-xs font-medium"> <span className="min-w-0 truncate text-xs font-medium">
{round.query || "正在生成检索词…"} {query}
</span> </span>
</div> </div>
)} )}
{round.queryShow && ( {showRetrievals && (
<section> <section>
<div className="mb-2 flex items-center gap-2 text-sm font-medium"> <div className="mb-2 flex items-center gap-2 text-sm font-medium">
<FileTextIcon className="text-muted-foreground size-4" /> <FileTextIcon className="text-muted-foreground size-4" />
@@ -111,6 +111,28 @@ test("a lone deep-research progress bar still opens its own group", () => {
assert.deepEqual(types, ["assistant:processing"]); assert.deepEqual(types, ["assistant:processing"]);
}); });
test("multi-round RAG assistant rows render even while the final answer is still empty", () => {
const types = groupTypes([
{ type: "human", id: "h1", content: "检索一下", additional_kwargs: { multi_round_rag: true } } as Message,
{
type: "ai",
id: "a1",
content: "",
additional_kwargs: {
multi_round_rag: true,
multi_round_rag_trace: {
title: "多轮检索思考过程",
rounds: [{ id: "r1", title: "思考过程", thoughtMarkdown: "正在拆解问题", query: "", queryShow: true, retrievals: [] }],
finalAnswerMarkdown: "",
thinkingComplete: false,
},
},
} as Message,
]);
assert.deepEqual(types, ["human", "assistant"]);
});
test("tool messages still attach to an open processing group", () => { test("tool messages still attach to an open processing group", () => {
const types = groupTypes([ const types = groupTypes([
{ {
+10
View File
@@ -141,6 +141,16 @@ export function groupMessages<T>(
} }
if (message.type === "ai") { if (message.type === "ai") {
// Multi-round RAG keeps rounds/retrievals on additional_kwargs while the
// visible answer is still empty. The default rule below only opens an
// assistant bubble when hasContent() is true, which would swallow the
// live thinking cards until the final answer arrives (or forever, if the
// turn is think/search-only). Force a normal assistant row so
// MessageListItem can render MultiRoundRagAssistantBody immediately.
if (message.additional_kwargs?.multi_round_rag === true) {
groups.push({ id: message.id, type: "assistant", messages: [message] });
continue;
}
// Deep-research interaction card: virtual ai messages injected by the // Deep-research interaction card: virtual ai messages injected by the
// workbench (additional_kwargs.setup_research_approval) render through // workbench (additional_kwargs.setup_research_approval) render through
// the host-provided slot. Writing phases use ordinary virtual tool calls // the host-provided slot. Writing phases use ordinary virtual tool calls
@@ -144,20 +144,22 @@ function platformTypeChange(platform: string) {
return platform || "facebook"; return platform || "facebook";
} }
function createEmptyRound(title: string, thoughtMarkdown = ""): MultiRoundRagRound {
return {
id: createId("multi-rag-round"),
title,
thoughtMarkdown,
query: "",
queryLoading: false,
queryShow: false,
retrievals: [],
};
}
function createEmptyTrace(): MultiRoundRagTrace { function createEmptyTrace(): MultiRoundRagTrace {
return { return {
title: "多轮检索思考过程", title: "多轮检索思考过程",
rounds: [ rounds: [createEmptyRound("正在准备检索")],
{
id: createId("multi-rag-round"),
title: "正在准备检索",
thoughtMarkdown: "",
query: "",
queryLoading: true,
queryShow: true,
retrievals: [],
},
],
thinkingComplete: false, thinkingComplete: false,
finalAnswerMarkdown: "", finalAnswerMarkdown: "",
}; };
@@ -370,16 +372,8 @@ export function applyMultiRoundToolCall(trace: MultiRoundRagTrace, toolValue: un
round.thoughtMarkdown = mergeThink(round.thoughtMarkdown, thinkContent); round.thoughtMarkdown = mergeThink(round.thoughtMarkdown, thinkContent);
} else { } else {
round.queryLoading = false; round.queryLoading = false;
round.queryShow = round.retrievals.length > 0; round.queryShow = Boolean(round.query) || round.retrievals.length > 0;
trace.rounds.push({ trace.rounds.push(createEmptyRound("思考中", thinkContent));
id: createId("multi-rag-round"),
title: "思考中",
thoughtMarkdown: thinkContent,
query: "",
queryLoading: true,
queryShow: true,
retrievals: [],
});
} }
} else { } else {
round.queryLoading = false; round.queryLoading = false;
+28 -6
View File
@@ -12,6 +12,7 @@ import { useParams, useRouter } from "next/navigation";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import { useRouteBase } from "@/core/route-base"; import { useRouteBase } from "@/core/route-base";
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input"; import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -25,6 +26,7 @@ import {
} from "@/components/workspace/agents/agent-guide"; } from "@/components/workspace/agents/agent-guide";
import { AgentWelcome } from "@/components/workspace/agent-welcome"; import { AgentWelcome } from "@/components/workspace/agent-welcome";
import { ArtifactTrigger } from "@/components/workspace/artifacts"; import { ArtifactTrigger } from "@/components/workspace/artifacts";
import { listThreadArtifacts } from "@/core/artifacts/listing";
import { ChatBox, useThreadChat } from "@/components/workspace/chats"; import { ChatBox, useThreadChat } from "@/components/workspace/chats";
import { ExportTrigger } from "@/components/workspace/export-trigger"; import { ExportTrigger } from "@/components/workspace/export-trigger";
import { InputBox } from "@/components/workspace/input-box"; import { InputBox } from "@/components/workspace/input-box";
@@ -98,6 +100,14 @@ const PAGE_REPORT_DESIGNER_AGENT_ID = "page-report-designer";
const RWFX_SQ_REPORT_CONTEXT_MAX_CHARS = 12000; const RWFX_SQ_REPORT_CONTEXT_MAX_CHARS = 12000;
const RWFX_SQ_REPORT_CONTEXT_TAIL_CHARS = 2000; const RWFX_SQ_REPORT_CONTEXT_TAIL_CHARS = 2000;
function isTaskReportsJsonPath(path: string | undefined): boolean {
const normalized = (path ?? "").replace(/\\/g, "/");
return (
normalized === "task-reports.json" ||
normalized.endsWith("/task-reports.json")
);
}
function readPendingKnowledgeSelection(agentId: string | undefined): string[] | null { function readPendingKnowledgeSelection(agentId: string | undefined): string[] | null {
if (typeof window === "undefined" || !agentId) return null; if (typeof window === "undefined" || !agentId) return null;
try { try {
@@ -504,9 +514,11 @@ export default function AgentChatPage({
// ── agentfx「入库」协助卡 ────────────────────────────────────────────────── // ── agentfx「入库」协助卡 ──────────────────────────────────────────────────
// 任务工作区(taskBind)里,内置研判智能体写出的 task-reports.json 产物落地且 // 任务工作区(taskBind)里,内置研判智能体写出的 task-reports.json 产物落地且
// 本轮流式结束后,向消息流尾部注入「是否入库」卡片。本地沙箱下 artifacts 可能 // 本轮流式结束后,向消息流尾部注入「是否入库」卡片。本地沙箱下 artifacts 可能
// 记录宿主真实路径,按文件名匹配(虚拟/宿主路径都命中)。任务已有详情**不抑制** // 记录宿主真实路径,按文件名匹配(虚拟/宿主路径都命中)。转换脚本已函数合并
// 卡片(入库是整体替换,由卡片文案说明覆盖语义);已导入按 thread 记忆在 // 写出该文件,即使模型中断、没跑 present_files,也再扫一遍 outputs 目录。
// sessionStorage(刷新后不再重复询问)、「暂不入库」为本次会话内记忆。 // 任务已有详情**不抑制**卡片(入库是整体替换,由卡片文案说明覆盖语义);
// 已导入按 thread 记忆在 sessionStorage(刷新后不再重复询问)、
// 「暂不入库」为本次会话内记忆。
const reportImportSessionKey = threadId const reportImportSessionKey = threadId
? `agentfx.reportImport.done.${threadId}` ? `agentfx.reportImport.done.${threadId}`
: ""; : "";
@@ -519,9 +531,19 @@ export default function AgentChatPage({
} }
}); });
const [reportImportDismissed, setReportImportDismissed] = useState(false); const [reportImportDismissed, setReportImportDismissed] = useState(false);
const reportJsonArtifactReady = (thread.values.artifacts ?? []).some((path) => const listedReportJsonQuery = useQuery({
path.replace(/\\/g, "/").endsWith("/task-reports.json"), queryKey: ["agentfx-task-reports", threadId],
); queryFn: ({ signal }) => listThreadArtifacts(threadId, signal),
enabled: Boolean(
taskBind && !taskGate && threadId && threadId !== "new" && !thread.isLoading,
),
retry: false,
});
const reportJsonArtifactReady =
(thread.values.artifacts ?? []).some(isTaskReportsJsonPath) ||
(listedReportJsonQuery.data?.files ?? []).some((file) =>
isTaskReportsJsonPath(file.path) || file.name === "task-reports.json",
);
const showReportImportCard = const showReportImportCard =
taskBind && taskBind &&
!taskGate && !taskGate &&
@@ -99,6 +99,7 @@ import {
extractToolResultPayload, extractToolResultPayload,
hasToolCalls, hasToolCalls,
} from "@/core/messages/utils"; } from "@/core/messages/utils";
import { normalizeSearchResults } from "@/core/messages/search-results";
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx"; import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
import { import {
type DeepResearchSession, type DeepResearchSession,
@@ -276,7 +277,18 @@ function assembleStreamingReport(targets: Record<string, { content: string; vers
const REPORT_REFUSAL_PATTERN = /(?:无法|不能|未能|不可以).{0,24}?(?:生成|撰写|产出|输出).{0,12}?报告/s; const REPORT_REFUSAL_PATTERN = /(?:无法|不能|未能|不可以).{0,24}?(?:生成|撰写|产出|输出).{0,12}?报告/s;
function isRefusalReportText(value: string): boolean { function isRefusalReportText(value: string): boolean {
return REPORT_REFUSAL_PATTERN.test(value.replace(/\s+/g, "")); const text = value || "";
const compact = text.replace(/\s+/g, "");
// Empty means the writer has not emitted Markdown yet (thinking / connecting).
// Treating that as a refusal opened the sandbox on an error card while the
// model was still working.
if (!compact) return false;
const headings = text.match(/^#{1,6}\s+\S+/gm) ?? [];
// A structured report may discuss evidence gaps. Only treat a short,
// unstructured refusal as “no report”, otherwise the zero-source fallback
// is hidden behind the diagnostic card and looks like writing failed.
if (headings.length >= 2) return false;
return REPORT_REFUSAL_PATTERN.test(compact);
} }
function researchExportBaseName(session: DeepResearchSession): string { function researchExportBaseName(session: DeepResearchSession): string {
@@ -422,68 +434,97 @@ function countCollectorMaterials(messages: readonly Message[] | undefined): numb
return unique.size; return unique.size;
} }
const COLLECTOR_PERSIST_LIMIT = 80;
const WRITE_FALLBACK_MATERIAL_LIMIT = 20;
const SKIP_COLLECTOR_PERSIST_TOOLS = new Set(["ask_clarification", "write_todos"]);
function collectorMaterialKey(item: Record<string, unknown>): string {
const text = (value: unknown) => typeof value === "string" ? value.trim() : "";
return text(item.id) || text(item.url) || `${text(item.title)}\n${text(item.raw_content).slice(0, 240)}`;
}
function isCompleteToolPayload(message: Message): boolean {
const payload = extractToolResultPayload(message);
if (payload == null) return false;
if (typeof payload === "string") {
const trimmed = payload.trim();
if (!trimmed) return false;
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
try {
JSON.parse(trimmed);
return true;
} catch {
return false;
}
}
return trimmed.length > 20;
}
return typeof payload === "object";
}
/** Freeze a compact evidence pool for a worker-independent report job. */ /** Freeze a compact evidence pool for a worker-independent report job. */
function snapshotCollectorMaterials(messages: readonly Message[] | undefined): Record<string, unknown>[] { function snapshotCollectorMaterials(
messages: readonly Message[] | undefined,
options?: { limit?: number; includeSummary?: boolean },
): Record<string, unknown>[] {
if (!messages?.length) return []; if (!messages?.length) return [];
const limit = options?.limit ?? COLLECTOR_PERSIST_LIMIT;
const frozen: Record<string, unknown>[] = []; const frozen: Record<string, unknown>[] = [];
const seen = new Set<string>(); const seen = new Set<string>();
const text = (value: unknown) => typeof value === "string" ? value.trim() : ""; const text = (value: unknown) => typeof value === "string" ? value.trim() : "";
const addCandidate = (candidate: unknown, toolName: string, index: number) => { const addNormalized = (item: ReturnType<typeof normalizeSearchResults>[number], toolName: string) => {
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) return; const url = text(item.url);
const item = candidate as Record<string, unknown>; const id = text(item.id) || text(item.recUuid) || text(item.uuid) || text(item.docId);
const url = text(item.url) || text(item.source_url) || text(item.link); const title = text(item.title) || `检索结果 ${frozen.length + 1}`;
const id = text(item.id) || text(item.recUuid) || text(item.rec_uuid) || text(item.uuid); const content = text(item.content) || text(item.snippet);
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; if (!url && !id && !content) return;
const key = id || url || `${title}\n${content.slice(0, 240)}`; const key = id || url || `${title}\n${content.slice(0, 240)}`;
if (seen.has(key) || frozen.length >= 80) return; if (seen.has(key) || frozen.length >= limit) return;
seen.add(key); seen.add(key);
frozen.push({ frozen.push({
id: id || undefined, id: id || undefined,
title: title.slice(0, 500), title: title.slice(0, 500),
url: url.slice(0, 2_000) || undefined, url: url.slice(0, 2_000) || undefined,
raw_content: content.slice(0, 6_000), raw_content: content.slice(0, 6_000),
snippet: content.slice(0, 500) || undefined, snippet: (text(item.snippet) || content).slice(0, 500) || undefined,
source: text(item.source) || toolName || "collector", source: text(item.source) || toolName || "collector",
source_type: toolName === "web_search" ? "web" : "other", source_type: toolName === "web_search" ? "web" : "other",
published_at: text(item.publish_time) || text(item.published_at) || text(item.time) || undefined, published_at: text(item.published_date) || text(item.publishedDate) || text(item.date) || undefined,
rec_uuid: text(item.recUuid) || text(item.rec_uuid) || undefined, rec_uuid: text(item.recUuid) || text(item.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<string, unknown>;
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) { for (const message of messages) {
if (message.type === "tool") { if (message.type === "tool" && message.name && SKIP_COLLECTOR_PERSIST_TOOLS.has(message.name)) {
visitPayload(extractToolResultPayload(message), message.name || "collector_tool"); continue;
}
const toolName = message.name || (message.type === "tool" ? "collector_tool" : "");
const payload = message.type === "tool" || message.type === "ai"
? extractToolResultPayload(message)
: undefined;
const rows = normalizeSearchResults(
payload && typeof payload === "object" && !Array.isArray(payload)
? payload
: typeof payload === "string"
? payload
: undefined,
);
for (const row of rows) addNormalized(row, toolName || "collector");
}
if (!frozen.length && options?.includeSummary !== false) {
const summary = [...messages].reverse().find(
(message) => message.type === "ai" && extractContentFromMessage(message).trim(),
);
const content = summary ? extractContentFromMessage(summary).trim() : "";
if (content) {
frozen.push({
title: "收集员小结",
raw_content: content.slice(0, 6_000),
snippet: content.slice(0, 500),
source: "collector",
source_type: "other",
});
} }
} }
// 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; return frozen;
} }
@@ -583,9 +624,18 @@ function DeepResearchWorkbenchInner() {
const replayedTraceJobs = useRef(new Set<string>()); const replayedTraceJobs = useRef(new Set<string>());
const pendingReportEvents = useRef<ProgressEvent[]>([]); const pendingReportEvents = useRef<ProgressEvent[]>([]);
const reportAnimationFrame = useRef<number | null>(null); const reportAnimationFrame = useRef<number | null>(null);
const boundStreamJobRef = useRef<string | null>(null);
const openReportRef = useRef<() => void>(() => {});
const refetchLiveJobRef = useRef<() => void>(() => {});
const inlineGenerationSessionRef = useRef<DeepResearchSession | null>(null);
// Chat-collection flow bookkeeping. // Chat-collection flow bookkeeping.
const pendingInitialQuery = useRef<string | null>(null); const pendingInitialQuery = useRef<string | null>(null);
const finishedTurnKey = useRef<string | null>(null); const finishedTurnKey = useRef<string | null>(null);
const persistedCollectorKey = useRef("");
const persistedCollectorToolIds = useRef(new Set<string>());
const persistedCollectorMaterialKeys = useRef(new Set<string>());
const collectorPersistChain = useRef(Promise.resolve());
const collectorSessionIdRef = useRef<string | null>(null);
const followUpAbortRef = useRef<AbortController | null>(null); const followUpAbortRef = useRef<AbortController | null>(null);
const refetchSession = sessionQuery.refetch; const refetchSession = sessionQuery.refetch;
@@ -594,6 +644,22 @@ function DeepResearchWorkbenchInner() {
const refetchReportVariants = reportVariants.refetch; const refetchReportVariants = reportVariants.refetch;
const session = sessionQuery.data; const session = sessionQuery.data;
const loggedSessionDiagnosticKey = useRef<string | null>(null);
useEffect(() => {
if (!session) return;
const diagnostics = session.runtimeDiagnostics;
if (!session.error && !diagnostics) return;
const key = `${session.id}:${session.activeJobId ?? session.usage?.lastJobId ?? ""}:${session.error ?? ""}`;
if (loggedSessionDiagnosticKey.current === key) return;
loggedSessionDiagnosticKey.current = key;
console.warn("[深度研究] session_runtime_diagnostic", {
sessionId: session.id,
status: session.status,
error: session.error,
activeJobId: session.activeJobId,
runtimeDiagnostics: diagnostics,
});
}, [session]);
const generatedReportVariants = useMemo(() => { const generatedReportVariants = useMemo(() => {
const byId = new Map((reportVariants.data ?? []).map((item) => [item.id, item])); const byId = new Map((reportVariants.data ?? []).map((item) => [item.id, item]));
if (inlineGeneratedReport && session && inlineGeneratedReport.parentSessionId === session.id) { if (inlineGeneratedReport && session && inlineGeneratedReport.parentSessionId === session.id) {
@@ -671,12 +737,25 @@ function DeepResearchWorkbenchInner() {
// a worker is stale or a new provider wording escapes that guard. // a worker is stale or a new provider wording escapes that guard.
const refusalReportDetected = isRefusalReportText(rawDisplayedReport); const refusalReportDetected = isRefusalReportText(rawDisplayedReport);
const hasUsableDisplayedReport = Boolean(rawDisplayedReport.trim()) && !refusalReportDetected; const hasUsableDisplayedReport = Boolean(rawDisplayedReport.trim()) && !refusalReportDetected;
// A completed session may retain an old diagnostic in `session.error` even const streamStillOpen = Boolean(run.jobId && run.phase !== "done" && run.phase !== "failed");
// after the backend has produced a usable fallback report. That historical const liveWriting = Boolean(
// diagnostic must not be appended to an otherwise normal report. isRunning
|| isAwaitingReview
|| reportDocumentRewrite.isActive
|| streamStillOpen
|| (viewingLiveGeneration && run.phase !== "done"),
);
// Never replace a live write with the operator diagnostic. An empty sandbox
// during thinking is normal; the JSON dump is only for a finished run that
// still has no usable Markdown.
const repairOnlyError = typeof session?.error === "string"
&& session.error.includes("session_read_repair");
const showRunDiagnostic = Boolean( const showRunDiagnostic = Boolean(
refusalReportDetected !liveWriting && !hasUsableDisplayedReport && (
|| (!hasUsableDisplayedReport && (session?.error || session?.status === "failed")), refusalReportDetected
|| session?.status === "failed"
|| (Boolean(session?.error) && !repairOnlyError)
),
); );
const runtimeDiagnosticText = JSON.stringify( const runtimeDiagnosticText = JSON.stringify(
session?.runtimeDiagnostics ?? { session?.runtimeDiagnostics ?? {
@@ -793,10 +872,19 @@ function DeepResearchWorkbenchInner() {
report: displayedReport, report: displayedReport,
writing: sandboxWriting, writing: sandboxWriting,
onFirstReportContent: handleFirstReportContent, onFirstReportContent: handleFirstReportContent,
// Open only after thinking has finished and the first write token exists.
// Thinking stays on the left progress card; an empty writing-phase stub
// must not steal the right panel.
activity: Boolean(run.report.trim()),
// Open on the first visible write, including first-generation reports and // Open on the first visible write, including first-generation reports and
// a new sibling. Stay put if the user has already switched to another card. // a new sibling. Stay put if the user has already switched to another card.
autoOpen: viewingLiveGeneration || reportDocumentRewrite.isActive, autoOpen: viewingLiveGeneration || reportDocumentRewrite.isActive,
}); });
openReportRef.current = openReport;
inlineGenerationSessionRef.current = inlineGenerationSession;
refetchLiveJobRef.current = () => {
void Promise.all([refetchSession(), refetchSessions(), refetchSources(), refetchReportVariants()]);
};
const sandboxVisible = sandboxOpen && Boolean( const sandboxVisible = sandboxOpen && Boolean(
displayedReport || isRunning || isAwaitingReview || reportDocumentRewrite.isActive || isGeneratingNewReport, displayedReport || isRunning || isAwaitingReview || reportDocumentRewrite.isActive || isGeneratingNewReport,
); );
@@ -805,6 +893,13 @@ function DeepResearchWorkbenchInner() {
openReport(); openReport();
}, [openReport]); }, [openReport]);
useEffect(() => {
if (!viewingLiveGeneration && !reportDocumentRewrite.isActive) return;
if (!run.report.trim()) return;
setSandboxOpen(true);
openReport();
}, [openReport, reportDocumentRewrite.isActive, run.report, viewingLiveGeneration]);
// ArtifactFileList invokes its handler synchronously. Switch the virtual // ArtifactFileList invokes its handler synchronously. Switch the virtual
// sandbox file first, then open it from an effect once useReportSandbox has // sandbox file first, then open it from an effect once useReportSandbox has
// rebuilt its URL for the generated report session. // rebuilt its URL for the generated report session.
@@ -1103,6 +1198,66 @@ function DeepResearchWorkbenchInner() {
session, session,
]); ]);
// Persist each completed search as it lands in the step list, instead of
// waiting until the whole collection turn finishes and writing 80 rows at once.
useEffect(() => {
collectorSessionIdRef.current = session?.id ?? null;
persistedCollectorToolIds.current = new Set();
persistedCollectorMaterialKeys.current = new Set();
persistedCollectorKey.current = "";
collectorPersistChain.current = Promise.resolve();
}, [session?.id]);
useEffect(() => {
if (!isChatSession || !session?.id) return;
if (session.status !== "collecting" && session.status !== "draft") return;
const remaining = COLLECTOR_PERSIST_LIMIT - persistedCollectorMaterialKeys.current.size;
if (remaining <= 0) return;
const pending: Record<string, unknown>[] = [];
const claimedToolIds: string[] = [];
const claimedKeys: string[] = [];
for (const message of collectorThread.messages ?? []) {
if (message.type !== "tool" || !message.id) continue;
if (message.name && SKIP_COLLECTOR_PERSIST_TOOLS.has(message.name)) continue;
if (persistedCollectorToolIds.current.has(message.id)) continue;
if (!isCompleteToolPayload(message)) continue;
const rows = snapshotCollectorMaterials([message], {
limit: remaining - pending.length,
includeSummary: false,
});
persistedCollectorToolIds.current.add(message.id);
claimedToolIds.push(message.id);
for (const row of rows) {
const key = collectorMaterialKey(row);
if (!key || persistedCollectorMaterialKeys.current.has(key)) continue;
persistedCollectorMaterialKeys.current.add(key);
claimedKeys.push(key);
pending.push(row);
if (pending.length >= remaining) break;
}
if (pending.length >= remaining) break;
}
if (!pending.length) return;
const sessionId = session.id;
collectorPersistChain.current = collectorPersistChain.current
.then(() => actions.persistCollectorMaterials.mutateAsync({
sessionId,
collectorMaterials: pending,
quiet: true,
}))
.catch(() => {
if (collectorSessionIdRef.current !== sessionId) return;
for (const toolId of claimedToolIds) persistedCollectorToolIds.current.delete(toolId);
for (const key of claimedKeys) persistedCollectorMaterialKeys.current.delete(key);
});
}, [
actions.persistCollectorMaterials,
collectorThread.messages,
isChatSession,
session?.id,
session?.status,
]);
// Turn finished without a pending clarification → surface the config card. // Turn finished without a pending clarification → surface the config card.
// Requiring a non-human tail closes the window between the optimistic user // Requiring a non-human tail closes the window between the optimistic user
// message and the run flag flipping, where the turn would look "ended". // message and the run flag flipping, where the turn would look "ended".
@@ -1119,7 +1274,20 @@ function DeepResearchWorkbenchInner() {
if (finishedTurnKey.current === key) return; if (finishedTurnKey.current === key) return;
finishedTurnKey.current = key; finishedTurnKey.current = key;
if (session) { if (session) {
void actions.updateStatus.mutate({ sessionId: session.id, status: "awaiting_report" }); const leftover = snapshotCollectorMaterials(collectorThread.messages, {
limit: COLLECTOR_PERSIST_LIMIT,
}).filter((item) => {
const materialKey = collectorMaterialKey(item);
if (!materialKey || persistedCollectorMaterialKeys.current.has(materialKey)) return false;
persistedCollectorMaterialKeys.current.add(materialKey);
return true;
});
persistedCollectorKey.current = `${session.id}:${persistedCollectorMaterialKeys.current.size}`;
void actions.updateStatus.mutate({
sessionId: session.id,
status: "awaiting_report",
...(leftover.length ? { collectorMaterials: leftover } : {}),
});
} }
}, [ }, [
actions.updateStatus, actions.updateStatus,
@@ -1127,10 +1295,45 @@ function DeepResearchWorkbenchInner() {
collectorPendingClarification, collectorPendingClarification,
collectorStreaming, collectorStreaming,
collectorTailIsHuman, collectorTailIsHuman,
collectorThread.messages,
isChatSession, isChatSession,
session, session,
]); ]);
// After a refresh the collector messages reload from the thread. Persist
// anything the incremental writer missed so 「选择结构再生成」 still has rows.
useEffect(() => {
if (!isChatSession || !session?.id) return;
if (session.status !== "awaiting_report") return;
const leftover = snapshotCollectorMaterials(collectorThread.messages, {
limit: COLLECTOR_PERSIST_LIMIT,
}).filter((item) => {
const materialKey = collectorMaterialKey(item);
if (!materialKey || persistedCollectorMaterialKeys.current.has(materialKey)) return false;
persistedCollectorMaterialKeys.current.add(materialKey);
return true;
});
if (!leftover.length) return;
const persistKey = `${session.id}:${persistedCollectorMaterialKeys.current.size}`;
if (persistedCollectorKey.current === persistKey) return;
persistedCollectorKey.current = persistKey;
void actions.persistCollectorMaterials.mutateAsync({
sessionId: session.id,
collectorMaterials: leftover,
}).catch(() => {
persistedCollectorKey.current = "";
for (const item of leftover) {
persistedCollectorMaterialKeys.current.delete(collectorMaterialKey(item));
}
});
}, [
actions.persistCollectorMaterials,
collectorThread.messages,
isChatSession,
session?.id,
session?.status,
]);
const collectedMaterialCount = useMemo( const collectedMaterialCount = useMemo(
() => countCollectorMaterials(collectorThread.messages), () => countCollectorMaterials(collectorThread.messages),
[collectorThread.messages], [collectorThread.messages],
@@ -1384,18 +1587,21 @@ function DeepResearchWorkbenchInner() {
if (!streamJobId || (!isRunning && !isAwaitingReview && !replayingTerminalTrace)) return; if (!streamJobId || (!isRunning && !isAwaitingReview && !replayingTerminalTrace)) return;
if (replayingTerminalTrace && replayedTraceJobs.current.has(streamJobId)) return; if (replayingTerminalTrace && replayedTraceJobs.current.has(streamJobId)) return;
if (replayingTerminalTrace) replayedTraceJobs.current.add(streamJobId); if (replayingTerminalTrace) replayedTraceJobs.current.add(streamJobId);
seenEventKeys.current = new Set();
liveEventSerial.current = 0;
pendingReportEvents.current = [];
setRun({ ...EMPTY_RUN, jobId: streamJobId });
// Keep the sandbox closed until a report delta is available. `useReportSandbox`
// opens it at that first visible write, rather than during model thinking
// or after the report has already finished. If the user has already opened
// another file card, leave that view alone.
const keepCurrentSandbox = Boolean( const keepCurrentSandbox = Boolean(
inlineGenerationSession && sandboxSessionId !== inlineGenerationSession.id, inlineGenerationSessionRef.current && sandboxSessionId !== inlineGenerationSessionRef.current.id,
); );
if (!replayingTerminalTrace && !keepCurrentSandbox) setSandboxOpen(false); const jobChanged = boundStreamJobRef.current !== streamJobId;
if (jobChanged) {
boundStreamJobRef.current = streamJobId;
seenEventKeys.current = new Set();
liveEventSerial.current = 0;
pendingReportEvents.current = [];
setRun({ ...EMPTY_RUN, jobId: streamJobId });
// Only close the sandbox when a *new* job starts. Reconnecting the same
// write stream used to wipe the live draft and flip auto-open off, so
// thinking could finish while the right panel stayed closed.
if (!replayingTerminalTrace && !keepCurrentSandbox) setSandboxOpen(false);
}
const flushReportEvents = () => { const flushReportEvents = () => {
reportAnimationFrame.current = null; reportAnimationFrame.current = null;
@@ -1471,6 +1677,13 @@ function DeepResearchWorkbenchInner() {
: eventKey(event); : eventKey(event);
if (seenEventKeys.current.has(key)) return; if (seenEventKeys.current.has(key)) return;
seenEventKeys.current.add(key); seenEventKeys.current.add(key);
const firstWriteToken = (
event.type === "report_delta" || event.type === "report_chunk"
) && typeof event.payload?.delta === "string" && event.payload.delta.trim();
if (firstWriteToken && !keepCurrentSandbox) {
setSandboxOpen(true);
openReportRef.current();
}
if ( if (
event.type === "plan_delta" || event.type === "plan_delta" ||
event.type === "plan_reasoning" || event.type === "plan_reasoning" ||
@@ -1503,8 +1716,9 @@ function DeepResearchWorkbenchInner() {
}, },
onTerminal: () => { onTerminal: () => {
if (!replayingTerminalTrace) replayedTraceJobs.current.add(streamJobId); if (!replayingTerminalTrace) replayedTraceJobs.current.add(streamJobId);
if (inlineGenerationSession) { const liveInline = inlineGenerationSessionRef.current;
void getResearchSession(inlineGenerationSession.id).then((completed) => { if (liveInline) {
void getResearchSession(liveInline.id).then((completed) => {
setInlineGeneratedReport((current) => current?.session.id === completed.id setInlineGeneratedReport((current) => current?.session.id === completed.id
? { ...current, session: completed } ? { ...current, session: completed }
: current); : current);
@@ -1513,7 +1727,7 @@ function DeepResearchWorkbenchInner() {
console.error("刷新新报告状态失败:", error); console.error("刷新新报告状态失败:", error);
}); });
} }
void Promise.all([refetchSession(), refetchSessions(), refetchSources(), refetchReportVariants()]); refetchLiveJobRef.current();
}, },
onError: (detail) => setRun((current) => current.jobId === streamJobId ? { ...current, error: detail } : current), onError: (detail) => setRun((current) => current.jobId === streamJobId ? { ...current, error: detail } : current),
}, },
@@ -1527,15 +1741,25 @@ function DeepResearchWorkbenchInner() {
} }
pendingReportEvents.current = []; pendingReportEvents.current = [];
}; };
}, [activeJobId, inlineGenerationSession, isAwaitingReview, isRunning, refetchReportVariants, refetchSession, refetchSessions, refetchSources, sandboxSessionId, session?.status, streamJobId]); }, [activeJobId, isAwaitingReview, isRunning, sandboxSessionId, session?.status, streamJobId]);
useEffect(() => { useEffect(() => {
if (!session?.reportMarkdown) return; if (!session?.reportMarkdown) return;
// A background regeneration writes into `run` for the child job. Do not // A background regeneration writes into `run` for the child job. Do not
// copy the parent markdown over that live stream. // copy the parent markdown over that live stream.
if (inlineGenerationSession && inlineGenerationSession.id !== session.id) return; if (inlineGenerationSession && inlineGenerationSession.id !== session.id) return;
setRun((current) => current.jobId === streamJobId ? { ...current, report: session.reportMarkdown! } : current); // A new write must start from a blank sandbox. Copying the previous
}, [inlineGenerationSession, session?.id, session?.reportMarkdown, streamJobId]); // durable report into `run` opened the panel with stale text, then the
// model only thought (summary / reasoning) and the sandbox never grew.
if (isRunning || isAwaitingReview) return;
const lastJobId = typeof session.usage?.lastJobId === "string" ? session.usage.lastJobId : null;
setRun((current) => {
if (current.jobId !== streamJobId) return current;
if (current.jobId && lastJobId !== current.jobId) return current;
if (current.report === session.reportMarkdown) return current;
return { ...current, report: session.reportMarkdown! };
});
}, [inlineGenerationSession, isAwaitingReview, isRunning, session?.id, session?.reportMarkdown, session?.usage?.lastJobId, streamJobId]);
// ── actions ──────────────────────────────────────────────────────────────── // ── actions ────────────────────────────────────────────────────────────────
@@ -1553,6 +1777,10 @@ function DeepResearchWorkbenchInner() {
setFollowUpStream(null); setFollowUpStream(null);
pendingInitialQuery.current = null; pendingInitialQuery.current = null;
finishedTurnKey.current = null; finishedTurnKey.current = null;
persistedCollectorKey.current = "";
persistedCollectorToolIds.current = new Set();
persistedCollectorMaterialKeys.current = new Set();
collectorPersistChain.current = Promise.resolve();
}, []); }, []);
// 历史报告不抢占对话区域:切换研究记录时总是先折叠右侧沙箱;用户可通过 // 历史报告不抢占对话区域:切换研究记录时总是先折叠右侧沙箱;用户可通过
@@ -1602,6 +1830,10 @@ function DeepResearchWorkbenchInner() {
}); });
pendingInitialQuery.current = query; pendingInitialQuery.current = query;
finishedTurnKey.current = null; finishedTurnKey.current = null;
persistedCollectorKey.current = "";
persistedCollectorToolIds.current = new Set();
persistedCollectorMaterialKeys.current = new Set();
collectorPersistChain.current = Promise.resolve();
setSelectedId(created.id); setSelectedId(created.id);
setSandboxOpen(false); setSandboxOpen(false);
} catch (error) { } catch (error) {
@@ -1918,10 +2150,16 @@ function DeepResearchWorkbenchInner() {
if (!activeJobId) return; if (!activeJobId) return;
try { try {
await actions.cancelJob.mutateAsync(activeJobId); await actions.cancelJob.mutateAsync(activeJobId);
toast.success("已请求停止研究"); toast.success("已停止撰写");
await Promise.all([refetchSession(), refetchSessions()]); await Promise.all([refetchSession(), refetchSessions()]);
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "停止研究失败"); const message = error instanceof Error ? error.message : "停止研究失败";
if (/not in a cancellable state/i.test(message) || /409/.test(message)) {
toast.success("已停止撰写");
await Promise.all([refetchSession(), refetchSessions()]);
return;
}
toast.error(message);
} }
}, [actions.cancelJob, activeJobId, refetchSession, refetchSessions]); }, [actions.cancelJob, activeJobId, refetchSession, refetchSessions]);
@@ -1959,14 +2197,23 @@ function DeepResearchWorkbenchInner() {
const handleReopenReportCard = useCallback(async () => { const handleReopenReportCard = useCallback(async () => {
if (!session) return; if (!session) return;
try { try {
await actions.updateStatus.mutateAsync({ sessionId: session.id, status: "awaiting_report" }); await actions.updateStatus.mutateAsync({
sessionId: session.id,
status: "awaiting_report",
collectorMaterials: snapshotCollectorMaterials(collectorThread.messages, {
limit: COLLECTOR_PERSIST_LIMIT,
}),
});
await refetchSession(); await refetchSession();
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "打开报告配置失败"); toast.error(error instanceof Error ? error.message : "打开报告配置失败");
} }
}, [actions.updateStatus, refetchSession, session]); }, [actions.updateStatus, collectorThread.messages, refetchSession, session]);
const handleConfirmReportCard = useCallback(async (config: Record<string, unknown>) => { const handleConfirmReportCard = useCallback(async (
config: Record<string, unknown>,
extras?: { writeWithoutMaterials?: boolean },
) => {
if (!session) return; if (!session) return;
// Keep the composer's explicit choice authoritative when the collection // Keep the composer's explicit choice authoritative when the collection
// phase turns into a report-writing job. // phase turns into a report-writing job.
@@ -1974,19 +2221,44 @@ function DeepResearchWorkbenchInner() {
? { fast_model: selectedModel, smart_model: selectedModel, strategic_model: selectedModel } ? { fast_model: selectedModel, smart_model: selectedModel, strategic_model: selectedModel }
: {}; : {};
try { try {
await actions.startJob.mutateAsync({ const writeWithoutMaterials = extras?.writeWithoutMaterials === true;
const collectorMaterials = writeWithoutMaterials
? []
: snapshotCollectorMaterials(collectorThread.messages, { limit: WRITE_FALLBACK_MATERIAL_LIMIT });
console.warn("[深度研究] browser_snapshot_sending", {
sessionId: session.id, sessionId: session.id,
// First writing must harvest the collector thread before entering the messageCount: collectorThread.messages?.length ?? 0,
// shared static-material report engine. Structural regeneration can toolMessageCount: (collectorThread.messages ?? []).filter((message) => message.type === "tool").length,
// read already-persisted sources, but a first report cannot assume snapshotCount: collectorMaterials.length,
// that selected source rows exist yet. snapshotLimit: WRITE_FALLBACK_MATERIAL_LIMIT,
entry: "chat", writeWithoutMaterials,
config: { ...config, ...modelConfig }, sampleTitles: collectorMaterials.slice(0, 5).map((item) => item.title),
collectorMaterials: snapshotCollectorMaterials(collectorThread.messages),
}); });
const started = await actions.startJob.mutateAsync({
sessionId: session.id,
entry: "chat",
query: session.query,
title: session.title,
config: { ...config, ...modelConfig },
collectorMaterials,
forceNoMaterials: writeWithoutMaterials,
});
console.warn("[深度研究] browser_snapshot_accepted", started);
if (writeWithoutMaterials) {
toast.warning("已按无素材模式启动撰写,对话里收集到的资料本次不会送入写作。");
} else if (!collectorMaterials.length) {
toast.warning("未传入检索素材,将按无素材模式撰写。请看控制台 [深度研究] browser_snapshot_sending");
}
setSandboxOpen(false); setSandboxOpen(false);
toast.success("开始撰写研究报告"); toast.success("开始撰写研究报告");
} catch (error) { } catch (error) {
console.error("[深度研究] write_report_failed", {
sessionId: session.id,
query: session.query,
title: session.title,
detail: error instanceof Error ? error.message : String(error),
error,
});
toast.error(error instanceof Error ? error.message : "启动报告撰写失败"); toast.error(error instanceof Error ? error.message : "启动报告撰写失败");
} }
}, [actions.startJob, collectorThread.messages, selectedModel, session]); }, [actions.startJob, collectorThread.messages, selectedModel, session]);
@@ -2182,7 +2454,7 @@ function DeepResearchWorkbenchInner() {
imageGenerationAvailable={imageGenerationAvailable} imageGenerationAvailable={imageGenerationAvailable}
imageLimit={capabilities.data?.imageGeneration?.maxImages ?? 4} imageLimit={capabilities.data?.imageGeneration?.maxImages ?? 4}
submitting={actions.startJob.isPending} submitting={actions.startJob.isPending}
onConfirm={(config) => void handleConfirmReportCard(config)} onConfirm={(config, extras) => void handleConfirmReportCard(config, extras)}
onContinueCollecting={handleContinueCollecting} onContinueCollecting={handleContinueCollecting}
/> />
); );
@@ -507,7 +507,7 @@ export function ReportConfigCard({
imageGenerationAvailable: boolean; imageGenerationAvailable: boolean;
imageLimit: number; imageLimit: number;
submitting: boolean; submitting: boolean;
onConfirm: (config: Record<string, unknown>) => void; onConfirm: (config: Record<string, unknown>, extras?: { writeWithoutMaterials?: boolean }) => void;
onContinueCollecting: () => void; onContinueCollecting: () => void;
}) { }) {
const [mode, setMode] = useState<ResearchMode>( const [mode, setMode] = useState<ResearchMode>(
@@ -521,6 +521,7 @@ export function ReportConfigCard({
retrievalSkills: retrievalSkillsOverride ?? defaultOptions.retrievalSkills, retrievalSkills: retrievalSkillsOverride ?? defaultOptions.retrievalSkills,
})); }));
const [generateImages, setGenerateImages] = useState(defaultGenerateImages); const [generateImages, setGenerateImages] = useState(defaultGenerateImages);
const [writeWithoutMaterials, setWriteWithoutMaterials] = useState(false);
const [advancedOpen, setAdvancedOpen] = useState(false); const [advancedOpen, setAdvancedOpen] = useState(false);
// The collector composer and this confirmation card are both reachable while // The collector composer and this confirmation card are both reachable while
@@ -572,9 +573,11 @@ export function ReportConfigCard({
下方选项沿用原研究配置并可调整;确认后将基于以上对话收集到的资料撰写报告。 下方选项沿用原研究配置并可调整;确认后将基于以上对话收集到的资料撰写报告。
{modelLabel ? <>写作模型:<span className="font-medium text-foreground">{modelLabel}</span>。</> : null} {modelLabel ? <>写作模型:<span className="font-medium text-foreground">{modelLabel}</span>。</> : null}
</p> </p>
<div className="mt-2 inline-flex items-center gap-1.5 rounded-md border border-primary/15 bg-primary/[0.06] px-2 py-1 text-[11px] text-primary"> <div className={`mt-2 inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-[11px] ${writeWithoutMaterials ? "border-amber-500/30 bg-amber-500/10 text-amber-800 dark:text-amber-200" : "border-primary/15 bg-primary/[0.06] text-primary"}`}>
<span className="font-medium">已收集 {materialCount} 条资料</span> <span className="font-medium">已收集 {materialCount} 条资料</span>
<span className="text-primary/70">将作为本次报告的写作依据</span> <span className={writeWithoutMaterials ? "opacity-80" : "text-primary/70"}>
{writeWithoutMaterials ? "本次不送入写作,按无素材模式成稿" : "将作为本次报告的写作依据"}
</span>
</div> </div>
</div> </div>
</div> </div>
@@ -621,6 +624,19 @@ export function ReportConfigCard({
)} )}
</div> </div>
<label className="mt-3 flex cursor-pointer items-start gap-2 rounded-lg border border-border/70 bg-background/60 p-2.5 text-[11px] leading-4">
<input
type="checkbox"
checked={writeWithoutMaterials}
onChange={(event) => setWriteWithoutMaterials(event.target.checked)}
className="mt-0.5 accent-primary"
/>
<span>
<span className="block font-medium text-foreground">无素材写作</span>
忽略对话里已收集的资料,直接走无素材成稿,用来确认这条链路是否生效。已落库的来源不会被删除。
</span>
</label>
<div className="mt-3 overflow-hidden rounded-xl border border-border/70"> <div className="mt-3 overflow-hidden rounded-xl border border-border/70">
<button <button
type="button" type="button"
@@ -710,7 +726,14 @@ export function ReportConfigCard({
<MessageSquarePlus className="mr-1.5 h-3.5 w-3.5" /> <MessageSquarePlus className="mr-1.5 h-3.5 w-3.5" />
继续补充资料 继续补充资料
</Button> </Button>
<Button size="sm" disabled={submitting} onClick={() => onConfirm(buildReportCardConfig(mode, generateImages, options))}> <Button
size="sm"
disabled={submitting}
onClick={() => onConfirm(
buildReportCardConfig(mode, generateImages, options),
{ writeWithoutMaterials },
)}
>
{submitting && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />} {submitting && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
<Sparkles className="mr-1.5 h-3.5 w-3.5" /> <Sparkles className="mr-1.5 h-3.5 w-3.5" />
开始撰写报告 开始撰写报告
@@ -53,6 +53,13 @@ interface UseReportSandboxOptions {
writing: boolean; writing: boolean;
/** First visible Markdown has arrived; the host can expand the right panel then. */ /** First visible Markdown has arrived; the host can expand the right panel then. */
onFirstReportContent?: () => void; onFirstReportContent?: () => void;
/**
* The writing run has produced something observable (report text or model
* reasoning). Report text streams as non-durable live frames, so on a
* multi-worker backend it can lag well behind the actual write; this lets
* the host open the sandbox on the first sign of progress instead.
*/
activity?: boolean;
/** /**
* A structural regeneration writes a sibling file in the background. Leave * A structural regeneration writes a sibling file in the background. Leave
* the currently displayed report in the sandbox until the user opens that * the currently displayed report in the sandbox until the user opens that
@@ -66,6 +73,7 @@ export function useReportSandbox({
report, report,
writing, writing,
onFirstReportContent, onFirstReportContent,
activity = false,
autoOpen = true, autoOpen = true,
}: UseReportSandboxOptions) { }: UseReportSandboxOptions) {
const { select, setOpen, openStreamedArtifact } = useArtifacts(); const { select, setOpen, openStreamedArtifact } = useArtifacts();
@@ -95,12 +103,35 @@ export function useReportSandbox({
wasWritingRef.current = writing; wasWritingRef.current = writing;
}, [writing]); }, [writing]);
const hadReportRef = useRef(Boolean(report.trim()));
useEffect(() => {
const hasReport = Boolean(report.trim());
// Keep the panel closed through reasoning. When the first Markdown token
// arrives, allow auto-open even if an earlier empty stub was prepared.
if (writing && hasReport && !hadReportRef.current) autoOpenedRef.current = false;
hadReportRef.current = hasReport;
}, [report, writing]);
// 每帧 upsert stub。普通空帧不抹掉已渲染的文件,但写作过程中的 // 每帧 upsert stub。普通空帧不抹掉已渲染的文件,但写作过程中的
// 空正文表示后端要求重置报告版本(report_reset)或新稿尚未出首字, // 空正文表示后端要求重置报告版本(report_reset)或新稿尚未出首字,
// 此时必须清掉旧版,避免把首版失败文本继续展示在沙箱里。 // 此时必须清掉旧版,避免把首版失败文本继续展示在沙箱里。
//
// 清掉的是**正文**而不是文件本身:多 worker 部署下 report_delta 属于
// 进程内 live 帧,观察端可能连在另一个 worker 上,首帧要等 0.8s 的
// 落库轮询(report_chunk)才到。若此时把 stub 置为 null,右栏就没有
// 可打开的虚拟文件,"开始撰写即打开沙箱" 会整段失效。
useEffect(() => { useEffect(() => {
if (!report) { if (!report) {
if (writing) setStubMessage(null); if (writing) {
setStubMessage({
id: messageId,
type: "ai",
content: "",
tool_calls: [
{ id: toolCallId, name: "write_file", args: { path: REPORT_OUTPUT_PATH, content: "" } },
],
});
}
return; return;
} }
setStubMessage((prev) => { setStubMessage((prev) => {
@@ -121,14 +152,14 @@ export function useReportSandbox({
}); });
}, [messageId, report, toolCallId, writing]); }, [messageId, report, toolCallId, writing]);
// 报告首帧到达且仍在写入 → 自动展开右栏并选中虚拟 URL。 // 思考结束后的报告正文首帧到达且仍在写入 → 自动展开右栏并选中虚拟 URL。
// openStreamedArtifact 尊重用户手动关闭(suppress),不会强行弹回。 // openStreamedArtifact 尊重用户手动关闭(suppress),不会强行弹回。
useEffect(() => { useEffect(() => {
if (!autoOpen || !report.trim() || !writing || autoOpenedRef.current) return; if (!autoOpen || (!report.trim() && !activity) || !writing || autoOpenedRef.current) return;
autoOpenedRef.current = true; autoOpenedRef.current = true;
onFirstReportContent?.(); onFirstReportContent?.();
openStreamedArtifact(virtualUrl, artifactPreviewKey(virtualUrl)); openStreamedArtifact(virtualUrl, artifactPreviewKey(virtualUrl));
}, [autoOpen, onFirstReportContent, openStreamedArtifact, report, virtualUrl, writing]); }, [activity, autoOpen, onFirstReportContent, openStreamedArtifact, report, virtualUrl, writing]);
/** 手动打开(头部「报告」按钮 / 完成卡「查看报告」):始终选中虚拟 URL。 */ /** 手动打开(头部「报告」按钮 / 完成卡「查看报告」):始终选中虚拟 URL。 */
const openReport = useCallback(() => { const openReport = useCallback(() => {
+403 -72
View File
@@ -6,9 +6,10 @@
* after_seq 重放),因此前端的快照/SSE/reducer 全链路走的是与真实后端 * after_seq 重放),因此前端的快照/SSE/reducer 全链路走的是与真实后端
* 一致的代码路径。 * 一致的代码路径。
* *
* 演示脚本(后端 POC 固定任务):输入任意报告需求 → 澄清一次 → 生成 3 个 * 演示脚本(后端 POC 固定任务):输入任意报告需求 → 澄清一次 → 规划智能体
* 候选方案 → 选择并开始 → 并行研究(TeamSay/工具调用/流式回复)→ 核验 → * (步骤条 / 思考 / 正文)→ 方案卡 → 选择并开始 → 并行研究(TeamSay/工具调用/
* 写作(report.delta → 正式版本)→ 审稿 → 完成;运行中支持命令确认卡演示。 * 流式回复)→ 核验 → 写作(report.delta → 正式版本)→ 审稿 → 完成;运行中支持
* 命令确认卡演示。
*/ */
import type { ReportCollaborationDriver } from "@/report-collaboration/api/driver"; import type { ReportCollaborationDriver } from "@/report-collaboration/api/driver";
@@ -111,6 +112,7 @@ function aiMessage(
agentRunId: string | null, agentRunId: string | null,
content = "", content = "",
nodeRunId?: string | null, nodeRunId?: string | null,
phaseId?: string | null,
): CollaborationMessage { ): CollaborationMessage {
return { return {
message_id: uid("msg"), message_id: uid("msg"),
@@ -119,7 +121,7 @@ function aiMessage(
name, name,
agent_run_id: agentRunId, agent_run_id: agentRunId,
node_run_id: nodeRunId ?? null, node_run_id: nodeRunId ?? null,
phase_id: agentRunId ? "phase_parallel_research" : null, phase_id: phaseId ?? (agentRunId ? "phase_parallel_research" : null),
visibility: "visible", visibility: "visible",
created_at: now(), created_at: now(),
completed_at: null, completed_at: null,
@@ -163,6 +165,194 @@ function streamAssistant(
}); });
} }
const TEXT_CHUNK = 18;
const TEXT_TICK = 70;
const REASONING_CHUNK = 22;
const REASONING_TICK = 45;
function chunkCount(text: string, size: number): number {
return Math.max(1, Math.ceil(text.length / size));
}
function assistantDurationMs(text: string): number {
return TEXT_TICK * chunkCount(text, TEXT_CHUNK) + 80;
}
function reasoningDurationMs(text: string): number {
return REASONING_TICK * chunkCount(text, REASONING_CHUNK) + 80;
}
/** 流式写入 `metadata.reasoning_content`,适配器映射到问答 CoT 思考区。 */
function streamReasoning(store: MockStore, message: CollaborationMessage, text: string): number {
const first = text.slice(0, REASONING_CHUNK) || text;
const rest = text.slice(first.length);
message.metadata = { ...(message.metadata ?? {}), reasoning_content: first };
store.messages.push(message);
emit(store, "message.created", { message });
const finish = (delay: number) => {
later(store, delay, () => {
message.completed_at = now();
emit(store, "message.completed", { message_id: message.message_id });
});
};
if (!rest) {
finish(reasoningDurationMs(text));
return reasoningDurationMs(text);
}
const chunks: string[] = [];
for (let i = 0; i < rest.length; i += REASONING_CHUNK) {
chunks.push(rest.slice(i, i + REASONING_CHUNK));
}
chunks.forEach((chunk, index) => {
later(store, REASONING_TICK * (index + 1), () => {
const prev = String(message.metadata?.reasoning_content ?? "");
message.metadata = { ...(message.metadata ?? {}), reasoning_content: prev + chunk };
emit(store, "message.delta", {
message_id: message.message_id,
agent_run_id: message.agent_run_id,
node_run_id: message.node_run_id,
delta: "",
reasoning_delta: chunk,
});
if (index === chunks.length - 1) {
message.completed_at = now();
emit(store, "message.completed", { message_id: message.message_id });
}
});
});
return reasoningDurationMs(text);
}
const COORDINATOR_RUN_ID = "agent_run_coordinator";
function ensureCoordinatorRun(store: MockStore, phaseId: string): AgentRun {
const existing = store.agentRuns[COORDINATOR_RUN_ID];
if (existing) {
existing.status = "running";
existing.phase_id = phaseId;
existing.ended_at = undefined;
emit(store, "session.snapshot", { agent_runs: [{ ...existing }], run: { ...store.run } });
return existing;
}
const agentRun: AgentRun = {
agent_run_id: COORDINATOR_RUN_ID,
node_run_id: null,
phase_id: phaseId,
role: "协调者",
display_name: "协调者",
status: "running",
started_at: now(),
};
store.agentRuns[COORDINATOR_RUN_ID] = agentRun;
emit(store, "session.snapshot", { agent_runs: [{ ...agentRun }] });
return agentRun;
}
const PLANNER_THINKING =
"用户已确认读者与覆盖范围。先拆需求要点,再对照可用的研究员、核验、分析、撰写、审稿角色与工具,装配均衡、深度、快速三套协作图,并检查依赖与质量门是否可行。思考和步骤要对用户可见,推荐方案卡放在完整输出之后。";
const PLANNER_OUTPUT = `已根据当前需求装配三种协作策略:
**均衡协作(推荐)**:两条并行研究线(市场规模 / 品牌竞争)→ 交叉核验 → 综合分析 → 单人写作 → 独立审稿。覆盖主要决策角度,团队规模与成本适中,适合大多数正式报告。
**深度并行**:四条研究线(市场 / 品牌 / 海外 / 政策)加双重核验与分角度分析,证据最全,耗时和费用也最高,适合高风险决策。
**快速出稿**:单研究线收窄范围,仍保留核验与审稿,适合要尽快看到初稿的场景。
请在下方卡片中切换预览对应的协作流程;选中后点「开始协作」才会真正创建执行。`;
/** 规划智能体完整回合:步骤条 → 思考 → 正文,最后才由调用方抛出方案卡。 */
function runPlannerAgent(store: MockStore, onDone: () => void): void {
const agentRun = ensureCoordinatorRun(store, "phase_planning");
const processId = `ai-tc-${agentRun.agent_run_id}-planner`;
const tools: { id: string; name: string; args: Record<string, unknown>; result: string }[] = [
{
id: "tc_planner_parse",
name: "write_todos",
args: { description: "解析需求要点" },
result: "已提取读者、必须覆盖的角度与资料约束。",
},
{
id: "tc_planner_search",
name: "web_search",
args: {
query: "多智能体报告协作 角色与工具",
description: "筛选可用智能体与工具",
},
result: JSON.stringify({
roles: ["研究员", "核验员", "分析员", "撰写员", "审稿员"],
tools: ["web_search", "read_file", "write_file", "write_todos"],
}),
},
{
id: "tc_planner_assemble",
name: "write_todos",
args: { description: "装配三种策略的协作图并校验可行性" },
result: "已生成均衡 / 深度 / 快速三套 DAG,依赖与质量门可执行。",
},
];
const toolsStart = 220;
tools.forEach((tool, index) => {
emitToolCall(store, agentRun, toolsStart + index * 280, tool, {
messageId: processId,
callId: tool.id,
resultDelay: 1100 + (tools.length - 1 - index) * 280,
});
});
const thinkStart = toolsStart + (tools.length - 1) * 280 + 1100 + 220;
later(store, thinkStart, () => {
streamReasoning(
store,
aiMessage("协调者", agentRun.agent_run_id, "", null, "phase_planning"),
PLANNER_THINKING,
);
});
const outputStart = thinkStart + reasoningDurationMs(PLANNER_THINKING) + 120;
later(store, outputStart, () => {
streamAssistant(
store,
aiMessage("协调者", agentRun.agent_run_id, "", null, "phase_planning"),
PLANNER_OUTPUT,
);
later(store, assistantDurationMs(PLANNER_OUTPUT), () => {
agentRun.status = "completed";
agentRun.ended_at = now();
emit(store, "session.snapshot", { agent_runs: [{ ...agentRun }] });
onDone();
});
});
}
function sequentialThinkingFor(node: PlanNode): string {
return `先看「${node.label}」的输入与验收:${node.mission ?? "完成本节点任务"}。检查上游产物是否齐全,再调用工具取证或核对,最后给出可交给下游的过程结论,而不是一句「已完成」。`;
}
function sequentialOutputFor(node: PlanNode, isWriter: boolean): string {
if (isWriter) {
return "已按分析结论写完初稿,并写入 /mnt/user-data/outputs/report.md。章节覆盖执行摘要、市场规模、品牌竞争、风险与建议;关键数字均绑定核验后的证据编号,没有手写未核验数据。";
}
if (node.id.includes("verify") || /核验/.test(node.label)) {
return `交叉核验过程如下:\n\n- 市场规模与品牌份额均回溯到 2026 年公开统计,统一为批发销量口径\n- 两条研究线份额差 1.2 个百分点,已按同一时间窗对齐\n- 近 90 天来源占比 6/8,未发现互相矛盾的硬事实\n\nVerificationResult 已交给下游分析。`;
}
if (node.id.includes("review") || /审稿/.test(node.label)) {
return "独立审稿过程:逐章核对结论与证据编号,检查引用是否断裂、结构是否缺章。结构完整,结论可映射到核验后的证据,无高危问题。建议作为正式稿;若需定向修订请指出章节名。";
}
if (node.id.includes("analysis") || /分析/.test(node.label)) {
return "综合分析过程:在核验后的证据上,市场侧增速放缓但高端份额上移;品牌侧价格带分化,技术路线向快充与固态预研并行。主要风险是政策补贴退坡与出口关税,机会在商用车电动化与海外本地化。AngleAnalysis 已提交撰写。";
}
return `「${node.label}」过程结论:${node.mission ?? "本节点任务"}已形成可交付成果,质量门通过,下游可以继续。`;
}
function researchThinkingFor(label: string, mission: string): string {
return `认领「${label}」后先定检索词与时效窗(近 90 天),再跑公开资料检索,最后把可溯源条目整理成证据包交给核验,而不是只给一句摘要。任务:${mission}`;
}
function researchOutputFor(label: string, mission: string): string {
return `「${label}」检索过程:围绕「${mission}」检索公开资料,命中 8 条来源(其中 6 条在近 90 天)。初步判断该方向证据充分,口径暂无冲突。完整条目已写入 EvidenceBundle,提交核验后由下游综合,而不是用简要总结代替过程。`;
}
// ── 演示内容 ───────────────────────────────────────────────────────────────── // ── 演示内容 ─────────────────────────────────────────────────────────────────
const CLARIFY_TEXT = const CLARIFY_TEXT =
@@ -384,7 +574,116 @@ interface ResearchScript {
teamSay: string; teamSay: string;
searchQuery: string; searchQuery: string;
searchSummary: string; searchSummary: string;
reply: string; replyMission: string;
}
function emitToolCall(
store: MockStore,
agentRun: AgentRun,
delay: number,
tool: { name: string; args: Record<string, unknown>; result: string },
options?: { messageId?: string; callId?: string; resultDelay?: number },
): string {
const callId = options?.callId ?? `tc_${agentRun.agent_run_id}_${tool.name}`;
const messageId = options?.messageId ?? `ai-tc-${agentRun.agent_run_id}-${callId}`;
const resultDelay = options?.resultDelay ?? 800;
later(store, delay, () => {
emit(store, "tool_call.created", {
message_id: messageId,
agent_run_id: agentRun.agent_run_id,
node_run_id: agentRun.node_run_id,
tool_call: { id: callId, name: tool.name, args: tool.args },
});
});
later(store, delay + resultDelay, () => {
const toolResultId = `tool-result-${callId}`;
store.messages.push({
message_id: toolResultId,
role: "tool",
content: tool.result,
name: tool.name,
tool_call_id: callId,
agent_run_id: agentRun.agent_run_id,
node_run_id: agentRun.node_run_id ?? null,
phase_id: agentRun.phase_id ?? null,
visibility: "visible",
created_at: now(),
completed_at: now(),
});
emit(store, "tool_result.created", {
message_id: toolResultId,
tool_call_id: callId,
name: tool.name,
agent_run_id: agentRun.agent_run_id,
node_run_id: agentRun.node_run_id,
phase_id: agentRun.phase_id,
content: tool.result,
});
});
return messageId;
}
function startAgentRun(store: MockStore, node: PlanNode, phaseId: string): AgentRun {
const agentRun: AgentRun = {
agent_run_id: `agent_run_${node.id}`,
node_run_id: `${node.id}-attempt1`,
phase_id: phaseId,
role: node.role_display_name ?? node.label,
display_name: node.label,
status: "running",
started_at: now(),
};
store.agentRuns[agentRun.agent_run_id] = agentRun;
store.run.current_phase = phaseId;
emit(store, "session.snapshot", {
agent_runs: [{ ...agentRun }],
run: { ...store.run, current_phase: phaseId },
});
return agentRun;
}
function completeAgentRun(store: MockStore, agentRun: AgentRun, nodeId: string): void {
agentRun.status = "completed";
agentRun.ended_at = now();
emit(store, "session.snapshot", { agent_runs: [{ ...agentRun }] });
setNodeStatus(store, nodeId, "completed");
}
function sequentialToolFor(node: PlanNode): { name: string; args: Record<string, unknown>; result: string } {
if (node.allowed_tools?.includes("web_search")) {
return {
name: "web_search",
args: { query: `${node.label} 新能源汽车 2026`, description: `检索「${node.label}」公开资料` },
result: JSON.stringify({
query: `${node.label} 新能源汽车 2026`,
count: 6,
results: Array.from({ length: 6 }, (_, i) => ({
title: `${node.label} · 来源 ${i + 1}`,
url: `https://example.com/source/${node.id}/${i + 1}`,
published_at: "2026-08-15",
})),
}),
};
}
if (node.id.includes("verify") || /核验/.test(node.label)) {
return {
name: "read_file",
args: { path: "/mnt/user-data/workspace/evidence.json", description: "读取上游证据包并核对口径" },
result: "已核对 8 条来源,关键数字口径一致。",
};
}
if (node.id.includes("review") || /审稿/.test(node.label)) {
return {
name: "read_file",
args: { path: "/mnt/user-data/outputs/report.md", description: "审阅报告全文与引用" },
result: "结构完整,结论与证据一致,无高危问题。",
};
}
return {
name: "write_todos",
args: { description: `整理「${node.label}」分析要点` },
result: "ok",
};
} }
function runResearchAgent(store: MockStore, script: ResearchScript, baseDelay: number): void { function runResearchAgent(store: MockStore, script: ResearchScript, baseDelay: number): void {
@@ -398,6 +697,13 @@ function runResearchAgent(store: MockStore, script: ResearchScript, baseDelay: n
started_at: now(), started_at: now(),
}; };
store.agentRuns[script.agentRunId] = agentRun; store.agentRuns[script.agentRunId] = agentRun;
const thinking = researchThinkingFor(script.searchSummary, script.replyMission);
const output = researchOutputFor(script.searchSummary, script.replyMission);
const processId = `ai-tc-${agentRun.agent_run_id}-process`;
const toolStart = baseDelay + 450;
const thinkStart = toolStart + 950;
const replyStart = thinkStart + reasoningDurationMs(thinking);
const doneStart = replyStart + assistantDurationMs(output);
later(store, baseDelay, () => { later(store, baseDelay, () => {
streamAssistant( streamAssistant(
@@ -408,20 +714,14 @@ function runResearchAgent(store: MockStore, script: ResearchScript, baseDelay: n
); );
}); });
later(store, baseDelay + 500, () => { emitToolCall(
const callId = `tc_${script.agentRunId}`; store,
emit(store, "tool_call.created", { agentRun,
message_id: `ai-tc-${script.agentRunId}-${callId}`, toolStart,
agent_run_id: script.agentRunId, {
tool_call: { id: callId, name: "web_search", args: { query: script.searchQuery } }, name: "web_search",
}); args: { query: script.searchQuery, description: `检索「${script.searchSummary}」公开资料` },
}); result: JSON.stringify({
later(store, baseDelay + 1200, () => {
const callId = `tc_${script.agentRunId}`;
const toolResultId = `tool-result-${callId}`;
const content = JSON.stringify(
{
query: script.searchQuery, query: script.searchQuery,
count: 8, count: 8,
results: Array.from({ length: 8 }, (_, i) => ({ results: Array.from({ length: 8 }, (_, i) => ({
@@ -429,42 +729,28 @@ function runResearchAgent(store: MockStore, script: ResearchScript, baseDelay: n
url: `https://example.com/source/${script.agentRunId}/${i + 1}`, url: `https://example.com/source/${script.agentRunId}/${i + 1}`,
published_at: "2026-08-15", published_at: "2026-08-15",
})), })),
}, }),
null, },
0, { messageId: processId },
);
later(store, thinkStart, () => {
streamReasoning(
store,
aiMessage(script.displayName, script.agentRunId, "", agentRun.node_run_id),
thinking,
); );
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, () => { later(store, replyStart, () => {
streamAssistant( streamAssistant(
store, store,
aiMessage(script.displayName, script.agentRunId, "", agentRun.node_run_id), aiMessage(script.displayName, script.agentRunId, "", agentRun.node_run_id),
script.reply, output,
); );
}); });
later(store, baseDelay + 2600, () => { later(store, doneStart + 200, () => {
setNodeStatus(store, script.nodeId, "validating"); setNodeStatus(store, script.nodeId, "validating");
const artifactId = uid("art"); const artifactId = uid("art");
store.artifactIds.push(artifactId); store.artifactIds.push(artifactId);
@@ -483,7 +769,7 @@ function runResearchAgent(store: MockStore, script: ResearchScript, baseDelay: n
}); });
}); });
later(store, baseDelay + 3100, () => { later(store, doneStart + 700, () => {
setNodeStatus(store, script.nodeId, "completed"); setNodeStatus(store, script.nodeId, "completed");
agentRun.status = "completed"; agentRun.status = "completed";
agentRun.ended_at = now(); agentRun.ended_at = now();
@@ -510,8 +796,7 @@ function executePlan(store: MockStore, plan: ReportPlanCandidate): void {
teamSay: `我认领「${node.label}」任务,先做一轮公开资料检索,重点看近 90 天的数据。`, teamSay: `我认领「${node.label}」任务,先做一轮公开资料检索,重点看近 90 天的数据。`,
searchQuery: `${node.label} 新能源汽车 2026`, searchQuery: `${node.label} 新能源汽车 2026`,
searchSummary: node.label, searchSummary: node.label,
reply: `【${node.label}】初步结论:${node.mission ?? ""}。已收集 8 条来源,其中 6 条为近 90 天信息,暂未发现口径冲突。详细证据已作为 EvidenceBundle 提交核验。`, replyMission: node.mission ?? node.label,
baseDelayUnused: index,
})); }));
researchScripts.forEach((script, index) => { researchScripts.forEach((script, index) => {
@@ -549,14 +834,43 @@ function executePlan(store: MockStore, plan: ReportPlanCandidate): void {
let cursor = 4200; let cursor = 4200;
for (const node of rest) { for (const node of rest) {
const isWriter = node.output_artifact_type === "ReportVersion" && node.id === "n-write"; const isWriter = node.id === "n-write";
const isReview = node.id === "n-review"; const isReview = node.id === "n-review" || /审稿/.test(node.label);
const isRevise = node.id === "n-revise"; const isRevise = node.id === "n-revise";
const delay = cursor; const delay = cursor;
const phaseId = `phase_${node.id}`;
const thinking = sequentialThinkingFor(node);
const output = sequentialOutputFor(node, isWriter);
const processId = `ai-tc-agent_run_${node.id}-process`;
const toolStart = delay + 700;
const thinkStart = isWriter ? delay + 700 : toolStart + 950;
const replyStart = thinkStart + reasoningDurationMs(thinking) + (isWriter ? 800 : 80);
const nodeDuration = replyStart - delay + assistantDurationMs(output) + 900;
later(store, delay, () => setNodeStatus(store, node.id, "queued")); later(store, delay, () => setNodeStatus(store, node.id, "queued"));
later(store, delay + 200, () => setNodeStatus(store, node.id, "running")); later(store, delay + 180, () => {
setNodeStatus(store, node.id, "running");
store.run.current_phase = phaseId;
const agentRun = startAgentRun(store, node, phaseId);
streamAssistant(
store,
aiMessage(agentRun.display_name, agentRun.agent_run_id, "", agentRun.node_run_id, phaseId),
`我开始执行「${node.label}」:${node.mission ?? "处理当前节点任务"}。`,
true,
);
});
if (isWriter) { if (isWriter) {
later(store, delay + 600, () => { later(store, thinkStart, () => {
const agentRun = store.agentRuns[`agent_run_${node.id}`];
if (!agentRun) return;
streamReasoning(
store,
aiMessage(agentRun.display_name, agentRun.agent_run_id, "", agentRun.node_run_id, phaseId),
thinking,
);
});
later(store, thinkStart + reasoningDurationMs(thinking), () => {
const chunks = DEMO_REPORT_MARKDOWN.match(/[\s\S]{1,60}/g) ?? []; const chunks = DEMO_REPORT_MARKDOWN.match(/[\s\S]{1,60}/g) ?? [];
chunks.forEach((chunk, index) => { chunks.forEach((chunk, index) => {
later(store, 60 * index, () => { later(store, 60 * index, () => {
@@ -565,19 +879,36 @@ function executePlan(store: MockStore, plan: ReportPlanCandidate): void {
}); });
}); });
} else { } else {
later(store, delay + 600, () => { later(store, toolStart, () => {
streamAssistant( const agentRun = store.agentRuns[`agent_run_${node.id}`];
if (!agentRun) return;
emitToolCall(store, agentRun, 0, sequentialToolFor(node), { messageId: processId });
});
later(store, thinkStart, () => {
const agentRun = store.agentRuns[`agent_run_${node.id}`];
if (!agentRun) return;
streamReasoning(
store, store,
aiMessage(node.role_display_name ?? node.label, null, "", `${node.id}-attempt1`), aiMessage(agentRun.display_name, agentRun.agent_run_id, "", agentRun.node_run_id, phaseId),
`【${node.label}】${node.mission ?? "处理中"}……完成,成果已通过质量门。`, thinking,
); );
}); });
} }
later(store, delay + (isWriter ? 2600 : 1100), () => {
setNodeStatus(store, node.id, "validating"); later(store, replyStart, () => {
const agentRun = store.agentRuns[`agent_run_${node.id}`];
if (!agentRun) return;
streamAssistant(
store,
aiMessage(agentRun.display_name, agentRun.agent_run_id, "", agentRun.node_run_id, phaseId),
output,
);
}); });
later(store, delay + (isWriter ? 3200 : 1500), () => {
setNodeStatus(store, node.id, "completed"); later(store, delay + nodeDuration - 500, () => setNodeStatus(store, node.id, "validating"));
later(store, delay + nodeDuration, () => {
const agentRun = store.agentRuns[`agent_run_${node.id}`];
if (agentRun) completeAgentRun(store, agentRun, node.id);
if (isWriter) { if (isWriter) {
store.versionCounter += 1; store.versionCounter += 1;
const version: ReportVersion = { const version: ReportVersion = {
@@ -614,7 +945,7 @@ function executePlan(store: MockStore, plan: ReportPlanCandidate): void {
emit(store, "run.status.changed", { status: "completed" }); emit(store, "run.status.changed", { status: "completed" });
} }
}); });
cursor += isWriter ? 3600 : 1900; cursor += nodeDuration;
} }
// 无修订节点的方案:最后一个节点完成后收尾。 // 无修订节点的方案:最后一个节点完成后收尾。
@@ -747,13 +1078,7 @@ const mockDriverImpl: ReportCollaborationDriver = {
} else if (status === "clarifying") { } else if (status === "clarifying") {
store.session.status = "planning"; store.session.status = "planning";
emit(store, "session.snapshot", { session: { ...store.session } }); emit(store, "session.snapshot", { session: { ...store.session } });
const stages = ["解析需求要点", "筛选可用智能体与工具", "装配三种策略的协作图", "校验计划可行性"]; runPlannerAgent(store, () => {
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); store.plans = buildDemoPlans(store.session.requirement_revision);
emit(store, "plan.proposed", { plans: store.plans }); emit(store, "plan.proposed", { plans: store.plans });
store.session.status = "proposal_ready"; store.session.status = "proposal_ready";
@@ -763,8 +1088,8 @@ const mockDriverImpl: ReportCollaborationDriver = {
later(store, 300, () => { later(store, 300, () => {
streamAssistant( streamAssistant(
store, store,
aiMessage("协调者", "agent_run_coordinator"), aiMessage("协调者", COORDINATOR_RUN_ID),
"可以在方案卡中切换预览对应的协作流程;点「采用此方案」后,「开始协作」会正式创建执行。", "上面已经给出三种策略的完整过程与方案卡。选中一张后点「开始协作」即可执行;也可以点「重新生成」让我再跑一轮规划。",
); );
}); });
} else if (status === "completed") { } else if (status === "completed") {
@@ -782,9 +1107,15 @@ const mockDriverImpl: ReportCollaborationDriver = {
async requestPlans(sessionId, expectedRevision) { async requestPlans(sessionId, expectedRevision) {
const store = getStore(sessionId); const store = getStore(sessionId);
if (!store) throw new Error("session not found"); if (!store) throw new Error("session not found");
later(store, 600, () => { store.session.status = "planning";
store.plans = [];
emit(store, "plan.proposed", { plans: [] });
emit(store, "session.snapshot", { session: { ...store.session } });
runPlannerAgent(store, () => {
store.plans = buildDemoPlans(expectedRevision ?? store.session.requirement_revision + 1); store.plans = buildDemoPlans(expectedRevision ?? store.session.requirement_revision + 1);
emit(store, "plan.proposed", { plans: store.plans }); emit(store, "plan.proposed", { plans: store.plans });
store.session.status = "proposal_ready";
emit(store, "session.snapshot", { session: { ...store.session } });
}); });
}, },
@@ -445,6 +445,8 @@ export interface MessageDeltaPayload {
agent_run_id?: string | null; agent_run_id?: string | null;
node_run_id?: string | null; node_run_id?: string | null;
delta: string; delta: string;
/** 思考过程增量,写入消息 `metadata.reasoning_content`,由适配器映射到问答 CoT。 */
reasoning_delta?: string;
} }
export interface MessageCompletedPayload { export interface MessageCompletedPayload {
@@ -7,11 +7,15 @@ import type { AgentRun } from "@/report-collaboration/api/types";
import { roleIcon } from "@/report-collaboration/components/ReportFlowNode"; import { roleIcon } from "@/report-collaboration/components/ReportFlowNode";
interface AgentStageSelectorProps { interface AgentStageSelectorProps {
/** 当前并行波次成员;空数组时不渲染。 */ /** 当前正在执行的成员;空数组且无 pinned 时不渲染。 */
tabs: AgentRun[]; tabs: AgentRun[];
/** 点击画布节点后钉住的历史成员(波次结束后回看步骤条)。 */
pinnedAgent?: AgentRun | null;
phaseLabel?: string | null; phaseLabel?: string | null;
focusedAgentRunId: string | null; focusedAgentRunId: string | null;
onFocus: (agentRunId: string) => void; onFocus: (agentRunId: string) => void;
/** 取消钉住,回到全量时间线。 */
onShowAll?: () => void;
/** 正在流式输出的成员。 */ /** 正在流式输出的成员。 */
streamingAgentRunIds: Set<string>; streamingAgentRunIds: Set<string>;
/** 请求用户协助(待回复)的成员。 */ /** 请求用户协助(待回复)的成员。 */
@@ -45,21 +49,29 @@ function statusLabel(status: ReturnType<typeof avatarStatus>, displayName: strin
} }
/** /**
* 并行波次头像条(对齐会商第二步 sandbox): * 执行中头像条(对齐会商第二步 sandbox):
* 只展示当前同时执行的多个智能体;点头像切换消息列表到该成员的执行过程。 * 单个模型节点与并行波次都展示;点头像切换到该成员的步骤条 / 模型输出。
* 未选中成员的事件继续接收并持久更新。 * 未选中成员的事件继续接收并持久更新。
*/ */
export const AgentStageSelector = memo(function AgentStageSelector({ export const AgentStageSelector = memo(function AgentStageSelector({
tabs, tabs,
pinnedAgent = null,
phaseLabel, phaseLabel,
focusedAgentRunId, focusedAgentRunId,
onFocus, onFocus,
onShowAll,
streamingAgentRunIds, streamingAgentRunIds,
awaitingAgentRunIds, awaitingAgentRunIds,
}: AgentStageSelectorProps) { }: AgentStageSelectorProps) {
if (tabs.length === 0) return null; const visibleTabs =
const focused = focusedAgentRunId ?? tabs[0]?.agent_run_id; tabs.length > 0
const runningCount = tabs.filter((tab) => { ? tabs
: pinnedAgent
? [pinnedAgent]
: [];
if (visibleTabs.length === 0) return null;
const focused = focusedAgentRunId ?? visibleTabs[0]?.agent_run_id;
const runningCount = visibleTabs.filter((tab) => {
const status = avatarStatus( const status = avatarStatus(
tab, tab,
streamingAgentRunIds.has(tab.agent_run_id), streamingAgentRunIds.has(tab.agent_run_id),
@@ -67,15 +79,18 @@ export const AgentStageSelector = memo(function AgentStageSelector({
); );
return status === "running" || status === "awaiting"; return status === "running" || status === "awaiting";
}).length; }).length;
const stripLabel =
phaseLabel ??
(visibleTabs.length > 1 ? `并行 ${visibleTabs.length} 席` : visibleTabs[0]?.display_name ?? "当前节点");
return ( return (
<div className="rc-agent-tabs" role="tablist" aria-label="并行成员"> <div className="rc-agent-tabs" role="tablist" aria-label="执行成员">
<div className="rc-agent-strip-meta"> <div className="rc-agent-strip-meta">
<span>{phaseLabel ?? `并行 ${tabs.length} 席`}</span> <span>{stripLabel}</span>
{runningCount > 0 && <span className="rc-agent-strip-running">{runningCount} 进行中</span>} {runningCount > 0 && <span className="rc-agent-strip-running">{runningCount} 进行中</span>}
</div> </div>
<div className="rc-agent-avatars"> <div className="rc-agent-avatars">
{tabs.map((tab) => { {visibleTabs.map((tab) => {
const streaming = streamingAgentRunIds.has(tab.agent_run_id); const streaming = streamingAgentRunIds.has(tab.agent_run_id);
const awaiting = awaitingAgentRunIds.has(tab.agent_run_id); const awaiting = awaitingAgentRunIds.has(tab.agent_run_id);
const status = avatarStatus(tab, streaming, awaiting); const status = avatarStatus(tab, streaming, awaiting);
@@ -108,6 +123,11 @@ export const AgentStageSelector = memo(function AgentStageSelector({
); );
})} })}
</div> </div>
{onShowAll && (tabs.length <= 1 || Boolean(pinnedAgent)) ? (
<button type="button" className="rc-btn rc-agent-show-all" onClick={onShowAll}>
全部对话
</button>
) : null}
</div> </div>
); );
}); });
@@ -6,7 +6,6 @@ import type { Message } from "@langchain/langgraph-sdk";
import { MessageList } from "@/components/workspace/messages/message-list"; import { MessageList } from "@/components/workspace/messages/message-list";
import { ThreadContext } from "@/components/workspace/messages/context"; import { ThreadContext } from "@/components/workspace/messages/context";
import { ArtifactsProvider } from "@/components/workspace/artifacts";
import { SubtasksProvider } from "@/core/tasks/context"; import { SubtasksProvider } from "@/core/tasks/context";
import type { AgentThreadState } from "@/core/threads"; import type { AgentThreadState } from "@/core/threads";
import type { BaseStream } from "@langchain/langgraph-sdk/react"; import type { BaseStream } from "@langchain/langgraph-sdk/react";
@@ -68,13 +67,11 @@ export function ReportConversationPanel({
return ( return (
<SubtasksProvider> <SubtasksProvider>
<ArtifactsProvider> <div className="flex min-h-0 flex-1 flex-col">
<div className="flex min-h-0 flex-1 flex-col"> {stageSelector}
{stageSelector} {content}
{content} {composer}
{composer} </div>
</div>
</ArtifactsProvider>
</SubtasksProvider> </SubtasksProvider>
); );
} }
@@ -0,0 +1,51 @@
"use client";
import { FilesIcon, XIcon } from "lucide-react";
import { ConversationEmptyState } from "@/components/ai-elements/conversation";
import { Button } from "@/components/ui/button";
import { ArtifactFileDetail, useArtifacts } from "@/components/workspace/artifacts";
/**
* 画布上的沙箱覆盖层:write_file 步骤条自动打开后,在右侧预览报告,
* 不另开第三栏。关闭后归还流程图。
*/
export function ReportSandboxOverlay({ threadId }: { threadId: string }) {
const { open, selectedArtifact, setOpen, deselect } = useArtifacts();
if (!open) return null;
const handleClose = () => {
deselect();
setOpen(false);
};
return (
<div className="rc-sandbox-overlay" role="dialog" aria-label="沙箱预览">
{selectedArtifact ? (
<ArtifactFileDetail
className="size-full"
filepath={selectedArtifact}
threadId={threadId}
onClose={handleClose}
/>
) : (
<div className="relative flex size-full items-center justify-center bg-[var(--rc-bg-panel)]">
<Button
size="icon-sm"
variant="ghost"
className="absolute top-2 right-2"
onClick={handleClose}
aria-label="关闭沙箱"
>
<XIcon />
</Button>
<ConversationEmptyState
icon={<FilesIcon />}
title="沙箱预览"
description="智能体写入文件后将在此预览"
/>
</div>
)}
</div>
);
}
@@ -16,9 +16,16 @@ import type { CollaborationMessage } from "@/report-collaboration/api/types";
import type { CollaborationState } from "@/report-collaboration/state/event-reducer"; import type { CollaborationState } from "@/report-collaboration/state/event-reducer";
import { import {
buildBaseStreamFacade, buildBaseStreamFacade,
buildReportWriteFileMessages,
reportWriteFileBelongsToFocus,
toLangGraphMessages, toLangGraphMessages,
} from "@/report-collaboration/state/message-adapter"; } from "@/report-collaboration/state/message-adapter";
import { selectConversationMessages, selectOrderedVisibleMessages } from "@/report-collaboration/state/selectors"; import {
selectConversationMessages,
selectHeadReportVersion,
selectOrderedVisibleMessages,
selectWriterAgentRun,
} from "@/report-collaboration/state/selectors";
export interface UseReportCollaborationMessagesResult { export interface UseReportCollaborationMessagesResult {
visibleMessages: CollaborationMessage[]; visibleMessages: CollaborationMessage[];
@@ -39,10 +46,23 @@ export function useReportCollaborationMessages(
const visibleMessages = selectOrderedVisibleMessages(state); const visibleMessages = selectOrderedVisibleMessages(state);
const focusedMessages = selectConversationMessages(state, focusedAgentRunId); const focusedMessages = selectConversationMessages(state, focusedAgentRunId);
const base = toLangGraphMessages(focusedMessages); const base = toLangGraphMessages(focusedMessages);
const displayMessages = extraCards.length > 0 ? [...base, ...extraCards] : base; const writeFiles = reportWriteFileBelongsToFocus(
buildReportWriteFileMessages({
sessionId: state.sessionId,
markdown: state.reportDraft?.markdown || selectHeadReportVersion(state)?.markdown || "",
writing: Boolean(state.reportDraft),
writer: selectWriterAgentRun(state),
}),
focusedAgentRunId,
);
const displayMessages =
extraCards.length > 0 || writeFiles.length > 0
? [...base, ...writeFiles, ...extraCards]
: base;
const streamSource = focusedAgentRunId ? focusedMessages : visibleMessages; const streamSource = focusedAgentRunId ? focusedMessages : visibleMessages;
const streaming = const streaming =
streamSource.some((message) => !message.completed_at && message.role !== "human") || streamSource.some((message) => !message.completed_at && message.role !== "human") ||
Boolean(state.reportDraft) ||
extraLoading; extraLoading;
const displayThread = buildBaseStreamFacade(displayMessages, streaming); const displayThread = buildBaseStreamFacade(displayMessages, streaming);
return { visibleMessages, focusedMessages, displayMessages, displayThread, streaming }; return { visibleMessages, focusedMessages, displayMessages, displayThread, streaming };
@@ -1,11 +1,14 @@
"use client"; "use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useLocation, useNavigate, useParams } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
import type { Message } from "@langchain/langgraph-sdk"; import type { Message } from "@langchain/langgraph-sdk";
import { toast } from "sonner"; import { toast } from "sonner";
import { FileText, Sparkles } from "lucide-react"; import { FileText, Sparkles } from "lucide-react";
import { ArtifactsProvider } from "@/components/workspace/artifacts";
import { ThreadContext } from "@/components/workspace/messages/context";
import { SubtasksProvider } from "@/core/tasks/context";
import { getReportCollaborationDriver } from "@/report-collaboration/api/driver"; import { getReportCollaborationDriver } from "@/report-collaboration/api/driver";
import { AgentStageSelector } from "@/report-collaboration/components/AgentStageSelector"; import { AgentStageSelector } from "@/report-collaboration/components/AgentStageSelector";
import { ConnectionStatusBar } from "@/report-collaboration/components/ConnectionStatusBar"; import { ConnectionStatusBar } from "@/report-collaboration/components/ConnectionStatusBar";
@@ -21,6 +24,7 @@ import {
ReportAvailableCard, ReportAvailableCard,
ReportPreviewDialog, ReportPreviewDialog,
} from "@/report-collaboration/components/ReportPreviewDialog"; } from "@/report-collaboration/components/ReportPreviewDialog";
import { ReportSandboxOverlay } from "@/report-collaboration/components/ReportSandboxOverlay";
import { SessionHistoryPanel } from "@/report-collaboration/components/SessionHistoryPanel"; import { SessionHistoryPanel } from "@/report-collaboration/components/SessionHistoryPanel";
import { useReportPanelLayout } from "@/report-collaboration/hooks/useReportPanelLayout"; import { useReportPanelLayout } from "@/report-collaboration/hooks/useReportPanelLayout";
import { useReportCollaborationMessages } from "@/report-collaboration/hooks/useReportCollaborationMessages"; import { useReportCollaborationMessages } from "@/report-collaboration/hooks/useReportCollaborationMessages";
@@ -34,11 +38,11 @@ import {
} from "@/report-collaboration/state/message-adapter"; } from "@/report-collaboration/state/message-adapter";
import { import {
derivePagePhase, derivePagePhase,
selectAgentRunForNode,
selectAgentTabs, selectAgentTabs,
selectAwaitingInputNodes, selectAwaitingInputNodes,
selectHeadReportVersion, selectHeadReportVersion,
selectNodeStatusMap, selectNodeStatusMap,
selectParallelMembers,
selectPendingConfirmationCommands, selectPendingConfirmationCommands,
selectPlanForCanvas, selectPlanForCanvas,
selectPreferredParallelFocus, selectPreferredParallelFocus,
@@ -101,6 +105,7 @@ export default function ReportCollaborationPage() {
); );
return ( return (
<ArtifactsProvider>
<ReportCollaborationLayout <ReportCollaborationLayout
layout={layout} layout={layout}
header={ header={
@@ -151,6 +156,7 @@ export default function ReportCollaborationPage() {
canvas={<ReportFlowCanvas plan={null} nodeStates={{}} selectedNodeId={null} onSelectNode={() => undefined} />} canvas={<ReportFlowCanvas plan={null} nodeStates={{}} selectedNodeId={null} onSelectNode={() => undefined} />}
statusBar={<ConnectionStatusBar status="idle" runId={null} lastSeq={0} />} statusBar={<ConnectionStatusBar status="idle" runId={null} lastSeq={0} />}
/> />
</ArtifactsProvider>
); );
} }
@@ -167,13 +173,21 @@ export function ReportCollaborationSessionPage({ sessionId }: { sessionId: strin
const [focusedAgentRunId, setFocusedAgentRunId] = useState<string | null>(null); const [focusedAgentRunId, setFocusedAgentRunId] = useState<string | null>(null);
const [focusPinned, setFocusPinned] = useState(false); const [focusPinned, setFocusPinned] = useState(false);
const tabIds = useMemo(() => new Set(agentTabs.map((tab) => tab.agent_run_id)), [agentTabs]); const tabIds = useMemo(() => new Set(agentTabs.map((tab) => tab.agent_run_id)), [agentTabs]);
const pinnedAgent =
focusPinned && focusedAgentRunId && !tabIds.has(focusedAgentRunId)
? (state.agentRuns[focusedAgentRunId] ?? null)
: null;
const effectiveFocus = const effectiveFocus =
agentTabs.length === 0 focusPinned && focusedAgentRunId && state.agentRuns[focusedAgentRunId]
? null ? focusedAgentRunId
: focusedAgentRunId && tabIds.has(focusedAgentRunId) : agentTabs.length === 0
? focusedAgentRunId ? null
: agentTabs[0].agent_run_id; : focusedAgentRunId && tabIds.has(focusedAgentRunId)
const focusedTab = agentTabs.find((tab) => tab.agent_run_id === effectiveFocus) ?? null; ? focusedAgentRunId
: agentTabs[0].agent_run_id;
const focusedTab =
agentTabs.find((tab) => tab.agent_run_id === effectiveFocus) ??
(effectiveFocus ? state.agentRuns[effectiveFocus] ?? null : null);
const { previewPlanId, previewPlan, effectivePlanId } = useReportPlanSelection( const { previewPlanId, previewPlan, effectivePlanId } = useReportPlanSelection(
state.plans, state.plans,
@@ -216,14 +230,17 @@ export function ReportCollaborationSessionPage({ sessionId }: { sessionId: strin
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [session.actionError]); }, [session.actionError]);
// 并行波次:默认落到正在执行的成员;点头像/画布后保持该成员,直到波次结束。 // 执行波次:默认落到正在输出的成员;点头像/画布后保持该成员直到该波次结束。
// 全部结束后若用户钉住某个节点,保留回看,不强制清回全量时间线。
useEffect(() => { useEffect(() => {
if (agentTabs.length === 0) { if (agentTabs.length === 0) {
if (focusedAgentRunId) setFocusedAgentRunId(null); if (!focusPinned && focusedAgentRunId) setFocusedAgentRunId(null);
if (focusPinned) setFocusPinned(false);
return; return;
} }
if (focusedAgentRunId && tabIds.has(focusedAgentRunId)) return; if (focusPinned && focusedAgentRunId && tabIds.has(focusedAgentRunId)) return;
if (focusPinned && focusedAgentRunId && !tabIds.has(focusedAgentRunId)) {
setFocusPinned(false);
}
const next = selectPreferredParallelFocus(agentTabs); const next = selectPreferredParallelFocus(agentTabs);
if (next) setFocusedAgentRunId(next); if (next) setFocusedAgentRunId(next);
}, [agentTabs, focusPinned, focusedAgentRunId, tabIds]); }, [agentTabs, focusPinned, focusedAgentRunId, tabIds]);
@@ -236,7 +253,7 @@ export function ReportCollaborationSessionPage({ sessionId }: { sessionId: strin
if ( if (
state.plans.length > 0 && state.plans.length > 0 &&
!runStarted && !runStarted &&
(phase === "proposal_ready" || phase === "planning") phase === "proposal_ready"
) { ) {
cards.push( cards.push(
buildPlanGroupCard(state.plans, state.selectedPlanId, state.session?.requirement_revision ?? 1), buildPlanGroupCard(state.plans, state.selectedPlanId, state.session?.requirement_revision ?? 1),
@@ -273,17 +290,7 @@ export function ReportCollaborationSessionPage({ sessionId }: { sessionId: strin
}, [state]); }, [state]);
const findMemberOfNode = useCallback( const findMemberOfNode = useCallback(
(nodeId: string) => { (nodeId: string) => selectAgentRunForNode(state, nodeId),
const members = selectParallelMembers(state);
return (
members.find((member) => member.node_run_id?.startsWith(`${nodeId}-`)) ??
Object.values(state.agentRuns).find(
(agentRun) =>
agentRun.role !== "协调者" && agentRun.node_run_id?.startsWith(`${nodeId}-`),
) ??
null
);
},
[state], [state],
); );
@@ -291,30 +298,29 @@ export function ReportCollaborationSessionPage({ sessionId }: { sessionId: strin
(nodeId: string | null) => { (nodeId: string | null) => {
setSelectedNodeId(nodeId); setSelectedNodeId(nodeId);
setDrawerOpen(Boolean(nodeId)); setDrawerOpen(Boolean(nodeId));
// 点并行智能体节点时,消息列表切到该成员的执行过程(与头像条同一套聚焦)。
if (nodeId) { if (nodeId) {
const member = findMemberOfNode(nodeId); const member = findMemberOfNode(nodeId);
if (member && tabIds.has(member.agent_run_id)) { if (member) {
setFocusedAgentRunId(member.agent_run_id); setFocusedAgentRunId(member.agent_run_id);
setFocusPinned(true); setFocusPinned(true);
} }
} }
}, },
[findMemberOfNode, tabIds], [findMemberOfNode],
); );
const handleNodeComment = useCallback( const handleNodeComment = useCallback(
(nodeId: string, nodeName: string) => { (nodeId: string, nodeName: string) => {
const member = findMemberOfNode(nodeId); const member = findMemberOfNode(nodeId);
setComposerTarget({ nodeId, nodeName, agentRunId: member?.agent_run_id ?? null }); setComposerTarget({ nodeId, nodeName, agentRunId: member?.agent_run_id ?? null });
if (member && tabIds.has(member.agent_run_id)) { if (member) {
setFocusedAgentRunId(member.agent_run_id); setFocusedAgentRunId(member.agent_run_id);
setFocusPinned(true); setFocusPinned(true);
} }
setDrawerOpen(false); setDrawerOpen(false);
setFocusSignal((signal) => signal + 1); setFocusSignal((signal) => signal + 1);
}, },
[findMemberOfNode, tabIds], [findMemberOfNode],
); );
const handleRestore = useCallback( const handleRestore = useCallback(
@@ -395,7 +401,7 @@ export function ReportCollaborationSessionPage({ sessionId }: { sessionId: strin
? "start" ? "start"
: outgoingIds.size > 0 : outgoingIds.size > 0
? "agent" ? "agent"
: "end") as const, : "end") as "start" | "agent" | "end",
mission: selectedPlanNode?.mission ?? null, mission: selectedPlanNode?.mission ?? null,
input_artifact_types: selectedPlanNode?.input_artifact_types ?? [], input_artifact_types: selectedPlanNode?.input_artifact_types ?? [],
output_artifact_type: selectedPlanNode?.output_artifact_type ?? null, output_artifact_type: selectedPlanNode?.output_artifact_type ?? null,
@@ -438,114 +444,128 @@ export function ReportCollaborationSessionPage({ sessionId }: { sessionId: strin
} }
return ( return (
<> <SubtasksProvider>
<ReportCollaborationLayout <ArtifactsProvider>
layout={layout} <ThreadContext.Provider value={{ thread: displayThread, isMock: true }}>
header={ <ReportCollaborationLayout
<ReportCollaborationHeader layout={layout}
title={state.session?.title ?? "报告协作"} header={
phase={phase} <ReportCollaborationHeader
onBack={() => navigate("/page/workspace/chats/new")} title={state.session?.title ?? "报告协作"}
onNewSession={() => navigate("/page/workspace/report-collaboration")} phase={phase}
onOpenReport={() => { onBack={() => navigate("/page/workspace/chats/new")}
if (headVersion) { onNewSession={() => navigate("/page/workspace/report-collaboration")}
setPreviewVersionId(headVersion.id); onOpenReport={() => {
setPreviewOpen(true); if (headVersion) {
} setPreviewVersionId(headVersion.id);
}} setPreviewOpen(true);
hasReport={Boolean(headVersion)} }
running={runActive} }}
onCancelRun={() => void session.cancelRun()} hasReport={Boolean(headVersion)}
extra={ running={runActive}
layout.isNarrow ? ( onCancelRun={() => void session.cancelRun()}
<NarrowSwitch narrowView={layout.narrowView} setNarrowView={layout.setNarrowView} /> extra={
) : null layout.isNarrow ? (
} <NarrowSwitch narrowView={layout.narrowView} setNarrowView={layout.setNarrowView} />
/> ) : null
}
conversation={
<ConversationSidebarTabs
conversation={
<ReportConversationPanel
sessionId={sessionId}
thread={displayThread}
businessCardSlot={businessCardSlot}
listKey={effectiveFocus ?? "timeline"}
loading={session.snapshotLoading}
stageSelector={
<AgentStageSelector
tabs={agentTabs}
phaseLabel={`并行 ${agentTabs.length} 席`}
focusedAgentRunId={effectiveFocus}
onFocus={(agentRunId) => {
setFocusedAgentRunId(agentRunId);
setFocusPinned(true);
}}
streamingAgentRunIds={streamingIds}
awaitingAgentRunIds={awaitingIds}
/>
}
emptyHint={
effectiveFocus
? `${focusedTab?.display_name ?? "智能体"}正在执行,输出会出现在这里`
: undefined
}
composer={
<ReportComposer
phase={phase}
busy={session.sendBusy}
target={composerTarget}
onClearTarget={() => setComposerTarget(null)}
onSend={(text) =>
void session.send(text, {
nodeId: composerTarget?.nodeId ?? null,
agentRunId: composerTarget?.agentRunId ?? null,
})
}
focusSignal={focusSignal}
/>
} }
/> />
} }
history={<SessionHistoryPanel activeSessionId={sessionId} />} conversation={
<ConversationSidebarTabs
conversation={
<ReportConversationPanel
sessionId={sessionId}
thread={displayThread}
businessCardSlot={businessCardSlot}
listKey={effectiveFocus ?? "timeline"}
loading={session.snapshotLoading}
stageSelector={
<AgentStageSelector
tabs={agentTabs}
pinnedAgent={pinnedAgent}
phaseLabel={
agentTabs.length > 1
? `并行 ${agentTabs.length} 席`
: focusedTab?.display_name ?? (agentTabs[0]?.display_name ?? null)
}
focusedAgentRunId={effectiveFocus}
onFocus={(agentRunId) => {
setFocusedAgentRunId(agentRunId);
setFocusPinned(true);
}}
onShowAll={() => {
setFocusedAgentRunId(null);
setFocusPinned(false);
}}
streamingAgentRunIds={streamingIds}
awaitingAgentRunIds={awaitingIds}
/>
}
emptyHint={
effectiveFocus
? `${focusedTab?.display_name ?? "智能体"}正在执行,输出会出现在这里`
: undefined
}
composer={
<ReportComposer
phase={phase}
busy={session.sendBusy}
target={composerTarget}
onClearTarget={() => setComposerTarget(null)}
onSend={(text) =>
void session.send(text, {
nodeId: composerTarget?.nodeId ?? null,
agentRunId: composerTarget?.agentRunId ?? null,
})
}
focusSignal={focusSignal}
/>
}
/>
}
history={<SessionHistoryPanel activeSessionId={sessionId} />}
/>
}
canvas={
<>
<ReportFlowCanvas
plan={canvasPlan}
nodeStates={nodeStates}
selectedNodeId={selectedNodeId}
onSelectNode={handleSelectNode}
/>
<NodeInspectorDrawer
open={drawerOpen}
nodeId={selectedNodeId}
runId={state.run?.id ?? state.session?.active_run_id ?? null}
fallback={drawerFallback}
onClose={() => setDrawerOpen(false)}
onComment={handleNodeComment}
/>
<ReportSandboxOverlay threadId={sessionId} />
</>
}
statusBar={
<ConnectionStatusBar
status={session.streamStatus.status}
runId={state.run?.id ?? null}
lastSeq={state.lastSeq}
message={session.streamStatus.message}
/>
}
/> />
} <ReportPreviewDialog
canvas={ open={previewOpen}
<> onClose={() => setPreviewOpen(false)}
<ReportFlowCanvas versions={state.reportVersions}
plan={canvasPlan} activeVersionId={previewVersionId}
nodeStates={nodeStates} onSelectVersion={setPreviewVersionId}
selectedNodeId={selectedNodeId} onRestore={(versionId) => void handleRestore(versionId)}
onSelectNode={handleSelectNode} restoreBusy={restoreBusy}
/>
<NodeInspectorDrawer
open={drawerOpen}
nodeId={selectedNodeId}
runId={state.run?.id ?? state.session?.active_run_id ?? null}
fallback={drawerFallback}
onClose={() => setDrawerOpen(false)}
onComment={handleNodeComment}
/>
</>
}
statusBar={
<ConnectionStatusBar
status={session.streamStatus.status}
runId={state.run?.id ?? null}
lastSeq={state.lastSeq}
message={session.streamStatus.message}
/> />
} </ThreadContext.Provider>
/> </ArtifactsProvider>
<ReportPreviewDialog </SubtasksProvider>
open={previewOpen}
onClose={() => setPreviewOpen(false)}
versions={state.reportVersions}
activeVersionId={previewVersionId}
onSelectVersion={setPreviewVersionId}
onRestore={(versionId) => void handleRestore(versionId)}
restoreBusy={restoreBusy}
/>
</>
); );
} }
@@ -588,15 +588,31 @@ function upsertMessage(
}; };
} }
function appendReasoningDelta(
metadata: Record<string, unknown> | null | undefined,
reasoningDelta: string,
): Record<string, unknown> | null {
if (!reasoningDelta) return metadata ?? null;
return {
...(metadata ?? {}),
reasoning_content: `${String(metadata?.reasoning_content ?? "")}${reasoningDelta}`,
};
}
function appendMessageDelta(state: CollaborationState, payload: MessageDeltaPayload): CollaborationState { function appendMessageDelta(state: CollaborationState, payload: MessageDeltaPayload): CollaborationState {
if (!payload.message_id) return state; if (!payload.message_id) return state;
const existing = state.messagesById[payload.message_id]; const existing = state.messagesById[payload.message_id];
const reasoningDelta = payload.reasoning_delta ?? "";
if (existing) { if (existing) {
return { return {
...state, ...state,
messagesById: { messagesById: {
...state.messagesById, ...state.messagesById,
[payload.message_id]: { ...existing, content: existing.content + payload.delta }, [payload.message_id]: {
...existing,
content: existing.content + (payload.delta ?? ""),
metadata: appendReasoningDelta(existing.metadata, reasoningDelta),
},
}, },
}; };
} }
@@ -604,12 +620,13 @@ function appendMessageDelta(state: CollaborationState, payload: MessageDeltaPayl
return upsertMessage(state, { return upsertMessage(state, {
message_id: payload.message_id, message_id: payload.message_id,
role: "ai", role: "ai",
content: payload.delta, content: payload.delta ?? "",
agent_run_id: payload.agent_run_id ?? null, agent_run_id: payload.agent_run_id ?? null,
node_run_id: payload.node_run_id ?? null, node_run_id: payload.node_run_id ?? null,
visibility: "visible", visibility: "visible",
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
completed_at: null, completed_at: null,
metadata: appendReasoningDelta(null, reasoningDelta),
}); });
} }
@@ -699,7 +716,7 @@ function toolResultMessageId(payload: { message_id?: string; tool_call_id: strin
} }
function applyToolResultCreated(state: CollaborationState, payload: ToolResultCreatedPayload): CollaborationState { function applyToolResultCreated(state: CollaborationState, payload: ToolResultCreatedPayload): CollaborationState {
return upsertMessage(state, { const withResult = upsertMessage(state, {
message_id: toolResultMessageId(payload), message_id: toolResultMessageId(payload),
role: "tool", role: "tool",
content: payload.content ?? "", content: payload.content ?? "",
@@ -710,8 +727,30 @@ function applyToolResultCreated(state: CollaborationState, payload: ToolResultCr
phase_id: payload.phase_id ?? null, phase_id: payload.phase_id ?? null,
visibility: "visible", visibility: "visible",
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
completed_at: null, completed_at: new Date().toISOString(),
}); });
return completeToolOnlyOwner(withResult, payload.tool_call_id);
}
/** 工具-only 的 ai 行在全部 tool 返回到达后收尾,避免步骤条一直转圈。 */
function completeToolOnlyOwner(state: CollaborationState, toolCallId: string): CollaborationState {
const owner = Object.values(state.messagesById).find((message) =>
message.tool_calls?.some((call) => call.id === toolCallId),
);
if (!owner || owner.completed_at || owner.content) return state;
const allResolved = (owner.tool_calls ?? []).every((call) =>
Object.values(state.messagesById).some(
(message) => message.role === "tool" && message.tool_call_id === call.id,
),
);
if (!allResolved) return state;
return {
...state,
messagesById: {
...state.messagesById,
[owner.message_id]: { ...owner, completed_at: new Date().toISOString() },
},
};
} }
function applyToolResultDelta(state: CollaborationState, payload: ToolResultDeltaPayload): CollaborationState { function applyToolResultDelta(state: CollaborationState, payload: ToolResultDeltaPayload): CollaborationState {
@@ -17,12 +17,16 @@ import type { BaseStream } from "@langchain/langgraph-sdk/react";
import type { AgentThreadState } from "@/core/threads"; import type { AgentThreadState } from "@/core/threads";
import type { import type {
AgentRun,
CollaborationCommand, CollaborationCommand,
CollaborationMessage, CollaborationMessage,
ReportPlanCandidate, ReportPlanCandidate,
ReportVersion, ReportVersion,
} from "@/report-collaboration/api/types"; } from "@/report-collaboration/api/types";
/** 报告在沙箱中的虚拟路径,对齐问答页 write_file / 深度研究 stub。 */
export const REPORT_OUTPUT_PATH = "/mnt/user-data/outputs/report.md";
// ── 归属标记 ───────────────────────────────────────────────────────────────── // ── 归属标记 ─────────────────────────────────────────────────────────────────
export interface ReportCollaborationKwargs { export interface ReportCollaborationKwargs {
@@ -70,6 +74,12 @@ export function toLangGraphMessage(message: CollaborationMessage): Message {
} as unknown as Message; } as unknown as Message;
} }
// 思考过程:与问答页 additional_kwargs.reasoning_content 对齐,供 MessageGroup CoT 渲染。
const reasoning = message.metadata?.reasoning_content;
if (typeof reasoning === "string" && reasoning.length > 0) {
additional_kwargs.reasoning_content = reasoning;
}
// ai:tool_calls 原样透传(args 流式期间为部分 JSON 字符串)。 // ai:tool_calls 原样透传(args 流式期间为部分 JSON 字符串)。
return { return {
id: message.message_id, id: message.message_id,
@@ -167,6 +177,74 @@ export function buildReportAvailableCard(version: ReportVersion, versionCount: n
}); });
} }
export interface ReportWriteFileSource {
sessionId: string;
markdown: string;
writing: boolean;
writer?: AgentRun | null;
}
/**
* 把 report.delta / 正式版本投影成原生 write_file 消息,让 MessageList
* 步骤条和 ArtifactFileDetail 沙箱走与问答页相同的渲染路径。
*/
export function buildReportWriteFileMessages(source: ReportWriteFileSource): Message[] {
const { markdown, writing, writer } = source;
if (!writing && !markdown) return [];
const sessionId = source.sessionId || "session";
const messageId = `rc-write-${sessionId}-report`;
const toolCallId = `${messageId}-file`;
const additional_kwargs: Record<string, unknown> = {};
if (writer) {
additional_kwargs.report_collaboration = {
agentRunId: writer.agent_run_id,
nodeRunId: writer.node_run_id ?? undefined,
phaseId: writer.phase_id ?? undefined,
};
}
const aiMessage = {
id: messageId,
type: "ai" as const,
content: "",
name: writer?.display_name ?? "撰写员",
tool_calls: [
{
id: toolCallId,
name: "write_file",
args: { path: REPORT_OUTPUT_PATH, content: markdown },
},
],
additional_kwargs,
} as unknown as Message;
if (writing) return [aiMessage];
const toolResult = {
id: `${toolCallId}-result`,
type: "tool" as const,
name: "write_file",
tool_call_id: toolCallId,
content: "ok",
additional_kwargs,
} as unknown as Message;
return [aiMessage, toolResult];
}
/** 注入的 write_file 行在聚焦某成员时只保留撰写员自己的。 */
export function reportWriteFileBelongsToFocus(
messages: Message[],
focusedAgentRunId: string | null,
): Message[] {
if (!focusedAgentRunId) return messages;
return messages.filter((message) => {
const kwargs = (message.additional_kwargs as Record<string, unknown> | undefined)
?.report_collaboration as { agentRunId?: string } | undefined;
return kwargs?.agentRunId === focusedAgentRunId;
});
}
// ── BaseStream 外观 ───────────────────────────────────────────────────────── // ── BaseStream 外观 ─────────────────────────────────────────────────────────
/** /**
@@ -90,14 +90,15 @@ function latestStartedAt(members: AgentRun[]): string {
} }
/** /**
* 当前正在并行执行的那一波成员(不含协调者)。 * 当前正在执行的那一波成员(不含协调者)。
* 同一 phase 下 ≥2 个成员且至少一人仍在执行时才返回;波次结束后回到空数组, * 同一 phase 下只要有人仍在执行就返回——单个模型节点也要露出过程;
* 消息列表按会商第二步收成普通聊天记录。 * 波次结束后回到空数组,消息列表收成普通聊天记录。
* ≥2 人时即为并行波次;1 人时为业务链上的当前节点。
*/ */
export function selectParallelMembers(state: CollaborationState): AgentRun[] { export function selectParallelMembers(state: CollaborationState): AgentRun[] {
const waves = [...membersByPhase(state).entries()] const waves = [...membersByPhase(state).entries()]
.map(([phaseId, list]) => ({ phaseId, list })) .map(([phaseId, list]) => ({ phaseId, list }))
.filter(({ list }) => list.length >= 2 && list.some((member) => isAgentStillExecuting(state, member))); .filter(({ list }) => list.length >= 1 && list.some((member) => isAgentStillExecuting(state, member)));
if (waves.length === 0) return []; if (waves.length === 0) return [];
const currentPhase = state.run?.current_phase ?? null; const currentPhase = state.run?.current_phase ?? null;
const chosen = const chosen =
@@ -106,11 +107,37 @@ export function selectParallelMembers(state: CollaborationState): AgentRun[] {
return [...chosen.list].sort((a, b) => (a.started_at ?? "").localeCompare(b.started_at ?? "")); return [...chosen.list].sort((a, b) => (a.started_at ?? "").localeCompare(b.started_at ?? ""));
} }
/** 头像条:仅当前并行波次的执行成员(不含协调者)。 */ /** 头像条:当前正在执行的成员(单个模型节点或并行波次,不含协调者)。 */
export function selectAgentTabs(state: CollaborationState): AgentRun[] { export function selectAgentTabs(state: CollaborationState): AgentRun[] {
return selectParallelMembers(state); return selectParallelMembers(state);
} }
/** 画布节点 → 对应成员(含已完成的顺序节点,供点击后回看该节点的步骤条)。 */
export function selectAgentRunForNode(
state: CollaborationState,
nodeId: string | null,
): AgentRun | null {
if (!nodeId) return null;
const members = Object.values(state.agentRuns).filter((agentRun) => {
const nodeRunId = agentRun.node_run_id;
return typeof nodeRunId === "string" && (nodeRunId === nodeId || nodeRunId.startsWith(`${nodeId}-`));
});
if (members.length === 0) return null;
return (
members.find((member) => isAgentStillExecuting(state, member)) ??
[...members].sort((a, b) => (b.started_at ?? "").localeCompare(a.started_at ?? ""))[0]
);
}
/** 撰写节点对应的成员(report.delta 注入 write_file 时挂到该归属)。 */
export function selectWriterAgentRun(state: CollaborationState): AgentRun | null {
const byRole = Object.values(state.agentRuns).find(
(agentRun) => /撰写|writer/i.test(agentRun.role) || /撰写/.test(agentRun.display_name),
);
if (byRole) return byRole;
return selectAgentRunForNode(state, "n-write");
}
/** 波次刚出现或当前聚焦已失效时,优先落到正在输出的成员。 */ /** 波次刚出现或当前聚焦已失效时,优先落到正在输出的成员。 */
export function selectPreferredParallelFocus( export function selectPreferredParallelFocus(
members: AgentRun[], members: AgentRun[],
@@ -309,6 +309,23 @@
flex-shrink: 0; flex-shrink: 0;
} }
.rc-agent-show-all {
margin-left: auto;
flex-shrink: 0;
}
/* 画布上的沙箱覆盖层:write_file 打开后占满右侧,关闭归还流程图 */
.rc-sandbox-overlay {
position: absolute;
inset: 0;
z-index: 22;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--rc-bg-panel);
animation: rc-drawer-in 0.18s ease-out;
}
.rc-agent-strip-meta { .rc-agent-strip-meta {
display: flex; display: flex;
flex-shrink: 0; flex-shrink: 0;
@@ -99,6 +99,32 @@ test("message.delta 多次更新同一 messageId,completed 收尾", () => {
assert.equal(state.lastSeq, 4); assert.equal(state.lastSeq, 4);
}); });
test("message.delta 的 reasoning_delta 累加到 metadata.reasoning_content", () => {
let state = applySessionSnapshot(createInitialCollaborationState(SESSION), snapshot());
state = applyCollaborationEvents(state, [
ev(1, "message.created", {
message: {
message_id: "m_think",
role: "ai",
content: "",
agent_run_id: "coord",
visibility: "visible",
metadata: { reasoning_content: "先" },
created_at: "2026-09-02T10:00:01Z",
completed_at: null,
},
}),
ev(2, "message.delta", {
message_id: "m_think",
agent_run_id: "coord",
delta: "",
reasoning_delta: "拆需求",
}),
]);
assert.equal(state.messagesById.m_think.content, "");
assert.equal(state.messagesById.m_think.metadata?.reasoning_content, "先拆需求");
});
test("乱序事件先缓冲,缺口补齐后按序 drain", () => { test("乱序事件先缓冲,缺口补齐后按序 drain", () => {
let state = applySessionSnapshot(createInitialCollaborationState(SESSION), snapshot()); let state = applySessionSnapshot(createInitialCollaborationState(SESSION), snapshot());
// seq=2 先到 → 缓冲并出现缺口 // seq=2 先到 → 缓冲并出现缺口
@@ -151,6 +177,7 @@ test("工具调用挂在所属 ai 消息,返回以 tool 消息配对 tool_call
assert.equal(toolMessage.tool_call_id, "tc1"); assert.equal(toolMessage.tool_call_id, "tc1");
assert.equal(state.messageOrder[0], "ai_owner"); assert.equal(state.messageOrder[0], "ai_owner");
assert.equal(state.messageOrder[1], "tr1"); assert.equal(state.messageOrder[1], "tr1");
assert.ok(owner.completed_at, "工具-only 的 ai 行在 tool 返回后应收尾,避免步骤条一直转圈");
}); });
test("节点状态机守卫:cancelled 后晚到 completed 被忽略", () => { test("节点状态机守卫:cancelled 后晚到 completed 被忽略", () => {
@@ -6,6 +6,8 @@ import {
buildCommandConfirmCard, buildCommandConfirmCard,
buildPlanGroupCard, buildPlanGroupCard,
buildBaseStreamFacade, buildBaseStreamFacade,
buildReportWriteFileMessages,
REPORT_OUTPUT_PATH,
toLangGraphMessages, toLangGraphMessages,
} from "../state/message-adapter"; } from "../state/message-adapter";
import { selectMessagesForAgentRun, selectAgentTabs, selectParallelMembers, selectConversationMessages, stitchParallelAgentBlocks } from "../state/selectors"; import { selectMessagesForAgentRun, selectAgentTabs, selectParallelMembers, selectConversationMessages, stitchParallelAgentBlocks } from "../state/selectors";
@@ -84,7 +86,7 @@ function agentRun(partial: Partial<AgentRun> & Pick<AgentRun, "agent_run_id" | "
}; };
} }
test("并行头像条:同一 phase 且至少两人仍在执行时才出现,不含协调者", () => { test("头像条:同一 phase 有人仍在执行即出现,单个模型节点也会露出,不含协调者", () => {
const state = createInitialCollaborationState("s1"); const state = createInitialCollaborationState("s1");
state.agentRuns = { state.agentRuns = {
coord: agentRun({ coord: agentRun({
@@ -105,6 +107,26 @@ test("并行头像条:同一 phase 且至少两人仍在执行时才出现,
assert.equal(selectParallelMembers(state).length, 2); assert.equal(selectParallelMembers(state).length, 2);
}); });
test("头像条:业务链上的单个执行节点也会出现", () => {
const state = createInitialCollaborationState("s1");
state.agentRuns = {
writer: agentRun({
agent_run_id: "agent_run_n-write",
display_name: "报告撰写",
role: "撰写员",
phase_id: "phase_n-write",
node_run_id: "n-write-attempt1",
status: "running",
}),
};
state.nodes = {
"n-write": { node_id: "n-write", label: "报告撰写", status: "running", attempt: 1, attempts: [] },
};
const tabs = selectAgentTabs(state);
assert.equal(tabs.length, 1);
assert.equal(tabs[0].agent_run_id, "agent_run_n-write");
});
test("并行波次全部结束后头像条消失,消息列表恢复全量", () => { test("并行波次全部结束后头像条消失,消息列表恢复全量", () => {
const state = createInitialCollaborationState("s1"); const state = createInitialCollaborationState("s1");
state.agentRuns = { state.agentRuns = {
@@ -227,6 +249,81 @@ test("方案组卡可被 groupMessages 识别且不产生正文气泡", () => {
assert.deepEqual(types, ["assistant:business-card"]); assert.deepEqual(types, ["assistant:business-card"]);
}); });
test("规划过程:思考+步骤条映射后排在方案卡之前,不是一句总结", () => {
const process: CollaborationMessage = {
message_id: "plan_process",
role: "ai",
content: "",
name: "协调者",
agent_run_id: "agent_run_coordinator",
visibility: "visible",
created_at: "t",
completed_at: "t",
tool_calls: [{ id: "tc_parse", name: "write_todos", args: { description: "解析需求要点" } }],
};
const toolResult: CollaborationMessage = {
message_id: "tr_parse",
role: "tool",
content: "已提取读者与角度",
name: "write_todos",
tool_call_id: "tc_parse",
agent_run_id: "agent_run_coordinator",
visibility: "visible",
created_at: "t",
completed_at: "t",
};
const thinking: CollaborationMessage = {
message_id: "plan_think",
role: "ai",
content: "",
name: "协调者",
agent_run_id: "agent_run_coordinator",
visibility: "visible",
created_at: "t",
completed_at: "t",
metadata: { reasoning_content: "先拆读者与覆盖范围,再装配三种策略。" },
};
const output = ai(
"plan_out",
"agent_run_coordinator",
"已装配均衡、深度、快速三种协作策略,下面给出方案卡。",
);
output.completed_at = "t";
const mapped = toLangGraphMessages([process, toolResult, thinking, output]);
const kwargs = (mapped[2] as { additional_kwargs?: Record<string, unknown> }).additional_kwargs;
assert.equal(kwargs?.reasoning_content, "先拆读者与覆盖范围,再装配三种策略。");
const card = buildPlanGroupCard(
[
{
id: "p1",
proposal_group_id: "g1",
title: "方案",
strategy: "balanced_review",
summary: "",
rationale: "",
estimated_duration_seconds: 1,
estimated_cost_level: "low",
requirement_revision: 1,
roles: [],
nodes: [],
edges: [],
quality_gates: [],
report_structure_id: null,
validation: { ok: true, errors: [] },
revision: 1,
status: "proposed",
},
],
null,
1,
);
assert.deepEqual(groupMessages([...mapped, card], (group) => group.type), [
"assistant:processing",
"assistant",
"assistant:business-card",
]);
});
test("BaseStream 外观提供 MessageList 所需字段", () => { test("BaseStream 外观提供 MessageList 所需字段", () => {
const thread = buildBaseStreamFacade([], true); const thread = buildBaseStreamFacade([], true);
assert.deepEqual(thread.messages, []); assert.deepEqual(thread.messages, []);
@@ -234,3 +331,39 @@ test("BaseStream 外观提供 MessageList 所需字段", () => {
assert.equal(thread.isThreadLoading, false); assert.equal(thread.isThreadLoading, false);
assert.equal(typeof thread.stop, "function"); assert.equal(typeof thread.stop, "function");
}); });
test("report.delta 投影为 write_file:流式中无 tool 返回,完成后补 tool 结果", () => {
const writing = buildReportWriteFileMessages({
sessionId: "s1",
markdown: "# 草稿",
writing: true,
writer: agentRun({
agent_run_id: "agent_run_n-write",
display_name: "报告撰写",
role: "撰写员",
}),
});
assert.equal(writing.length, 1);
const aiMessage = writing[0] as { type: string; tool_calls?: { name: string; args: { path: string; content: string } }[] };
assert.equal(aiMessage.type, "ai");
assert.equal(aiMessage.tool_calls?.[0].name, "write_file");
assert.equal(aiMessage.tool_calls?.[0].args.path, REPORT_OUTPUT_PATH);
assert.equal(aiMessage.tool_calls?.[0].args.content, "# 草稿");
const done = buildReportWriteFileMessages({
sessionId: "s1",
markdown: "# 正式稿",
writing: false,
writer: null,
});
assert.equal(done.length, 2);
assert.equal(done[1].type, "tool");
assert.equal((done[1] as { tool_call_id?: string }).tool_call_id, `${writing[0].id}-file`);
});
test("空草稿且未在写入时不注入 write_file", () => {
assert.deepEqual(
buildReportWriteFileMessages({ sessionId: "s1", markdown: "", writing: false }),
[],
);
});
@@ -174,12 +174,20 @@ export function isTerminalJobStatus(status: JobStatus): boolean {
async function readJson<T>(res: Response, ctx: string): Promise<T> { async function readJson<T>(res: Response, ctx: string): Promise<T> {
if (!res.ok) { if (!res.ok) {
let detail = `${ctx} failed (${res.status})`; let detail = `${ctx} failed (${res.status})`;
let body: unknown;
try { try {
const body = await res.json(); body = await res.json();
detail = body.detail || detail; const record = body && typeof body === "object" ? body as Record<string, unknown> : {};
detail = typeof record.detail === "string" ? record.detail : detail;
} catch { } catch {
// ignore parse error // ignore parse error
} }
console.error(`[深度研究] ${ctx}_failed`, {
status: res.status,
url: res.url,
detail,
body,
});
throw new Error(detail); throw new Error(detail);
} }
return res.json() as Promise<T>; return res.json() as Promise<T>;
@@ -323,12 +331,25 @@ export async function getSession(id: string): Promise<DeepResearchSession> {
export async function updateSession( export async function updateSession(
id: string, id: string,
patch: { title?: string; status?: "collecting" | "awaiting_report" }, patch: {
title?: string;
status?: "collecting" | "awaiting_report";
collectorMaterials?: Record<string, unknown>[];
},
): Promise<DeepResearchSession> { ): Promise<DeepResearchSession> {
const res = await apiFetch(`${BASE()}/sessions/${id}`, { const res = await apiFetch(`${BASE()}/sessions/${id}`, {
method: "PATCH", method: "PATCH",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(patch), body: JSON.stringify({
...(patch.title !== undefined ? { title: patch.title } : {}),
...(patch.status !== undefined ? { status: patch.status } : {}),
...(patch.collectorMaterials !== undefined
? {
collector_materials: patch.collectorMaterials,
collectorMaterials: patch.collectorMaterials,
}
: {}),
}),
}); });
return normalizeSession(await readJson<WireRecord>(res, "updateSession")); return normalizeSession(await readJson<WireRecord>(res, "updateSession"));
} }
@@ -352,27 +373,90 @@ export interface StartJobOptions {
entry?: "chat" | "legacy" | "regenerate"; entry?: "chat" | "legacy" | "regenerate";
/** Report-config card choices merged over the frozen session snapshot. */ /** Report-config card choices merged over the frozen session snapshot. */
config?: Record<string, unknown>; config?: Record<string, unknown>;
/** Topic text frozen by the browser. Empty materials still write a report. */
query?: string;
title?: string;
/** Compact collector evidence frozen before the report job starts. */ /** Compact collector evidence frozen before the report job starts. */
collectorMaterials?: Record<string, unknown>[]; collectorMaterials?: Record<string, unknown>[];
/** Skip harvested / leftover sources and take the no-material writer. */
forceNoMaterials?: boolean;
} }
export async function startJob( export async function startJob(
sessionId: string, sessionId: string,
optionsOrRequestId?: StartJobOptions | string, optionsOrRequestId?: StartJobOptions | string,
): Promise<{ jobId: string; sessionId: string; status: string; reused: boolean }> { ): Promise<{
jobId: string;
sessionId: string;
status: string;
reused: boolean;
collectorMaterialCount?: number;
persistedSourceCount?: number;
diagnostics?: Array<{
code?: string;
stage?: string;
message?: string;
recoverable?: boolean;
errorType?: string;
detail?: string;
[key: string]: unknown;
}>;
}> {
const opts: StartJobOptions = const opts: StartJobOptions =
typeof optionsOrRequestId === "string" ? { requestId: optionsOrRequestId } : optionsOrRequestId ?? {}; typeof optionsOrRequestId === "string" ? { requestId: optionsOrRequestId } : optionsOrRequestId ?? {};
const res = await apiFetch(`${BASE()}/sessions/${sessionId}/jobs`, { const entry = opts.entry ?? "legacy";
const collectorMaterials = opts.collectorMaterials ?? (entry === "chat" ? [] : null);
// First write posts materials to the dedicated report API. GET /jobs/{id}/stream
// only replays progress and cannot carry evidence.
const path = entry === "chat"
? `${BASE()}/sessions/${sessionId}/report`
: `${BASE()}/sessions/${sessionId}/jobs`;
const res = await apiFetch(path, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify(
request_id: opts.requestId ?? createClientId(), entry === "chat"
entry: opts.entry ?? "legacy", ? {
config: opts.config ?? null, request_id: opts.requestId ?? createClientId(),
collector_materials: opts.collectorMaterials ?? null, query: opts.query ?? "",
}), title: opts.title ?? "",
config: opts.config ?? null,
collector_materials: collectorMaterials ?? [],
collectorMaterials: collectorMaterials ?? [],
force_no_materials: opts.forceNoMaterials === true,
forceNoMaterials: opts.forceNoMaterials === true,
}
: {
request_id: opts.requestId ?? createClientId(),
entry,
config: opts.config ?? null,
collector_materials: collectorMaterials,
collectorMaterials,
},
),
}); });
return readJson(res, "startJob"); const started = await readJson<{
jobId: string;
sessionId: string;
status: string;
reused: boolean;
collectorMaterialCount?: number;
persistedSourceCount?: number;
diagnostics?: Array<{
code?: string;
stage?: string;
message?: string;
recoverable?: boolean;
errorType?: string;
detail?: string;
[key: string]: unknown;
}>;
}>(res, entry === "chat" ? "writeReport" : "startJob");
for (const item of started.diagnostics ?? []) {
const code = String(item.code || "write_report_diagnostic");
console.warn(`[深度研究] ${code}`, item);
}
return started;
} }
export async function getJob(jobId: string): Promise<DeepResearchJob> { export async function getJob(jobId: string): Promise<DeepResearchJob> {
@@ -384,6 +468,9 @@ export async function cancelJob(
jobId: string, jobId: string,
): Promise<{ jobId: string; status: string }> { ): Promise<{ jobId: string; status: string }> {
const res = await apiFetch(`${BASE()}/jobs/${jobId}/cancel`, { method: "POST" }); const res = await apiFetch(`${BASE()}/jobs/${jobId}/cancel`, { method: "POST" });
if (res.status === 409) {
return { jobId, status: "cancelled" };
}
return readJson(res, "cancelJob"); return readJson(res, "cancelJob");
} }
@@ -402,6 +489,54 @@ export async function resumeJob(
// ── SSE streaming ──────────────────────────────────────────────────────────── // ── SSE streaming ────────────────────────────────────────────────────────────
const DIAGNOSTIC_EVENT_TYPES = new Set([
"warning",
"error",
"report_reset",
]);
/**
* Print backend diagnostics in the browser console.
*
* Production operators cannot always read Gateway logs. These frames already
* travel on the job SSE stream; echoing them here makes the real exception
* (errorType / diagnostic.detail / code) copyable from DevTools.
*/
function logDeepResearchDiagnostic(event: ProgressEvent): void {
const payload = event.payload ?? {};
const jobFailed =
event.type === "job_status" &&
(Boolean(payload.errorCode) || Boolean(payload.errorMessage) || payload.status === "failed");
const degraded = payload.eventPersistenceDegraded === true;
const emptyCollection =
event.type === "collection_completed" && Number(payload.materialCount ?? 0) === 0;
if (!DIAGNOSTIC_EVENT_TYPES.has(event.type) && !jobFailed && !degraded && !emptyCollection) return;
const summary = {
jobId: event.jobId,
sessionId: event.sessionId,
seq: event.seq,
type: event.type,
phase: event.phase,
code: payload.code ?? payload.errorCode ?? event.type,
message: payload.message ?? payload.errorMessage,
errorType: payload.errorType,
detail: payload.detail,
recoverable: payload.recoverable,
reason: payload.reason,
status: payload.status,
diagnostic: payload.diagnostic,
diagnostics: payload.diagnostics,
payload,
};
const label = `[深度研究] ${String(summary.code)}`;
if (event.type === "error" || jobFailed) {
console.error(label, summary);
return;
}
console.warn(label, summary);
}
export interface StreamJobHandlers { export interface StreamJobHandlers {
onEvent: (event: ProgressEvent) => void; onEvent: (event: ProgressEvent) => void;
onTerminal?: (status: string) => void; onTerminal?: (status: string) => void;
@@ -428,7 +563,9 @@ export function streamJob(
{ method: "GET", signal: controller.signal }, { method: "GET", signal: controller.signal },
); );
if (!res.ok || !res.body) { if (!res.ok || !res.body) {
handlers.onError?.(`stream failed (${res.status})`); const detail = `stream failed (${res.status})`;
console.error("[深度研究] stream_open_failed", { jobId, afterSeq, status: res.status });
handlers.onError?.(detail);
return; return;
} }
const reader = res.body.getReader(); const reader = res.body.getReader();
@@ -460,6 +597,7 @@ export function streamJob(
} catch { } catch {
continue; continue;
} }
logDeepResearchDiagnostic(event);
handlers.onEvent(event); handlers.onEvent(event);
// Check for terminal status in the payload. // Check for terminal status in the payload.
@@ -475,7 +613,9 @@ export function streamJob(
} }
} catch (err) { } catch (err) {
if (!controller.signal.aborted) { if (!controller.signal.aborted) {
handlers.onError?.(err instanceof Error ? err.message : String(err)); const detail = err instanceof Error ? err.message : String(err);
console.error("[深度研究] stream_disconnected", { jobId, afterSeq, detail, error: err });
handlers.onError?.(detail);
} }
} }
})(); })();
@@ -1010,13 +1150,19 @@ export function useResearchActions() {
sessionId, sessionId,
entry, entry,
config, config,
query,
title,
collectorMaterials, collectorMaterials,
forceNoMaterials,
}: { }: {
sessionId: string; sessionId: string;
entry?: "chat" | "legacy" | "regenerate"; entry?: "chat" | "legacy" | "regenerate";
config?: Record<string, unknown>; config?: Record<string, unknown>;
query?: string;
title?: string;
collectorMaterials?: Record<string, unknown>[]; collectorMaterials?: Record<string, unknown>[];
}) => startJob(sessionId, { entry, config, collectorMaterials }), forceNoMaterials?: boolean;
}) => startJob(sessionId, { entry, config, query, title, collectorMaterials, forceNoMaterials }),
onSuccess: (_result, { sessionId }) => { onSuccess: (_result, { sessionId }) => {
queryClient.invalidateQueries({ queryKey: [DEEP_RESEARCH_SESSIONS_KEY] }); queryClient.invalidateQueries({ queryKey: [DEEP_RESEARCH_SESSIONS_KEY] });
queryClient.invalidateQueries({ queryKey: [DEEP_RESEARCH_SESSION_KEY, sessionId] }); queryClient.invalidateQueries({ queryKey: [DEEP_RESEARCH_SESSION_KEY, sessionId] });
@@ -1026,15 +1172,32 @@ export function useResearchActions() {
mutationFn: ({ mutationFn: ({
sessionId, sessionId,
status, status,
collectorMaterials,
}: { }: {
sessionId: string; sessionId: string;
status: "collecting" | "awaiting_report"; status: "collecting" | "awaiting_report";
}) => updateSession(sessionId, { status }), collectorMaterials?: Record<string, unknown>[];
}) => updateSession(sessionId, { status, collectorMaterials }),
onSuccess: (_result, { sessionId }) => { onSuccess: (_result, { sessionId }) => {
queryClient.invalidateQueries({ queryKey: [DEEP_RESEARCH_SESSIONS_KEY] }); queryClient.invalidateQueries({ queryKey: [DEEP_RESEARCH_SESSIONS_KEY] });
queryClient.invalidateQueries({ queryKey: [DEEP_RESEARCH_SESSION_KEY, sessionId] }); queryClient.invalidateQueries({ queryKey: [DEEP_RESEARCH_SESSION_KEY, sessionId] });
}, },
}), }),
persistCollectorMaterials: useMutation({
mutationFn: ({
sessionId,
collectorMaterials,
}: {
sessionId: string;
collectorMaterials: Record<string, unknown>[];
quiet?: boolean;
}) => updateSession(sessionId, { collectorMaterials }),
onSuccess: (_result, { sessionId, quiet }) => {
if (quiet) return;
queryClient.invalidateQueries({ queryKey: [DEEP_RESEARCH_SESSIONS_KEY] });
queryClient.invalidateQueries({ queryKey: [DEEP_RESEARCH_SESSION_KEY, sessionId] });
},
}),
cancelJob: useMutation({ cancelJob: useMutation({
mutationFn: (jobId: string) => cancelJob(jobId), mutationFn: (jobId: string) => cancelJob(jobId),
onSuccess: () => { onSuccess: () => {
+51 -20
View File
@@ -1,17 +1,48 @@
import { existsSync } from "node:fs";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import { defineConfig } from "vite"; import { defineConfig, type Plugin } from "vite";
import sourceIdentifierPlugin from "vite-plugin-source-identifier"; import sourceIdentifierPlugin from "vite-plugin-source-identifier";
const rootDir = fileURLToPath(new URL(".", import.meta.url)); const rootDir = fileURLToPath(new URL(".", import.meta.url));
// 依赖预构建缓存被重建后(pnpm install / 重复启动 vite / --force),
// 残留的旧浏览器标签页仍会按旧版本号(?v=<hash>)请求已删除的 chunk,
// 每次都在终端刷 "The file does not exist ... in the optimize deps directory"。
// 该插件拦截这类「文件确实不存在」的 deps 请求,返回一段触发 location.reload()
// 的模块脚本,让旧页面自愈到新版本;sessionStorage 防止真正的坏页面无限刷新。
const staleDepsChunkSelfReload = (): Plugin => ({
name: "stale-deps-chunk-self-reload",
configureServer(server) {
const depsDir = path.join(rootDir, "node_modules", ".vite", "deps");
server.middlewares.use((req, res, next) => {
const match = /^\/node_modules\/\.vite\/deps\/([^?]+)/.exec(req.url ?? "");
if (!match) {
next();
return;
}
const filePath = path.join(depsDir, decodeURIComponent(match[1]));
if (existsSync(filePath)) {
next();
return;
}
res.setHeader("Content-Type", "application/javascript");
res.end(
"if(!sessionStorage.getItem('__vite_stale_chunk_reloaded__')){" +
"sessionStorage.setItem('__vite_stale_chunk_reloaded__','1');" +
"location.reload();}",
);
});
},
});
// Tailwind 4 emits CSS cascade layers, which require Chrome 99+. Chrome 100 // Tailwind 4 emits CSS cascade layers, which require Chrome 99+. Chrome 100
// is therefore the practical lower bound for this Win7 legacy build. // is therefore the practical lower bound for this Win7 legacy build.
const LEGACY_CHROME = 100 << 16; const LEGACY_CHROME = 100 << 16;
export default defineConfig({ export default defineConfig({
plugins: [sourceIdentifierPlugin(), react()], plugins: [staleDepsChunkSelfReload(), sourceIdentifierPlugin(), react()],
css: { css: {
// Also used by the production CSS minifier. Development CSS is handled // Also used by the production CSS minifier. Development CSS is handled
// by the matching PostCSS compatibility pass in postcss.config.js. // by the matching PostCSS compatibility pass in postcss.config.js.
@@ -21,20 +52,20 @@ export default defineConfig({
}, },
}, },
}, },
resolve: { resolve: {
alias: [ alias: [
{ {
// react-syntax-highlighter@16.1.1 exposes an async `sqlMore` // react-syntax-highlighter@16.1.1 exposes an async `sqlMore`
// loader, but its own highlight.js@10.x dependency has no // loader, but its own highlight.js@10.x dependency has no
// `sql_more` module. Reuse the bundled SQL grammar so Rollup can // `sql_more` module. Reuse the bundled SQL grammar so Rollup can
// resolve every generated language loader without leaving a broken // resolve every generated language loader without leaving a broken
// browser-time external import. // browser-time external import.
find: /^highlight\.js\/lib\/languages\/sql_more$/, find: /^highlight\.js\/lib\/languages\/sql_more$/,
replacement: "highlight.js/lib/languages/sql", replacement: "highlight.js/lib/languages/sql",
}, },
{ {
find: "@/env", find: "@/env",
replacement: path.resolve(rootDir, "src/env.ts"), replacement: path.resolve(rootDir, "src/env.ts"),
}, },
{ {
find: "next/navigation", find: "next/navigation",
@@ -64,9 +95,9 @@ export default defineConfig({
target: "chrome100", target: "chrome100",
}, },
server: { server: {
// 与 scripts/env.sh FRONTEND_PORT=5174、圆桌 wujie 嵌入文档一致。 // 与 scripts/env.sh FRONTEND_PORT=5174、圆桌 wujie 嵌入文档一致。
port: 5173, port: 5173,
// 无界宿主跨域 fetch 子应用静态资源时,Vite 需回显请求的 Origin。 // 无界宿主跨域 fetch 子应用静态资源时,Vite 需回显请求的 Origin。
cors: true, cors: true,
}, },
}); });