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:
1 parent
e58da3fbdf
commit
438772f0b7
25 files changed
+1740
-399
No files matched your search
@@ -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 |
@@ -3,6 +3,7 @@
|
||||
import { ChevronRightIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import deerMaskUrl from "@/assets/deer.svg";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FlickeringGrid } from "@/components/ui/flickering-grid";
|
||||
import Galaxy from "@/components/ui/galaxy";
|
||||
@@ -29,7 +30,11 @@ export function Hero({ className }: { className?: string }) {
|
||||
/>
|
||||
</div>
|
||||
<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}
|
||||
gridGap={4}
|
||||
color={"white"}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
SearchIcon,
|
||||
SparklesIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import {
|
||||
Conversation,
|
||||
@@ -165,9 +165,6 @@ function MultiRoundRagTraceView({
|
||||
onSelectRetrieval: (item: MultiRoundRagRetrieval) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(!trace.thinkingComplete);
|
||||
useEffect(() => {
|
||||
setOpen(!trace.thinkingComplete);
|
||||
}, [trace.thinkingComplete]);
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
@@ -208,6 +205,7 @@ function MultiRoundRagTraceView({
|
||||
index={index + 1}
|
||||
round={round}
|
||||
isLoading={isLoading}
|
||||
isActiveRound={isLoading && index === trace.rounds.length - 1}
|
||||
rehypePlugins={rehypePlugins}
|
||||
onSelectRetrieval={onSelectRetrieval}
|
||||
/>
|
||||
@@ -222,20 +220,22 @@ function MultiRoundRagRoundCard({
|
||||
index,
|
||||
round,
|
||||
isLoading,
|
||||
isActiveRound,
|
||||
rehypePlugins,
|
||||
onSelectRetrieval,
|
||||
}: {
|
||||
index: number;
|
||||
round: MultiRoundRagRound;
|
||||
isLoading: boolean;
|
||||
isActiveRound: boolean;
|
||||
rehypePlugins: MessageResponseProps["rehypePlugins"];
|
||||
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);
|
||||
useEffect(() => {
|
||||
setOpen(searching || !round.query);
|
||||
}, [round.id, round.query, searching]);
|
||||
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
@@ -254,7 +254,7 @@ function MultiRoundRagRoundCard({
|
||||
<Loader2Icon className="size-3 animate-spin" />
|
||||
检索中
|
||||
</span>
|
||||
) : (
|
||||
) : isActiveRound ? null : (
|
||||
<span className="text-muted-foreground text-[11px]">已完成</span>
|
||||
)}
|
||||
<ChevronDownIcon
|
||||
@@ -277,7 +277,7 @@ function MultiRoundRagRoundCard({
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
{round.queryShow && (round.query || searching) && (
|
||||
{showQuery && (
|
||||
<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"
|
||||
aria-live={searching ? "polite" : undefined}
|
||||
@@ -291,11 +291,11 @@ function MultiRoundRagRoundCard({
|
||||
{round.queryTitle || "检索词"}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-xs font-medium">
|
||||
{round.query || "正在生成检索词…"}
|
||||
{query}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{round.queryShow && (
|
||||
{showRetrievals && (
|
||||
<section>
|
||||
<div className="mb-2 flex items-center gap-2 text-sm font-medium">
|
||||
<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"]);
|
||||
});
|
||||
|
||||
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", () => {
|
||||
const types = groupTypes([
|
||||
{
|
||||
|
||||
@@ -141,6 +141,16 @@ export function groupMessages<T>(
|
||||
}
|
||||
|
||||
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
|
||||
// workbench (additional_kwargs.setup_research_approval) render through
|
||||
// the host-provided slot. Writing phases use ordinary virtual tool calls
|
||||
|
||||
@@ -144,20 +144,22 @@ function platformTypeChange(platform: string) {
|
||||
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 {
|
||||
return {
|
||||
title: "多轮检索思考过程",
|
||||
rounds: [
|
||||
{
|
||||
id: createId("multi-rag-round"),
|
||||
title: "正在准备检索",
|
||||
thoughtMarkdown: "",
|
||||
query: "",
|
||||
queryLoading: true,
|
||||
queryShow: true,
|
||||
retrievals: [],
|
||||
},
|
||||
],
|
||||
rounds: [createEmptyRound("正在准备检索")],
|
||||
thinkingComplete: false,
|
||||
finalAnswerMarkdown: "",
|
||||
};
|
||||
@@ -370,16 +372,8 @@ export function applyMultiRoundToolCall(trace: MultiRoundRagTrace, toolValue: un
|
||||
round.thoughtMarkdown = mergeThink(round.thoughtMarkdown, thinkContent);
|
||||
} else {
|
||||
round.queryLoading = false;
|
||||
round.queryShow = round.retrievals.length > 0;
|
||||
trace.rounds.push({
|
||||
id: createId("multi-rag-round"),
|
||||
title: "思考中",
|
||||
thoughtMarkdown: thinkContent,
|
||||
query: "",
|
||||
queryLoading: true,
|
||||
queryShow: true,
|
||||
retrievals: [],
|
||||
});
|
||||
round.queryShow = Boolean(round.query) || round.retrievals.length > 0;
|
||||
trace.rounds.push(createEmptyRound("思考中", thinkContent));
|
||||
}
|
||||
} else {
|
||||
round.queryLoading = false;
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useParams, useRouter } from "next/navigation";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { useRouteBase } from "@/core/route-base";
|
||||
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 { Button } from "@/components/ui/button";
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
} from "@/components/workspace/agents/agent-guide";
|
||||
import { AgentWelcome } from "@/components/workspace/agent-welcome";
|
||||
import { ArtifactTrigger } from "@/components/workspace/artifacts";
|
||||
import { listThreadArtifacts } from "@/core/artifacts/listing";
|
||||
import { ChatBox, useThreadChat } from "@/components/workspace/chats";
|
||||
import { ExportTrigger } from "@/components/workspace/export-trigger";
|
||||
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_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 {
|
||||
if (typeof window === "undefined" || !agentId) return null;
|
||||
try {
|
||||
@@ -504,9 +514,11 @@ export default function AgentChatPage({
|
||||
// ── agentfx「入库」协助卡 ──────────────────────────────────────────────────
|
||||
// 任务工作区(taskBind)里,内置研判智能体写出的 task-reports.json 产物落地且
|
||||
// 本轮流式结束后,向消息流尾部注入「是否入库」卡片。本地沙箱下 artifacts 可能
|
||||
// 记录宿主真实路径,按文件名匹配(虚拟/宿主路径都命中)。任务已有详情**不抑制**
|
||||
// 卡片(入库是整体替换,由卡片文案说明覆盖语义);已导入按 thread 记忆在
|
||||
// sessionStorage(刷新后不再重复询问)、「暂不入库」为本次会话内记忆。
|
||||
// 记录宿主真实路径,按文件名匹配(虚拟/宿主路径都命中)。转换脚本已函数合并
|
||||
// 写出该文件,即使模型中断、没跑 present_files,也再扫一遍 outputs 目录。
|
||||
// 任务已有详情**不抑制**卡片(入库是整体替换,由卡片文案说明覆盖语义);
|
||||
// 已导入按 thread 记忆在 sessionStorage(刷新后不再重复询问)、
|
||||
// 「暂不入库」为本次会话内记忆。
|
||||
const reportImportSessionKey = threadId
|
||||
? `agentfx.reportImport.done.${threadId}`
|
||||
: "";
|
||||
@@ -519,9 +531,19 @@ export default function AgentChatPage({
|
||||
}
|
||||
});
|
||||
const [reportImportDismissed, setReportImportDismissed] = useState(false);
|
||||
const reportJsonArtifactReady = (thread.values.artifacts ?? []).some((path) =>
|
||||
path.replace(/\\/g, "/").endsWith("/task-reports.json"),
|
||||
);
|
||||
const listedReportJsonQuery = useQuery({
|
||||
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 =
|
||||
taskBind &&
|
||||
!taskGate &&
|
||||
|
||||
@@ -99,6 +99,7 @@ import {
|
||||
extractToolResultPayload,
|
||||
hasToolCalls,
|
||||
} from "@/core/messages/utils";
|
||||
import { normalizeSearchResults } from "@/core/messages/search-results";
|
||||
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
|
||||
import {
|
||||
type DeepResearchSession,
|
||||
@@ -276,7 +277,18 @@ function assembleStreamingReport(targets: Record<string, { content: string; vers
|
||||
const REPORT_REFUSAL_PATTERN = /(?:无法|不能|未能|不可以).{0,24}?(?:生成|撰写|产出|输出).{0,12}?报告/s;
|
||||
|
||||
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 {
|
||||
@@ -422,68 +434,97 @@ function countCollectorMaterials(messages: readonly Message[] | undefined): numb
|
||||
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. */
|
||||
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 [];
|
||||
const limit = options?.limit ?? COLLECTOR_PERSIST_LIMIT;
|
||||
const frozen: Record<string, unknown>[] = [];
|
||||
const seen = new Set<string>();
|
||||
const text = (value: unknown) => typeof value === "string" ? value.trim() : "";
|
||||
const addCandidate = (candidate: unknown, toolName: string, index: number) => {
|
||||
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) return;
|
||||
const item = candidate as Record<string, unknown>;
|
||||
const url = text(item.url) || text(item.source_url) || text(item.link);
|
||||
const id = text(item.id) || text(item.recUuid) || text(item.rec_uuid) || text(item.uuid);
|
||||
const title = text(item.title) || text(item.name) || `检索结果 ${index + 1}`;
|
||||
const content = text(item.content) || text(item.raw_content) || text(item.snippet)
|
||||
|| text(item.content_preview) || text(item.text);
|
||||
const addNormalized = (item: ReturnType<typeof normalizeSearchResults>[number], toolName: string) => {
|
||||
const url = text(item.url);
|
||||
const id = text(item.id) || text(item.recUuid) || text(item.uuid) || text(item.docId);
|
||||
const title = text(item.title) || `检索结果 ${frozen.length + 1}`;
|
||||
const content = text(item.content) || text(item.snippet);
|
||||
if (!url && !id && !content) return;
|
||||
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);
|
||||
frozen.push({
|
||||
id: id || undefined,
|
||||
title: title.slice(0, 500),
|
||||
url: url.slice(0, 2_000) || undefined,
|
||||
raw_content: content.slice(0, 6_000),
|
||||
snippet: content.slice(0, 500) || undefined,
|
||||
snippet: (text(item.snippet) || content).slice(0, 500) || undefined,
|
||||
source: text(item.source) || toolName || "collector",
|
||||
source_type: toolName === "web_search" ? "web" : "other",
|
||||
published_at: text(item.publish_time) || text(item.published_at) || text(item.time) || undefined,
|
||||
rec_uuid: text(item.recUuid) || text(item.rec_uuid) || undefined,
|
||||
published_at: text(item.published_date) || text(item.publishedDate) || text(item.date) || 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) {
|
||||
if (message.type === "tool") {
|
||||
visitPayload(extractToolResultPayload(message), message.name || "collector_tool");
|
||||
if (message.type === "tool" && message.name && SKIP_COLLECTOR_PERSIST_TOOLS.has(message.name)) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -583,9 +624,18 @@ function DeepResearchWorkbenchInner() {
|
||||
const replayedTraceJobs = useRef(new Set<string>());
|
||||
const pendingReportEvents = useRef<ProgressEvent[]>([]);
|
||||
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.
|
||||
const pendingInitialQuery = 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 refetchSession = sessionQuery.refetch;
|
||||
@@ -594,6 +644,22 @@ function DeepResearchWorkbenchInner() {
|
||||
const refetchReportVariants = reportVariants.refetch;
|
||||
|
||||
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 byId = new Map((reportVariants.data ?? []).map((item) => [item.id, item]));
|
||||
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.
|
||||
const refusalReportDetected = isRefusalReportText(rawDisplayedReport);
|
||||
const hasUsableDisplayedReport = Boolean(rawDisplayedReport.trim()) && !refusalReportDetected;
|
||||
// A completed session may retain an old diagnostic in `session.error` even
|
||||
// after the backend has produced a usable fallback report. That historical
|
||||
// diagnostic must not be appended to an otherwise normal report.
|
||||
const streamStillOpen = Boolean(run.jobId && run.phase !== "done" && run.phase !== "failed");
|
||||
const liveWriting = Boolean(
|
||||
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(
|
||||
refusalReportDetected
|
||||
|| (!hasUsableDisplayedReport && (session?.error || session?.status === "failed")),
|
||||
!liveWriting && !hasUsableDisplayedReport && (
|
||||
refusalReportDetected
|
||||
|| session?.status === "failed"
|
||||
|| (Boolean(session?.error) && !repairOnlyError)
|
||||
),
|
||||
);
|
||||
const runtimeDiagnosticText = JSON.stringify(
|
||||
session?.runtimeDiagnostics ?? {
|
||||
@@ -793,10 +872,19 @@ function DeepResearchWorkbenchInner() {
|
||||
report: displayedReport,
|
||||
writing: sandboxWriting,
|
||||
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
|
||||
// a new sibling. Stay put if the user has already switched to another card.
|
||||
autoOpen: viewingLiveGeneration || reportDocumentRewrite.isActive,
|
||||
});
|
||||
openReportRef.current = openReport;
|
||||
inlineGenerationSessionRef.current = inlineGenerationSession;
|
||||
refetchLiveJobRef.current = () => {
|
||||
void Promise.all([refetchSession(), refetchSessions(), refetchSources(), refetchReportVariants()]);
|
||||
};
|
||||
const sandboxVisible = sandboxOpen && Boolean(
|
||||
displayedReport || isRunning || isAwaitingReview || reportDocumentRewrite.isActive || isGeneratingNewReport,
|
||||
);
|
||||
@@ -805,6 +893,13 @@ function DeepResearchWorkbenchInner() {
|
||||
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
|
||||
// sandbox file first, then open it from an effect once useReportSandbox has
|
||||
// rebuilt its URL for the generated report session.
|
||||
@@ -1103,6 +1198,66 @@ function DeepResearchWorkbenchInner() {
|
||||
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.
|
||||
// Requiring a non-human tail closes the window between the optimistic user
|
||||
// message and the run flag flipping, where the turn would look "ended".
|
||||
@@ -1119,7 +1274,20 @@ function DeepResearchWorkbenchInner() {
|
||||
if (finishedTurnKey.current === key) return;
|
||||
finishedTurnKey.current = key;
|
||||
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,
|
||||
@@ -1127,10 +1295,45 @@ function DeepResearchWorkbenchInner() {
|
||||
collectorPendingClarification,
|
||||
collectorStreaming,
|
||||
collectorTailIsHuman,
|
||||
collectorThread.messages,
|
||||
isChatSession,
|
||||
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(
|
||||
() => countCollectorMaterials(collectorThread.messages),
|
||||
[collectorThread.messages],
|
||||
@@ -1384,18 +1587,21 @@ function DeepResearchWorkbenchInner() {
|
||||
if (!streamJobId || (!isRunning && !isAwaitingReview && !replayingTerminalTrace)) return;
|
||||
if (replayingTerminalTrace && replayedTraceJobs.current.has(streamJobId)) return;
|
||||
if (replayingTerminalTrace) replayedTraceJobs.current.add(streamJobId);
|
||||
seenEventKeys.current = new Set();
|
||||
liveEventSerial.current = 0;
|
||||
pendingReportEvents.current = [];
|
||||
setRun({ ...EMPTY_RUN, jobId: streamJobId });
|
||||
// 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(
|
||||
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 = () => {
|
||||
reportAnimationFrame.current = null;
|
||||
@@ -1471,6 +1677,13 @@ function DeepResearchWorkbenchInner() {
|
||||
: eventKey(event);
|
||||
if (seenEventKeys.current.has(key)) return;
|
||||
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 (
|
||||
event.type === "plan_delta" ||
|
||||
event.type === "plan_reasoning" ||
|
||||
@@ -1503,8 +1716,9 @@ function DeepResearchWorkbenchInner() {
|
||||
},
|
||||
onTerminal: () => {
|
||||
if (!replayingTerminalTrace) replayedTraceJobs.current.add(streamJobId);
|
||||
if (inlineGenerationSession) {
|
||||
void getResearchSession(inlineGenerationSession.id).then((completed) => {
|
||||
const liveInline = inlineGenerationSessionRef.current;
|
||||
if (liveInline) {
|
||||
void getResearchSession(liveInline.id).then((completed) => {
|
||||
setInlineGeneratedReport((current) => current?.session.id === completed.id
|
||||
? { ...current, session: completed }
|
||||
: current);
|
||||
@@ -1513,7 +1727,7 @@ function DeepResearchWorkbenchInner() {
|
||||
console.error("刷新新报告状态失败:", error);
|
||||
});
|
||||
}
|
||||
void Promise.all([refetchSession(), refetchSessions(), refetchSources(), refetchReportVariants()]);
|
||||
refetchLiveJobRef.current();
|
||||
},
|
||||
onError: (detail) => setRun((current) => current.jobId === streamJobId ? { ...current, error: detail } : current),
|
||||
},
|
||||
@@ -1527,15 +1741,25 @@ function DeepResearchWorkbenchInner() {
|
||||
}
|
||||
pendingReportEvents.current = [];
|
||||
};
|
||||
}, [activeJobId, inlineGenerationSession, isAwaitingReview, isRunning, refetchReportVariants, refetchSession, refetchSessions, refetchSources, sandboxSessionId, session?.status, streamJobId]);
|
||||
}, [activeJobId, isAwaitingReview, isRunning, sandboxSessionId, session?.status, streamJobId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session?.reportMarkdown) return;
|
||||
// A background regeneration writes into `run` for the child job. Do not
|
||||
// copy the parent markdown over that live stream.
|
||||
if (inlineGenerationSession && inlineGenerationSession.id !== session.id) return;
|
||||
setRun((current) => current.jobId === streamJobId ? { ...current, report: session.reportMarkdown! } : current);
|
||||
}, [inlineGenerationSession, session?.id, session?.reportMarkdown, streamJobId]);
|
||||
// A new write must start from a blank sandbox. Copying the previous
|
||||
// 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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1553,6 +1777,10 @@ function DeepResearchWorkbenchInner() {
|
||||
setFollowUpStream(null);
|
||||
pendingInitialQuery.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;
|
||||
finishedTurnKey.current = null;
|
||||
persistedCollectorKey.current = "";
|
||||
persistedCollectorToolIds.current = new Set();
|
||||
persistedCollectorMaterialKeys.current = new Set();
|
||||
collectorPersistChain.current = Promise.resolve();
|
||||
setSelectedId(created.id);
|
||||
setSandboxOpen(false);
|
||||
} catch (error) {
|
||||
@@ -1918,10 +2150,16 @@ function DeepResearchWorkbenchInner() {
|
||||
if (!activeJobId) return;
|
||||
try {
|
||||
await actions.cancelJob.mutateAsync(activeJobId);
|
||||
toast.success("已请求停止研究");
|
||||
toast.success("已停止撰写");
|
||||
await Promise.all([refetchSession(), refetchSessions()]);
|
||||
} 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]);
|
||||
|
||||
@@ -1959,14 +2197,23 @@ function DeepResearchWorkbenchInner() {
|
||||
const handleReopenReportCard = useCallback(async () => {
|
||||
if (!session) return;
|
||||
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();
|
||||
} catch (error) {
|
||||
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;
|
||||
// Keep the composer's explicit choice authoritative when the collection
|
||||
// phase turns into a report-writing job.
|
||||
@@ -1974,19 +2221,44 @@ function DeepResearchWorkbenchInner() {
|
||||
? { fast_model: selectedModel, smart_model: selectedModel, strategic_model: selectedModel }
|
||||
: {};
|
||||
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,
|
||||
// First writing must harvest the collector thread before entering the
|
||||
// shared static-material report engine. Structural regeneration can
|
||||
// read already-persisted sources, but a first report cannot assume
|
||||
// that selected source rows exist yet.
|
||||
entry: "chat",
|
||||
config: { ...config, ...modelConfig },
|
||||
collectorMaterials: snapshotCollectorMaterials(collectorThread.messages),
|
||||
messageCount: collectorThread.messages?.length ?? 0,
|
||||
toolMessageCount: (collectorThread.messages ?? []).filter((message) => message.type === "tool").length,
|
||||
snapshotCount: collectorMaterials.length,
|
||||
snapshotLimit: WRITE_FALLBACK_MATERIAL_LIMIT,
|
||||
writeWithoutMaterials,
|
||||
sampleTitles: collectorMaterials.slice(0, 5).map((item) => item.title),
|
||||
});
|
||||
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);
|
||||
toast.success("开始撰写研究报告");
|
||||
} 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 : "启动报告撰写失败");
|
||||
}
|
||||
}, [actions.startJob, collectorThread.messages, selectedModel, session]);
|
||||
@@ -2182,7 +2454,7 @@ function DeepResearchWorkbenchInner() {
|
||||
imageGenerationAvailable={imageGenerationAvailable}
|
||||
imageLimit={capabilities.data?.imageGeneration?.maxImages ?? 4}
|
||||
submitting={actions.startJob.isPending}
|
||||
onConfirm={(config) => void handleConfirmReportCard(config)}
|
||||
onConfirm={(config, extras) => void handleConfirmReportCard(config, extras)}
|
||||
onContinueCollecting={handleContinueCollecting}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -507,7 +507,7 @@ export function ReportConfigCard({
|
||||
imageGenerationAvailable: boolean;
|
||||
imageLimit: number;
|
||||
submitting: boolean;
|
||||
onConfirm: (config: Record<string, unknown>) => void;
|
||||
onConfirm: (config: Record<string, unknown>, extras?: { writeWithoutMaterials?: boolean }) => void;
|
||||
onContinueCollecting: () => void;
|
||||
}) {
|
||||
const [mode, setMode] = useState<ResearchMode>(
|
||||
@@ -521,6 +521,7 @@ export function ReportConfigCard({
|
||||
retrievalSkills: retrievalSkillsOverride ?? defaultOptions.retrievalSkills,
|
||||
}));
|
||||
const [generateImages, setGenerateImages] = useState(defaultGenerateImages);
|
||||
const [writeWithoutMaterials, setWriteWithoutMaterials] = useState(false);
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
|
||||
// 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}
|
||||
</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="text-primary/70">将作为本次报告的写作依据</span>
|
||||
<span className={writeWithoutMaterials ? "opacity-80" : "text-primary/70"}>
|
||||
{writeWithoutMaterials ? "本次不送入写作,按无素材模式成稿" : "将作为本次报告的写作依据"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -621,6 +624,19 @@ export function ReportConfigCard({
|
||||
)}
|
||||
</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">
|
||||
<button
|
||||
type="button"
|
||||
@@ -710,7 +726,14 @@ export function ReportConfigCard({
|
||||
<MessageSquarePlus className="mr-1.5 h-3.5 w-3.5" />
|
||||
继续补充资料
|
||||
</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" />}
|
||||
<Sparkles className="mr-1.5 h-3.5 w-3.5" />
|
||||
开始撰写报告
|
||||
|
||||
@@ -53,6 +53,13 @@ interface UseReportSandboxOptions {
|
||||
writing: boolean;
|
||||
/** First visible Markdown has arrived; the host can expand the right panel then. */
|
||||
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
|
||||
* the currently displayed report in the sandbox until the user opens that
|
||||
@@ -66,6 +73,7 @@ export function useReportSandbox({
|
||||
report,
|
||||
writing,
|
||||
onFirstReportContent,
|
||||
activity = false,
|
||||
autoOpen = true,
|
||||
}: UseReportSandboxOptions) {
|
||||
const { select, setOpen, openStreamedArtifact } = useArtifacts();
|
||||
@@ -95,12 +103,35 @@ export function useReportSandbox({
|
||||
wasWritingRef.current = 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。普通空帧不抹掉已渲染的文件,但写作过程中的
|
||||
// 空正文表示后端要求重置报告版本(report_reset)或新稿尚未出首字,
|
||||
// 此时必须清掉旧版,避免把首版失败文本继续展示在沙箱里。
|
||||
//
|
||||
// 清掉的是**正文**而不是文件本身:多 worker 部署下 report_delta 属于
|
||||
// 进程内 live 帧,观察端可能连在另一个 worker 上,首帧要等 0.8s 的
|
||||
// 落库轮询(report_chunk)才到。若此时把 stub 置为 null,右栏就没有
|
||||
// 可打开的虚拟文件,"开始撰写即打开沙箱" 会整段失效。
|
||||
useEffect(() => {
|
||||
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;
|
||||
}
|
||||
setStubMessage((prev) => {
|
||||
@@ -121,14 +152,14 @@ export function useReportSandbox({
|
||||
});
|
||||
}, [messageId, report, toolCallId, writing]);
|
||||
|
||||
// 报告首帧到达且仍在写入 → 自动展开右栏并选中虚拟 URL。
|
||||
// 思考结束后的报告正文首帧到达且仍在写入 → 自动展开右栏并选中虚拟 URL。
|
||||
// openStreamedArtifact 尊重用户手动关闭(suppress),不会强行弹回。
|
||||
useEffect(() => {
|
||||
if (!autoOpen || !report.trim() || !writing || autoOpenedRef.current) return;
|
||||
if (!autoOpen || (!report.trim() && !activity) || !writing || autoOpenedRef.current) return;
|
||||
autoOpenedRef.current = true;
|
||||
onFirstReportContent?.();
|
||||
openStreamedArtifact(virtualUrl, artifactPreviewKey(virtualUrl));
|
||||
}, [autoOpen, onFirstReportContent, openStreamedArtifact, report, virtualUrl, writing]);
|
||||
}, [activity, autoOpen, onFirstReportContent, openStreamedArtifact, report, virtualUrl, writing]);
|
||||
|
||||
/** 手动打开(头部「报告」按钮 / 完成卡「查看报告」):始终选中虚拟 URL。 */
|
||||
const openReport = useCallback(() => {
|
||||
|
||||
@@ -6,9 +6,10 @@
|
||||
* after_seq 重放),因此前端的快照/SSE/reducer 全链路走的是与真实后端
|
||||
* 一致的代码路径。
|
||||
*
|
||||
* 演示脚本(后端 POC 固定任务):输入任意报告需求 → 澄清一次 → 生成 3 个
|
||||
* 候选方案 → 选择并开始 → 并行研究(TeamSay/工具调用/流式回复)→ 核验 →
|
||||
* 写作(report.delta → 正式版本)→ 审稿 → 完成;运行中支持命令确认卡演示。
|
||||
* 演示脚本(后端 POC 固定任务):输入任意报告需求 → 澄清一次 → 规划智能体
|
||||
* (步骤条 / 思考 / 正文)→ 方案卡 → 选择并开始 → 并行研究(TeamSay/工具调用/
|
||||
* 流式回复)→ 核验 → 写作(report.delta → 正式版本)→ 审稿 → 完成;运行中支持
|
||||
* 命令确认卡演示。
|
||||
*/
|
||||
|
||||
import type { ReportCollaborationDriver } from "@/report-collaboration/api/driver";
|
||||
@@ -111,6 +112,7 @@ function aiMessage(
|
||||
agentRunId: string | null,
|
||||
content = "",
|
||||
nodeRunId?: string | null,
|
||||
phaseId?: string | null,
|
||||
): CollaborationMessage {
|
||||
return {
|
||||
message_id: uid("msg"),
|
||||
@@ -119,7 +121,7 @@ function aiMessage(
|
||||
name,
|
||||
agent_run_id: agentRunId,
|
||||
node_run_id: nodeRunId ?? null,
|
||||
phase_id: agentRunId ? "phase_parallel_research" : null,
|
||||
phase_id: phaseId ?? (agentRunId ? "phase_parallel_research" : null),
|
||||
visibility: "visible",
|
||||
created_at: now(),
|
||||
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 =
|
||||
@@ -384,7 +574,116 @@ interface ResearchScript {
|
||||
teamSay: string;
|
||||
searchQuery: 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 {
|
||||
@@ -398,6 +697,13 @@ function runResearchAgent(store: MockStore, script: ResearchScript, baseDelay: n
|
||||
started_at: now(),
|
||||
};
|
||||
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, () => {
|
||||
streamAssistant(
|
||||
@@ -408,20 +714,14 @@ function runResearchAgent(store: MockStore, script: ResearchScript, baseDelay: n
|
||||
);
|
||||
});
|
||||
|
||||
later(store, baseDelay + 500, () => {
|
||||
const callId = `tc_${script.agentRunId}`;
|
||||
emit(store, "tool_call.created", {
|
||||
message_id: `ai-tc-${script.agentRunId}-${callId}`,
|
||||
agent_run_id: script.agentRunId,
|
||||
tool_call: { id: callId, name: "web_search", args: { query: script.searchQuery } },
|
||||
});
|
||||
});
|
||||
|
||||
later(store, baseDelay + 1200, () => {
|
||||
const callId = `tc_${script.agentRunId}`;
|
||||
const toolResultId = `tool-result-${callId}`;
|
||||
const content = JSON.stringify(
|
||||
{
|
||||
emitToolCall(
|
||||
store,
|
||||
agentRun,
|
||||
toolStart,
|
||||
{
|
||||
name: "web_search",
|
||||
args: { query: script.searchQuery, description: `检索「${script.searchSummary}」公开资料` },
|
||||
result: JSON.stringify({
|
||||
query: script.searchQuery,
|
||||
count: 8,
|
||||
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}`,
|
||||
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(
|
||||
store,
|
||||
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");
|
||||
const artifactId = uid("art");
|
||||
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");
|
||||
agentRun.status = "completed";
|
||||
agentRun.ended_at = now();
|
||||
@@ -510,8 +796,7 @@ function executePlan(store: MockStore, plan: ReportPlanCandidate): void {
|
||||
teamSay: `我认领「${node.label}」任务,先做一轮公开资料检索,重点看近 90 天的数据。`,
|
||||
searchQuery: `${node.label} 新能源汽车 2026`,
|
||||
searchSummary: node.label,
|
||||
reply: `【${node.label}】初步结论:${node.mission ?? ""}。已收集 8 条来源,其中 6 条为近 90 天信息,暂未发现口径冲突。详细证据已作为 EvidenceBundle 提交核验。`,
|
||||
baseDelayUnused: index,
|
||||
replyMission: node.mission ?? node.label,
|
||||
}));
|
||||
|
||||
researchScripts.forEach((script, index) => {
|
||||
@@ -549,14 +834,43 @@ function executePlan(store: MockStore, plan: ReportPlanCandidate): void {
|
||||
|
||||
let cursor = 4200;
|
||||
for (const node of rest) {
|
||||
const isWriter = node.output_artifact_type === "ReportVersion" && node.id === "n-write";
|
||||
const isReview = node.id === "n-review";
|
||||
const isWriter = node.id === "n-write";
|
||||
const isReview = node.id === "n-review" || /审稿/.test(node.label);
|
||||
const isRevise = node.id === "n-revise";
|
||||
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 + 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) {
|
||||
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) ?? [];
|
||||
chunks.forEach((chunk, index) => {
|
||||
later(store, 60 * index, () => {
|
||||
@@ -565,19 +879,36 @@ function executePlan(store: MockStore, plan: ReportPlanCandidate): void {
|
||||
});
|
||||
});
|
||||
} else {
|
||||
later(store, delay + 600, () => {
|
||||
streamAssistant(
|
||||
later(store, toolStart, () => {
|
||||
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,
|
||||
aiMessage(node.role_display_name ?? node.label, null, "", `${node.id}-attempt1`),
|
||||
`【${node.label}】${node.mission ?? "处理中"}……完成,成果已通过质量门。`,
|
||||
aiMessage(agentRun.display_name, agentRun.agent_run_id, "", agentRun.node_run_id, phaseId),
|
||||
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) {
|
||||
store.versionCounter += 1;
|
||||
const version: ReportVersion = {
|
||||
@@ -614,7 +945,7 @@ function executePlan(store: MockStore, plan: ReportPlanCandidate): void {
|
||||
emit(store, "run.status.changed", { status: "completed" });
|
||||
}
|
||||
});
|
||||
cursor += isWriter ? 3600 : 1900;
|
||||
cursor += nodeDuration;
|
||||
}
|
||||
|
||||
// 无修订节点的方案:最后一个节点完成后收尾。
|
||||
@@ -747,13 +1078,7 @@ const mockDriverImpl: ReportCollaborationDriver = {
|
||||
} else if (status === "clarifying") {
|
||||
store.session.status = "planning";
|
||||
emit(store, "session.snapshot", { session: { ...store.session } });
|
||||
const stages = ["解析需求要点", "筛选可用智能体与工具", "装配三种策略的协作图", "校验计划可行性"];
|
||||
stages.forEach((stage, index) => {
|
||||
later(store, 300 + index * 450, () => {
|
||||
emit(store, "plan.progress", { stage, detail: `${stage}……` });
|
||||
});
|
||||
});
|
||||
later(store, 300 + stages.length * 450, () => {
|
||||
runPlannerAgent(store, () => {
|
||||
store.plans = buildDemoPlans(store.session.requirement_revision);
|
||||
emit(store, "plan.proposed", { plans: store.plans });
|
||||
store.session.status = "proposal_ready";
|
||||
@@ -763,8 +1088,8 @@ const mockDriverImpl: ReportCollaborationDriver = {
|
||||
later(store, 300, () => {
|
||||
streamAssistant(
|
||||
store,
|
||||
aiMessage("协调者", "agent_run_coordinator"),
|
||||
"可以在方案卡中切换预览对应的协作流程;点「采用此方案」后,「开始协作」会正式创建执行。",
|
||||
aiMessage("协调者", COORDINATOR_RUN_ID),
|
||||
"上面已经给出三种策略的完整过程与方案卡。选中一张后点「开始协作」即可执行;也可以点「重新生成」让我再跑一轮规划。",
|
||||
);
|
||||
});
|
||||
} else if (status === "completed") {
|
||||
@@ -782,9 +1107,15 @@ const mockDriverImpl: ReportCollaborationDriver = {
|
||||
async requestPlans(sessionId, expectedRevision) {
|
||||
const store = getStore(sessionId);
|
||||
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);
|
||||
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;
|
||||
node_run_id?: string | null;
|
||||
delta: string;
|
||||
/** 思考过程增量,写入消息 `metadata.reasoning_content`,由适配器映射到问答 CoT。 */
|
||||
reasoning_delta?: string;
|
||||
}
|
||||
|
||||
export interface MessageCompletedPayload {
|
||||
|
||||
@@ -7,11 +7,15 @@ import type { AgentRun } from "@/report-collaboration/api/types";
|
||||
import { roleIcon } from "@/report-collaboration/components/ReportFlowNode";
|
||||
|
||||
interface AgentStageSelectorProps {
|
||||
/** 当前并行波次成员;空数组时不渲染。 */
|
||||
/** 当前正在执行的成员;空数组且无 pinned 时不渲染。 */
|
||||
tabs: AgentRun[];
|
||||
/** 点击画布节点后钉住的历史成员(波次结束后回看步骤条)。 */
|
||||
pinnedAgent?: AgentRun | null;
|
||||
phaseLabel?: string | null;
|
||||
focusedAgentRunId: string | null;
|
||||
onFocus: (agentRunId: string) => void;
|
||||
/** 取消钉住,回到全量时间线。 */
|
||||
onShowAll?: () => void;
|
||||
/** 正在流式输出的成员。 */
|
||||
streamingAgentRunIds: Set<string>;
|
||||
/** 请求用户协助(待回复)的成员。 */
|
||||
@@ -45,21 +49,29 @@ function statusLabel(status: ReturnType<typeof avatarStatus>, displayName: strin
|
||||
}
|
||||
|
||||
/**
|
||||
* 并行波次头像条(对齐会商第二步 sandbox):
|
||||
* 只展示当前同时执行的多个智能体;点头像切换消息列表到该成员的执行过程。
|
||||
* 执行中头像条(对齐会商第二步 sandbox):
|
||||
* 单个模型节点与并行波次都展示;点头像切换到该成员的步骤条 / 模型输出。
|
||||
* 未选中成员的事件继续接收并持久更新。
|
||||
*/
|
||||
export const AgentStageSelector = memo(function AgentStageSelector({
|
||||
tabs,
|
||||
pinnedAgent = null,
|
||||
phaseLabel,
|
||||
focusedAgentRunId,
|
||||
onFocus,
|
||||
onShowAll,
|
||||
streamingAgentRunIds,
|
||||
awaitingAgentRunIds,
|
||||
}: AgentStageSelectorProps) {
|
||||
if (tabs.length === 0) return null;
|
||||
const focused = focusedAgentRunId ?? tabs[0]?.agent_run_id;
|
||||
const runningCount = tabs.filter((tab) => {
|
||||
const visibleTabs =
|
||||
tabs.length > 0
|
||||
? 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(
|
||||
tab,
|
||||
streamingAgentRunIds.has(tab.agent_run_id),
|
||||
@@ -67,15 +79,18 @@ export const AgentStageSelector = memo(function AgentStageSelector({
|
||||
);
|
||||
return status === "running" || status === "awaiting";
|
||||
}).length;
|
||||
const stripLabel =
|
||||
phaseLabel ??
|
||||
(visibleTabs.length > 1 ? `并行 ${visibleTabs.length} 席` : visibleTabs[0]?.display_name ?? "当前节点");
|
||||
|
||||
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">
|
||||
<span>{phaseLabel ?? `并行 ${tabs.length} 席`}</span>
|
||||
<span>{stripLabel}</span>
|
||||
{runningCount > 0 && <span className="rc-agent-strip-running">{runningCount} 进行中</span>}
|
||||
</div>
|
||||
<div className="rc-agent-avatars">
|
||||
{tabs.map((tab) => {
|
||||
{visibleTabs.map((tab) => {
|
||||
const streaming = streamingAgentRunIds.has(tab.agent_run_id);
|
||||
const awaiting = awaitingAgentRunIds.has(tab.agent_run_id);
|
||||
const status = avatarStatus(tab, streaming, awaiting);
|
||||
@@ -108,6 +123,11 @@ export const AgentStageSelector = memo(function AgentStageSelector({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{onShowAll && (tabs.length <= 1 || Boolean(pinnedAgent)) ? (
|
||||
<button type="button" className="rc-btn rc-agent-show-all" onClick={onShowAll}>
|
||||
全部对话
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -6,7 +6,6 @@ import type { Message } from "@langchain/langgraph-sdk";
|
||||
|
||||
import { MessageList } from "@/components/workspace/messages/message-list";
|
||||
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||
import { ArtifactsProvider } from "@/components/workspace/artifacts";
|
||||
import { SubtasksProvider } from "@/core/tasks/context";
|
||||
import type { AgentThreadState } from "@/core/threads";
|
||||
import type { BaseStream } from "@langchain/langgraph-sdk/react";
|
||||
@@ -68,13 +67,11 @@ export function ReportConversationPanel({
|
||||
|
||||
return (
|
||||
<SubtasksProvider>
|
||||
<ArtifactsProvider>
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{stageSelector}
|
||||
{content}
|
||||
{composer}
|
||||
</div>
|
||||
</ArtifactsProvider>
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{stageSelector}
|
||||
{content}
|
||||
{composer}
|
||||
</div>
|
||||
</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 {
|
||||
buildBaseStreamFacade,
|
||||
buildReportWriteFileMessages,
|
||||
reportWriteFileBelongsToFocus,
|
||||
toLangGraphMessages,
|
||||
} 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 {
|
||||
visibleMessages: CollaborationMessage[];
|
||||
@@ -39,10 +46,23 @@ export function useReportCollaborationMessages(
|
||||
const visibleMessages = selectOrderedVisibleMessages(state);
|
||||
const focusedMessages = selectConversationMessages(state, focusedAgentRunId);
|
||||
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 streaming =
|
||||
streamSource.some((message) => !message.completed_at && message.role !== "human") ||
|
||||
Boolean(state.reportDraft) ||
|
||||
extraLoading;
|
||||
const displayThread = buildBaseStreamFacade(displayMessages, streaming);
|
||||
return { visibleMessages, focusedMessages, displayMessages, displayThread, streaming };
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
"use client";
|
||||
|
||||
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 { toast } from "sonner";
|
||||
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 { AgentStageSelector } from "@/report-collaboration/components/AgentStageSelector";
|
||||
import { ConnectionStatusBar } from "@/report-collaboration/components/ConnectionStatusBar";
|
||||
@@ -21,6 +24,7 @@ import {
|
||||
ReportAvailableCard,
|
||||
ReportPreviewDialog,
|
||||
} from "@/report-collaboration/components/ReportPreviewDialog";
|
||||
import { ReportSandboxOverlay } from "@/report-collaboration/components/ReportSandboxOverlay";
|
||||
import { SessionHistoryPanel } from "@/report-collaboration/components/SessionHistoryPanel";
|
||||
import { useReportPanelLayout } from "@/report-collaboration/hooks/useReportPanelLayout";
|
||||
import { useReportCollaborationMessages } from "@/report-collaboration/hooks/useReportCollaborationMessages";
|
||||
@@ -34,11 +38,11 @@ import {
|
||||
} from "@/report-collaboration/state/message-adapter";
|
||||
import {
|
||||
derivePagePhase,
|
||||
selectAgentRunForNode,
|
||||
selectAgentTabs,
|
||||
selectAwaitingInputNodes,
|
||||
selectHeadReportVersion,
|
||||
selectNodeStatusMap,
|
||||
selectParallelMembers,
|
||||
selectPendingConfirmationCommands,
|
||||
selectPlanForCanvas,
|
||||
selectPreferredParallelFocus,
|
||||
@@ -101,6 +105,7 @@ export default function ReportCollaborationPage() {
|
||||
);
|
||||
|
||||
return (
|
||||
<ArtifactsProvider>
|
||||
<ReportCollaborationLayout
|
||||
layout={layout}
|
||||
header={
|
||||
@@ -151,6 +156,7 @@ export default function ReportCollaborationPage() {
|
||||
canvas={<ReportFlowCanvas plan={null} nodeStates={{}} selectedNodeId={null} onSelectNode={() => undefined} />}
|
||||
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 [focusPinned, setFocusPinned] = useState(false);
|
||||
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 =
|
||||
agentTabs.length === 0
|
||||
? null
|
||||
: focusedAgentRunId && tabIds.has(focusedAgentRunId)
|
||||
? focusedAgentRunId
|
||||
: agentTabs[0].agent_run_id;
|
||||
const focusedTab = agentTabs.find((tab) => tab.agent_run_id === effectiveFocus) ?? null;
|
||||
focusPinned && focusedAgentRunId && state.agentRuns[focusedAgentRunId]
|
||||
? focusedAgentRunId
|
||||
: agentTabs.length === 0
|
||||
? null
|
||||
: focusedAgentRunId && tabIds.has(focusedAgentRunId)
|
||||
? 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(
|
||||
state.plans,
|
||||
@@ -216,14 +230,17 @@ export function ReportCollaborationSessionPage({ sessionId }: { sessionId: strin
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [session.actionError]);
|
||||
|
||||
// 并行波次:默认落到正在执行的成员;点头像/画布后保持该成员,直到波次结束。
|
||||
// 执行波次:默认落到正在输出的成员;点头像/画布后保持该成员直到该波次结束。
|
||||
// 全部结束后若用户钉住某个节点,保留回看,不强制清回全量时间线。
|
||||
useEffect(() => {
|
||||
if (agentTabs.length === 0) {
|
||||
if (focusedAgentRunId) setFocusedAgentRunId(null);
|
||||
if (focusPinned) setFocusPinned(false);
|
||||
if (!focusPinned && focusedAgentRunId) setFocusedAgentRunId(null);
|
||||
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);
|
||||
if (next) setFocusedAgentRunId(next);
|
||||
}, [agentTabs, focusPinned, focusedAgentRunId, tabIds]);
|
||||
@@ -236,7 +253,7 @@ export function ReportCollaborationSessionPage({ sessionId }: { sessionId: strin
|
||||
if (
|
||||
state.plans.length > 0 &&
|
||||
!runStarted &&
|
||||
(phase === "proposal_ready" || phase === "planning")
|
||||
phase === "proposal_ready"
|
||||
) {
|
||||
cards.push(
|
||||
buildPlanGroupCard(state.plans, state.selectedPlanId, state.session?.requirement_revision ?? 1),
|
||||
@@ -273,17 +290,7 @@ export function ReportCollaborationSessionPage({ sessionId }: { sessionId: strin
|
||||
}, [state]);
|
||||
|
||||
const findMemberOfNode = useCallback(
|
||||
(nodeId: string) => {
|
||||
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
|
||||
);
|
||||
},
|
||||
(nodeId: string) => selectAgentRunForNode(state, nodeId),
|
||||
[state],
|
||||
);
|
||||
|
||||
@@ -291,30 +298,29 @@ export function ReportCollaborationSessionPage({ sessionId }: { sessionId: strin
|
||||
(nodeId: string | null) => {
|
||||
setSelectedNodeId(nodeId);
|
||||
setDrawerOpen(Boolean(nodeId));
|
||||
// 点并行智能体节点时,消息列表切到该成员的执行过程(与头像条同一套聚焦)。
|
||||
if (nodeId) {
|
||||
const member = findMemberOfNode(nodeId);
|
||||
if (member && tabIds.has(member.agent_run_id)) {
|
||||
if (member) {
|
||||
setFocusedAgentRunId(member.agent_run_id);
|
||||
setFocusPinned(true);
|
||||
}
|
||||
}
|
||||
},
|
||||
[findMemberOfNode, tabIds],
|
||||
[findMemberOfNode],
|
||||
);
|
||||
|
||||
const handleNodeComment = useCallback(
|
||||
(nodeId: string, nodeName: string) => {
|
||||
const member = findMemberOfNode(nodeId);
|
||||
setComposerTarget({ nodeId, nodeName, agentRunId: member?.agent_run_id ?? null });
|
||||
if (member && tabIds.has(member.agent_run_id)) {
|
||||
if (member) {
|
||||
setFocusedAgentRunId(member.agent_run_id);
|
||||
setFocusPinned(true);
|
||||
}
|
||||
setDrawerOpen(false);
|
||||
setFocusSignal((signal) => signal + 1);
|
||||
},
|
||||
[findMemberOfNode, tabIds],
|
||||
[findMemberOfNode],
|
||||
);
|
||||
|
||||
const handleRestore = useCallback(
|
||||
@@ -395,7 +401,7 @@ export function ReportCollaborationSessionPage({ sessionId }: { sessionId: strin
|
||||
? "start"
|
||||
: outgoingIds.size > 0
|
||||
? "agent"
|
||||
: "end") as const,
|
||||
: "end") as "start" | "agent" | "end",
|
||||
mission: selectedPlanNode?.mission ?? null,
|
||||
input_artifact_types: selectedPlanNode?.input_artifact_types ?? [],
|
||||
output_artifact_type: selectedPlanNode?.output_artifact_type ?? null,
|
||||
@@ -438,114 +444,128 @@ export function ReportCollaborationSessionPage({ sessionId }: { sessionId: strin
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ReportCollaborationLayout
|
||||
layout={layout}
|
||||
header={
|
||||
<ReportCollaborationHeader
|
||||
title={state.session?.title ?? "报告协作"}
|
||||
phase={phase}
|
||||
onBack={() => navigate("/page/workspace/chats/new")}
|
||||
onNewSession={() => navigate("/page/workspace/report-collaboration")}
|
||||
onOpenReport={() => {
|
||||
if (headVersion) {
|
||||
setPreviewVersionId(headVersion.id);
|
||||
setPreviewOpen(true);
|
||||
}
|
||||
}}
|
||||
hasReport={Boolean(headVersion)}
|
||||
running={runActive}
|
||||
onCancelRun={() => void session.cancelRun()}
|
||||
extra={
|
||||
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}
|
||||
/>
|
||||
<SubtasksProvider>
|
||||
<ArtifactsProvider>
|
||||
<ThreadContext.Provider value={{ thread: displayThread, isMock: true }}>
|
||||
<ReportCollaborationLayout
|
||||
layout={layout}
|
||||
header={
|
||||
<ReportCollaborationHeader
|
||||
title={state.session?.title ?? "报告协作"}
|
||||
phase={phase}
|
||||
onBack={() => navigate("/page/workspace/chats/new")}
|
||||
onNewSession={() => navigate("/page/workspace/report-collaboration")}
|
||||
onOpenReport={() => {
|
||||
if (headVersion) {
|
||||
setPreviewVersionId(headVersion.id);
|
||||
setPreviewOpen(true);
|
||||
}
|
||||
}}
|
||||
hasReport={Boolean(headVersion)}
|
||||
running={runActive}
|
||||
onCancelRun={() => void session.cancelRun()}
|
||||
extra={
|
||||
layout.isNarrow ? (
|
||||
<NarrowSwitch narrowView={layout.narrowView} setNarrowView={layout.setNarrowView} />
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
}
|
||||
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}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
}
|
||||
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}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
statusBar={
|
||||
<ConnectionStatusBar
|
||||
status={session.streamStatus.status}
|
||||
runId={state.run?.id ?? null}
|
||||
lastSeq={state.lastSeq}
|
||||
message={session.streamStatus.message}
|
||||
<ReportPreviewDialog
|
||||
open={previewOpen}
|
||||
onClose={() => setPreviewOpen(false)}
|
||||
versions={state.reportVersions}
|
||||
activeVersionId={previewVersionId}
|
||||
onSelectVersion={setPreviewVersionId}
|
||||
onRestore={(versionId) => void handleRestore(versionId)}
|
||||
restoreBusy={restoreBusy}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ReportPreviewDialog
|
||||
open={previewOpen}
|
||||
onClose={() => setPreviewOpen(false)}
|
||||
versions={state.reportVersions}
|
||||
activeVersionId={previewVersionId}
|
||||
onSelectVersion={setPreviewVersionId}
|
||||
onRestore={(versionId) => void handleRestore(versionId)}
|
||||
restoreBusy={restoreBusy}
|
||||
/>
|
||||
</>
|
||||
</ThreadContext.Provider>
|
||||
</ArtifactsProvider>
|
||||
</SubtasksProvider>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
if (!payload.message_id) return state;
|
||||
const existing = state.messagesById[payload.message_id];
|
||||
const reasoningDelta = payload.reasoning_delta ?? "";
|
||||
if (existing) {
|
||||
return {
|
||||
...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, {
|
||||
message_id: payload.message_id,
|
||||
role: "ai",
|
||||
content: payload.delta,
|
||||
content: payload.delta ?? "",
|
||||
agent_run_id: payload.agent_run_id ?? null,
|
||||
node_run_id: payload.node_run_id ?? null,
|
||||
visibility: "visible",
|
||||
created_at: new Date().toISOString(),
|
||||
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 {
|
||||
return upsertMessage(state, {
|
||||
const withResult = upsertMessage(state, {
|
||||
message_id: toolResultMessageId(payload),
|
||||
role: "tool",
|
||||
content: payload.content ?? "",
|
||||
@@ -710,8 +727,30 @@ function applyToolResultCreated(state: CollaborationState, payload: ToolResultCr
|
||||
phase_id: payload.phase_id ?? null,
|
||||
visibility: "visible",
|
||||
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 {
|
||||
|
||||
@@ -17,12 +17,16 @@ import type { BaseStream } from "@langchain/langgraph-sdk/react";
|
||||
import type { AgentThreadState } from "@/core/threads";
|
||||
|
||||
import type {
|
||||
AgentRun,
|
||||
CollaborationCommand,
|
||||
CollaborationMessage,
|
||||
ReportPlanCandidate,
|
||||
ReportVersion,
|
||||
} from "@/report-collaboration/api/types";
|
||||
|
||||
/** 报告在沙箱中的虚拟路径,对齐问答页 write_file / 深度研究 stub。 */
|
||||
export const REPORT_OUTPUT_PATH = "/mnt/user-data/outputs/report.md";
|
||||
|
||||
// ── 归属标记 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ReportCollaborationKwargs {
|
||||
@@ -70,6 +74,12 @@ export function toLangGraphMessage(message: CollaborationMessage): 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 字符串)。
|
||||
return {
|
||||
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 外观 ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -90,14 +90,15 @@ function latestStartedAt(members: AgentRun[]): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前正在并行执行的那一波成员(不含协调者)。
|
||||
* 同一 phase 下 ≥2 个成员且至少一人仍在执行时才返回;波次结束后回到空数组,
|
||||
* 消息列表按会商第二步收成普通聊天记录。
|
||||
* 当前正在执行的那一波成员(不含协调者)。
|
||||
* 同一 phase 下只要有人仍在执行就返回——单个模型节点也要露出过程;
|
||||
* 波次结束后回到空数组,消息列表收成普通聊天记录。
|
||||
* ≥2 人时即为并行波次;1 人时为业务链上的当前节点。
|
||||
*/
|
||||
export function selectParallelMembers(state: CollaborationState): AgentRun[] {
|
||||
const waves = [...membersByPhase(state).entries()]
|
||||
.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 [];
|
||||
const currentPhase = state.run?.current_phase ?? null;
|
||||
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 ?? ""));
|
||||
}
|
||||
|
||||
/** 头像条:仅当前并行波次的执行成员(不含协调者)。 */
|
||||
/** 头像条:当前正在执行的成员(单个模型节点或并行波次,不含协调者)。 */
|
||||
export function selectAgentTabs(state: CollaborationState): AgentRun[] {
|
||||
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(
|
||||
members: AgentRun[],
|
||||
|
||||
@@ -309,6 +309,23 @@
|
||||
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 {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
|
||||
@@ -99,6 +99,32 @@ test("message.delta 多次更新同一 messageId,completed 收尾", () => {
|
||||
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", () => {
|
||||
let state = applySessionSnapshot(createInitialCollaborationState(SESSION), snapshot());
|
||||
// seq=2 先到 → 缓冲并出现缺口
|
||||
@@ -151,6 +177,7 @@ test("工具调用挂在所属 ai 消息,返回以 tool 消息配对 tool_call
|
||||
assert.equal(toolMessage.tool_call_id, "tc1");
|
||||
assert.equal(state.messageOrder[0], "ai_owner");
|
||||
assert.equal(state.messageOrder[1], "tr1");
|
||||
assert.ok(owner.completed_at, "工具-only 的 ai 行在 tool 返回后应收尾,避免步骤条一直转圈");
|
||||
});
|
||||
|
||||
test("节点状态机守卫:cancelled 后晚到 completed 被忽略", () => {
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
buildCommandConfirmCard,
|
||||
buildPlanGroupCard,
|
||||
buildBaseStreamFacade,
|
||||
buildReportWriteFileMessages,
|
||||
REPORT_OUTPUT_PATH,
|
||||
toLangGraphMessages,
|
||||
} from "../state/message-adapter";
|
||||
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");
|
||||
state.agentRuns = {
|
||||
coord: agentRun({
|
||||
@@ -105,6 +107,26 @@ test("并行头像条:同一 phase 且至少两人仍在执行时才出现,
|
||||
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("并行波次全部结束后头像条消失,消息列表恢复全量", () => {
|
||||
const state = createInitialCollaborationState("s1");
|
||||
state.agentRuns = {
|
||||
@@ -227,6 +249,81 @@ test("方案组卡可被 groupMessages 识别且不产生正文气泡", () => {
|
||||
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 所需字段", () => {
|
||||
const thread = buildBaseStreamFacade([], true);
|
||||
assert.deepEqual(thread.messages, []);
|
||||
@@ -234,3 +331,39 @@ test("BaseStream 外观提供 MessageList 所需字段", () => {
|
||||
assert.equal(thread.isThreadLoading, false);
|
||||
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> {
|
||||
if (!res.ok) {
|
||||
let detail = `${ctx} failed (${res.status})`;
|
||||
let body: unknown;
|
||||
try {
|
||||
const body = await res.json();
|
||||
detail = body.detail || detail;
|
||||
body = await res.json();
|
||||
const record = body && typeof body === "object" ? body as Record<string, unknown> : {};
|
||||
detail = typeof record.detail === "string" ? record.detail : detail;
|
||||
} catch {
|
||||
// ignore parse error
|
||||
}
|
||||
console.error(`[深度研究] ${ctx}_failed`, {
|
||||
status: res.status,
|
||||
url: res.url,
|
||||
detail,
|
||||
body,
|
||||
});
|
||||
throw new Error(detail);
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
@@ -323,12 +331,25 @@ export async function getSession(id: string): Promise<DeepResearchSession> {
|
||||
|
||||
export async function updateSession(
|
||||
id: string,
|
||||
patch: { title?: string; status?: "collecting" | "awaiting_report" },
|
||||
patch: {
|
||||
title?: string;
|
||||
status?: "collecting" | "awaiting_report";
|
||||
collectorMaterials?: Record<string, unknown>[];
|
||||
},
|
||||
): Promise<DeepResearchSession> {
|
||||
const res = await apiFetch(`${BASE()}/sessions/${id}`, {
|
||||
method: "PATCH",
|
||||
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"));
|
||||
}
|
||||
@@ -352,27 +373,90 @@ export interface StartJobOptions {
|
||||
entry?: "chat" | "legacy" | "regenerate";
|
||||
/** Report-config card choices merged over the frozen session snapshot. */
|
||||
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. */
|
||||
collectorMaterials?: Record<string, unknown>[];
|
||||
/** Skip harvested / leftover sources and take the no-material writer. */
|
||||
forceNoMaterials?: boolean;
|
||||
}
|
||||
|
||||
export async function startJob(
|
||||
sessionId: 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 =
|
||||
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",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
request_id: opts.requestId ?? createClientId(),
|
||||
entry: opts.entry ?? "legacy",
|
||||
config: opts.config ?? null,
|
||||
collector_materials: opts.collectorMaterials ?? null,
|
||||
}),
|
||||
body: JSON.stringify(
|
||||
entry === "chat"
|
||||
? {
|
||||
request_id: opts.requestId ?? createClientId(),
|
||||
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> {
|
||||
@@ -384,6 +468,9 @@ export async function cancelJob(
|
||||
jobId: string,
|
||||
): Promise<{ jobId: string; status: string }> {
|
||||
const res = await apiFetch(`${BASE()}/jobs/${jobId}/cancel`, { method: "POST" });
|
||||
if (res.status === 409) {
|
||||
return { jobId, status: "cancelled" };
|
||||
}
|
||||
return readJson(res, "cancelJob");
|
||||
}
|
||||
|
||||
@@ -402,6 +489,54 @@ export async function resumeJob(
|
||||
|
||||
// ── 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 {
|
||||
onEvent: (event: ProgressEvent) => void;
|
||||
onTerminal?: (status: string) => void;
|
||||
@@ -428,7 +563,9 @@ export function streamJob(
|
||||
{ method: "GET", signal: controller.signal },
|
||||
);
|
||||
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;
|
||||
}
|
||||
const reader = res.body.getReader();
|
||||
@@ -460,6 +597,7 @@ export function streamJob(
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
logDeepResearchDiagnostic(event);
|
||||
handlers.onEvent(event);
|
||||
|
||||
// Check for terminal status in the payload.
|
||||
@@ -475,7 +613,9 @@ export function streamJob(
|
||||
}
|
||||
} catch (err) {
|
||||
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,
|
||||
entry,
|
||||
config,
|
||||
query,
|
||||
title,
|
||||
collectorMaterials,
|
||||
forceNoMaterials,
|
||||
}: {
|
||||
sessionId: string;
|
||||
entry?: "chat" | "legacy" | "regenerate";
|
||||
config?: Record<string, unknown>;
|
||||
query?: string;
|
||||
title?: string;
|
||||
collectorMaterials?: Record<string, unknown>[];
|
||||
}) => startJob(sessionId, { entry, config, collectorMaterials }),
|
||||
forceNoMaterials?: boolean;
|
||||
}) => startJob(sessionId, { entry, config, query, title, collectorMaterials, forceNoMaterials }),
|
||||
onSuccess: (_result, { sessionId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: [DEEP_RESEARCH_SESSIONS_KEY] });
|
||||
queryClient.invalidateQueries({ queryKey: [DEEP_RESEARCH_SESSION_KEY, sessionId] });
|
||||
@@ -1026,15 +1172,32 @@ export function useResearchActions() {
|
||||
mutationFn: ({
|
||||
sessionId,
|
||||
status,
|
||||
collectorMaterials,
|
||||
}: {
|
||||
sessionId: string;
|
||||
status: "collecting" | "awaiting_report";
|
||||
}) => updateSession(sessionId, { status }),
|
||||
collectorMaterials?: Record<string, unknown>[];
|
||||
}) => updateSession(sessionId, { status, collectorMaterials }),
|
||||
onSuccess: (_result, { sessionId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: [DEEP_RESEARCH_SESSIONS_KEY] });
|
||||
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({
|
||||
mutationFn: (jobId: string) => cancelJob(jobId),
|
||||
onSuccess: () => {
|
||||
|
||||
+51
-20
@@ -1,17 +1,48 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
import { defineConfig, type Plugin } from "vite";
|
||||
import sourceIdentifierPlugin from "vite-plugin-source-identifier";
|
||||
|
||||
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
|
||||
// is therefore the practical lower bound for this Win7 legacy build.
|
||||
const LEGACY_CHROME = 100 << 16;
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sourceIdentifierPlugin(), react()],
|
||||
plugins: [staleDepsChunkSelfReload(), sourceIdentifierPlugin(), react()],
|
||||
css: {
|
||||
// Also used by the production CSS minifier. Development CSS is handled
|
||||
// by the matching PostCSS compatibility pass in postcss.config.js.
|
||||
@@ -21,20 +52,20 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: [
|
||||
{
|
||||
// react-syntax-highlighter@16.1.1 exposes an async `sqlMore`
|
||||
// loader, but its own highlight.js@10.x dependency has no
|
||||
// `sql_more` module. Reuse the bundled SQL grammar so Rollup can
|
||||
// resolve every generated language loader without leaving a broken
|
||||
// browser-time external import.
|
||||
find: /^highlight\.js\/lib\/languages\/sql_more$/,
|
||||
replacement: "highlight.js/lib/languages/sql",
|
||||
},
|
||||
{
|
||||
find: "@/env",
|
||||
replacement: path.resolve(rootDir, "src/env.ts"),
|
||||
resolve: {
|
||||
alias: [
|
||||
{
|
||||
// react-syntax-highlighter@16.1.1 exposes an async `sqlMore`
|
||||
// loader, but its own highlight.js@10.x dependency has no
|
||||
// `sql_more` module. Reuse the bundled SQL grammar so Rollup can
|
||||
// resolve every generated language loader without leaving a broken
|
||||
// browser-time external import.
|
||||
find: /^highlight\.js\/lib\/languages\/sql_more$/,
|
||||
replacement: "highlight.js/lib/languages/sql",
|
||||
},
|
||||
{
|
||||
find: "@/env",
|
||||
replacement: path.resolve(rootDir, "src/env.ts"),
|
||||
},
|
||||
{
|
||||
find: "next/navigation",
|
||||
@@ -64,9 +95,9 @@ export default defineConfig({
|
||||
target: "chrome100",
|
||||
},
|
||||
server: {
|
||||
// 与 scripts/env.sh FRONTEND_PORT=5174、圆桌 wujie 嵌入文档一致。
|
||||
port: 5173,
|
||||
// 无界宿主跨域 fetch 子应用静态资源时,Vite 需回显请求的 Origin。
|
||||
cors: true,
|
||||
// 与 scripts/env.sh FRONTEND_PORT=5174、圆桌 wujie 嵌入文档一致。
|
||||
port: 5173,
|
||||
// 无界宿主跨域 fetch 子应用静态资源时,Vite 需回显请求的 Origin。
|
||||
cors: true,
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user