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