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

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

No files matched your search

+28 -6
View File
@@ -12,6 +12,7 @@ import { useParams, useRouter } from "next/navigation";
import { useLocation } from "react-router-dom";
import { useRouteBase } from "@/core/route-base";
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { Button } from "@/components/ui/button";
@@ -25,6 +26,7 @@ import {
} from "@/components/workspace/agents/agent-guide";
import { AgentWelcome } from "@/components/workspace/agent-welcome";
import { ArtifactTrigger } from "@/components/workspace/artifacts";
import { listThreadArtifacts } from "@/core/artifacts/listing";
import { ChatBox, useThreadChat } from "@/components/workspace/chats";
import { ExportTrigger } from "@/components/workspace/export-trigger";
import { InputBox } from "@/components/workspace/input-box";
@@ -98,6 +100,14 @@ const PAGE_REPORT_DESIGNER_AGENT_ID = "page-report-designer";
const RWFX_SQ_REPORT_CONTEXT_MAX_CHARS = 12000;
const RWFX_SQ_REPORT_CONTEXT_TAIL_CHARS = 2000;
function isTaskReportsJsonPath(path: string | undefined): boolean {
const normalized = (path ?? "").replace(/\\/g, "/");
return (
normalized === "task-reports.json" ||
normalized.endsWith("/task-reports.json")
);
}
function readPendingKnowledgeSelection(agentId: string | undefined): string[] | null {
if (typeof window === "undefined" || !agentId) return null;
try {
@@ -504,9 +514,11 @@ export default function AgentChatPage({
// ── agentfx「入库」协助卡 ──────────────────────────────────────────────────
// 任务工作区(taskBind)里,内置研判智能体写出的 task-reports.json 产物落地且
// 本轮流式结束后,向消息流尾部注入「是否入库」卡片。本地沙箱下 artifacts 可能
// 记录宿主真实路径,按文件名匹配(虚拟/宿主路径都命中)。任务已有详情**不抑制**
// 卡片(入库是整体替换,由卡片文案说明覆盖语义);已导入按 thread 记忆在
// sessionStorage(刷新后不再重复询问)、「暂不入库」为本次会话内记忆。
// 记录宿主真实路径,按文件名匹配(虚拟/宿主路径都命中)。转换脚本已函数合并
// 写出该文件,即使模型中断、没跑 present_files,也再扫一遍 outputs 目录。
// 任务已有详情**不抑制**卡片(入库是整体替换,由卡片文案说明覆盖语义);
// 已导入按 thread 记忆在 sessionStorage(刷新后不再重复询问)、
// 「暂不入库」为本次会话内记忆。
const reportImportSessionKey = threadId
? `agentfx.reportImport.done.${threadId}`
: "";
@@ -519,9 +531,19 @@ export default function AgentChatPage({
}
});
const [reportImportDismissed, setReportImportDismissed] = useState(false);
const reportJsonArtifactReady = (thread.values.artifacts ?? []).some((path) =>
path.replace(/\\/g, "/").endsWith("/task-reports.json"),
);
const listedReportJsonQuery = useQuery({
queryKey: ["agentfx-task-reports", threadId],
queryFn: ({ signal }) => listThreadArtifacts(threadId, signal),
enabled: Boolean(
taskBind && !taskGate && threadId && threadId !== "new" && !thread.isLoading,
),
retry: false,
});
const reportJsonArtifactReady =
(thread.values.artifacts ?? []).some(isTaskReportsJsonPath) ||
(listedReportJsonQuery.data?.files ?? []).some((file) =>
isTaskReportsJsonPath(file.path) || file.name === "task-reports.json",
);
const showReportImportCard =
taskBind &&
!taskGate &&
@@ -99,6 +99,7 @@ import {
extractToolResultPayload,
hasToolCalls,
} from "@/core/messages/utils";
import { normalizeSearchResults } from "@/core/messages/search-results";
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
import {
type DeepResearchSession,
@@ -276,7 +277,18 @@ function assembleStreamingReport(targets: Record<string, { content: string; vers
const REPORT_REFUSAL_PATTERN = /(?:无法|不能|未能|不可以).{0,24}?(?:生成|撰写|产出|输出).{0,12}?报告/s;
function isRefusalReportText(value: string): boolean {
return REPORT_REFUSAL_PATTERN.test(value.replace(/\s+/g, ""));
const text = value || "";
const compact = text.replace(/\s+/g, "");
// Empty means the writer has not emitted Markdown yet (thinking / connecting).
// Treating that as a refusal opened the sandbox on an error card while the
// model was still working.
if (!compact) return false;
const headings = text.match(/^#{1,6}\s+\S+/gm) ?? [];
// A structured report may discuss evidence gaps. Only treat a short,
// unstructured refusal as “no report”, otherwise the zero-source fallback
// is hidden behind the diagnostic card and looks like writing failed.
if (headings.length >= 2) return false;
return REPORT_REFUSAL_PATTERN.test(compact);
}
function researchExportBaseName(session: DeepResearchSession): string {
@@ -422,68 +434,97 @@ function countCollectorMaterials(messages: readonly Message[] | undefined): numb
return unique.size;
}
const COLLECTOR_PERSIST_LIMIT = 80;
const WRITE_FALLBACK_MATERIAL_LIMIT = 20;
const SKIP_COLLECTOR_PERSIST_TOOLS = new Set(["ask_clarification", "write_todos"]);
function collectorMaterialKey(item: Record<string, unknown>): string {
const text = (value: unknown) => typeof value === "string" ? value.trim() : "";
return text(item.id) || text(item.url) || `${text(item.title)}\n${text(item.raw_content).slice(0, 240)}`;
}
function isCompleteToolPayload(message: Message): boolean {
const payload = extractToolResultPayload(message);
if (payload == null) return false;
if (typeof payload === "string") {
const trimmed = payload.trim();
if (!trimmed) return false;
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
try {
JSON.parse(trimmed);
return true;
} catch {
return false;
}
}
return trimmed.length > 20;
}
return typeof payload === "object";
}
/** Freeze a compact evidence pool for a worker-independent report job. */
function snapshotCollectorMaterials(messages: readonly Message[] | undefined): Record<string, unknown>[] {
function snapshotCollectorMaterials(
messages: readonly Message[] | undefined,
options?: { limit?: number; includeSummary?: boolean },
): Record<string, unknown>[] {
if (!messages?.length) return [];
const limit = options?.limit ?? COLLECTOR_PERSIST_LIMIT;
const frozen: Record<string, unknown>[] = [];
const seen = new Set<string>();
const text = (value: unknown) => typeof value === "string" ? value.trim() : "";
const addCandidate = (candidate: unknown, toolName: string, index: number) => {
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) return;
const item = candidate as Record<string, unknown>;
const url = text(item.url) || text(item.source_url) || text(item.link);
const id = text(item.id) || text(item.recUuid) || text(item.rec_uuid) || text(item.uuid);
const title = text(item.title) || text(item.name) || `检索结果 ${index + 1}`;
const content = text(item.content) || text(item.raw_content) || text(item.snippet)
|| text(item.content_preview) || text(item.text);
const addNormalized = (item: ReturnType<typeof normalizeSearchResults>[number], toolName: string) => {
const url = text(item.url);
const id = text(item.id) || text(item.recUuid) || text(item.uuid) || text(item.docId);
const title = text(item.title) || `检索结果 ${frozen.length + 1}`;
const content = text(item.content) || text(item.snippet);
if (!url && !id && !content) return;
const key = id || url || `${title}\n${content.slice(0, 240)}`;
if (seen.has(key) || frozen.length >= 80) return;
if (seen.has(key) || frozen.length >= limit) return;
seen.add(key);
frozen.push({
id: id || undefined,
title: title.slice(0, 500),
url: url.slice(0, 2_000) || undefined,
raw_content: content.slice(0, 6_000),
snippet: content.slice(0, 500) || undefined,
snippet: (text(item.snippet) || content).slice(0, 500) || undefined,
source: text(item.source) || toolName || "collector",
source_type: toolName === "web_search" ? "web" : "other",
published_at: text(item.publish_time) || text(item.published_at) || text(item.time) || undefined,
rec_uuid: text(item.recUuid) || text(item.rec_uuid) || undefined,
published_at: text(item.published_date) || text(item.publishedDate) || text(item.date) || undefined,
rec_uuid: text(item.recUuid) || text(item.uuid) || undefined,
});
};
const visitPayload = (payload: unknown, toolName: string) => {
if (typeof payload === "string") {
try {
visitPayload(JSON.parse(payload), toolName);
} catch {
// A plain-text tool result is represented by the collector summary.
}
return;
}
if (Array.isArray(payload)) {
payload.forEach((item, index) => addCandidate(item, toolName, index));
return;
}
if (!payload || typeof payload !== "object") return;
const record = payload as Record<string, unknown>;
for (const key of ["results", "items", "records", "documents", "hits", "data", "sources"]) {
if (Array.isArray(record[key])) {
record[key].forEach((item, index) => addCandidate(item, toolName, index));
return;
}
}
addCandidate(record, toolName, 0);
};
for (const message of messages) {
if (message.type === "tool") {
visitPayload(extractToolResultPayload(message), message.name || "collector_tool");
if (message.type === "tool" && message.name && SKIP_COLLECTOR_PERSIST_TOOLS.has(message.name)) {
continue;
}
const toolName = message.name || (message.type === "tool" ? "collector_tool" : "");
const payload = message.type === "tool" || message.type === "ai"
? extractToolResultPayload(message)
: undefined;
const rows = normalizeSearchResults(
payload && typeof payload === "object" && !Array.isArray(payload)
? payload
: typeof payload === "string"
? payload
: undefined,
);
for (const row of rows) addNormalized(row, toolName || "collector");
}
if (!frozen.length && options?.includeSummary !== false) {
const summary = [...messages].reverse().find(
(message) => message.type === "ai" && extractContentFromMessage(message).trim(),
);
const content = summary ? extractContentFromMessage(summary).trim() : "";
if (content) {
frozen.push({
title: "收集员小结",
raw_content: content.slice(0, 6_000),
snippet: content.slice(0, 500),
source: "collector",
source_type: "other",
});
}
}
// Do not disguise the collector's narrative conclusion as a source. A
// summary-only snapshot used to look like one successful material, which
// prevented the backend from trying the checkpoint/durable fallback that
// actually contains the source rows.
return frozen;
}
@@ -583,9 +624,18 @@ function DeepResearchWorkbenchInner() {
const replayedTraceJobs = useRef(new Set<string>());
const pendingReportEvents = useRef<ProgressEvent[]>([]);
const reportAnimationFrame = useRef<number | null>(null);
const boundStreamJobRef = useRef<string | null>(null);
const openReportRef = useRef<() => void>(() => {});
const refetchLiveJobRef = useRef<() => void>(() => {});
const inlineGenerationSessionRef = useRef<DeepResearchSession | null>(null);
// Chat-collection flow bookkeeping.
const pendingInitialQuery = useRef<string | null>(null);
const finishedTurnKey = useRef<string | null>(null);
const persistedCollectorKey = useRef("");
const persistedCollectorToolIds = useRef(new Set<string>());
const persistedCollectorMaterialKeys = useRef(new Set<string>());
const collectorPersistChain = useRef(Promise.resolve());
const collectorSessionIdRef = useRef<string | null>(null);
const followUpAbortRef = useRef<AbortController | null>(null);
const refetchSession = sessionQuery.refetch;
@@ -594,6 +644,22 @@ function DeepResearchWorkbenchInner() {
const refetchReportVariants = reportVariants.refetch;
const session = sessionQuery.data;
const loggedSessionDiagnosticKey = useRef<string | null>(null);
useEffect(() => {
if (!session) return;
const diagnostics = session.runtimeDiagnostics;
if (!session.error && !diagnostics) return;
const key = `${session.id}:${session.activeJobId ?? session.usage?.lastJobId ?? ""}:${session.error ?? ""}`;
if (loggedSessionDiagnosticKey.current === key) return;
loggedSessionDiagnosticKey.current = key;
console.warn("[深度研究] session_runtime_diagnostic", {
sessionId: session.id,
status: session.status,
error: session.error,
activeJobId: session.activeJobId,
runtimeDiagnostics: diagnostics,
});
}, [session]);
const generatedReportVariants = useMemo(() => {
const byId = new Map((reportVariants.data ?? []).map((item) => [item.id, item]));
if (inlineGeneratedReport && session && inlineGeneratedReport.parentSessionId === session.id) {
@@ -671,12 +737,25 @@ function DeepResearchWorkbenchInner() {
// a worker is stale or a new provider wording escapes that guard.
const refusalReportDetected = isRefusalReportText(rawDisplayedReport);
const hasUsableDisplayedReport = Boolean(rawDisplayedReport.trim()) && !refusalReportDetected;
// A completed session may retain an old diagnostic in `session.error` even
// after the backend has produced a usable fallback report. That historical
// diagnostic must not be appended to an otherwise normal report.
const streamStillOpen = Boolean(run.jobId && run.phase !== "done" && run.phase !== "failed");
const liveWriting = Boolean(
isRunning
|| isAwaitingReview
|| reportDocumentRewrite.isActive
|| streamStillOpen
|| (viewingLiveGeneration && run.phase !== "done"),
);
// Never replace a live write with the operator diagnostic. An empty sandbox
// during thinking is normal; the JSON dump is only for a finished run that
// still has no usable Markdown.
const repairOnlyError = typeof session?.error === "string"
&& session.error.includes("session_read_repair");
const showRunDiagnostic = Boolean(
refusalReportDetected
|| (!hasUsableDisplayedReport && (session?.error || session?.status === "failed")),
!liveWriting && !hasUsableDisplayedReport && (
refusalReportDetected
|| session?.status === "failed"
|| (Boolean(session?.error) && !repairOnlyError)
),
);
const runtimeDiagnosticText = JSON.stringify(
session?.runtimeDiagnostics ?? {
@@ -793,10 +872,19 @@ function DeepResearchWorkbenchInner() {
report: displayedReport,
writing: sandboxWriting,
onFirstReportContent: handleFirstReportContent,
// Open only after thinking has finished and the first write token exists.
// Thinking stays on the left progress card; an empty writing-phase stub
// must not steal the right panel.
activity: Boolean(run.report.trim()),
// Open on the first visible write, including first-generation reports and
// a new sibling. Stay put if the user has already switched to another card.
autoOpen: viewingLiveGeneration || reportDocumentRewrite.isActive,
});
openReportRef.current = openReport;
inlineGenerationSessionRef.current = inlineGenerationSession;
refetchLiveJobRef.current = () => {
void Promise.all([refetchSession(), refetchSessions(), refetchSources(), refetchReportVariants()]);
};
const sandboxVisible = sandboxOpen && Boolean(
displayedReport || isRunning || isAwaitingReview || reportDocumentRewrite.isActive || isGeneratingNewReport,
);
@@ -805,6 +893,13 @@ function DeepResearchWorkbenchInner() {
openReport();
}, [openReport]);
useEffect(() => {
if (!viewingLiveGeneration && !reportDocumentRewrite.isActive) return;
if (!run.report.trim()) return;
setSandboxOpen(true);
openReport();
}, [openReport, reportDocumentRewrite.isActive, run.report, viewingLiveGeneration]);
// ArtifactFileList invokes its handler synchronously. Switch the virtual
// sandbox file first, then open it from an effect once useReportSandbox has
// rebuilt its URL for the generated report session.
@@ -1103,6 +1198,66 @@ function DeepResearchWorkbenchInner() {
session,
]);
// Persist each completed search as it lands in the step list, instead of
// waiting until the whole collection turn finishes and writing 80 rows at once.
useEffect(() => {
collectorSessionIdRef.current = session?.id ?? null;
persistedCollectorToolIds.current = new Set();
persistedCollectorMaterialKeys.current = new Set();
persistedCollectorKey.current = "";
collectorPersistChain.current = Promise.resolve();
}, [session?.id]);
useEffect(() => {
if (!isChatSession || !session?.id) return;
if (session.status !== "collecting" && session.status !== "draft") return;
const remaining = COLLECTOR_PERSIST_LIMIT - persistedCollectorMaterialKeys.current.size;
if (remaining <= 0) return;
const pending: Record<string, unknown>[] = [];
const claimedToolIds: string[] = [];
const claimedKeys: string[] = [];
for (const message of collectorThread.messages ?? []) {
if (message.type !== "tool" || !message.id) continue;
if (message.name && SKIP_COLLECTOR_PERSIST_TOOLS.has(message.name)) continue;
if (persistedCollectorToolIds.current.has(message.id)) continue;
if (!isCompleteToolPayload(message)) continue;
const rows = snapshotCollectorMaterials([message], {
limit: remaining - pending.length,
includeSummary: false,
});
persistedCollectorToolIds.current.add(message.id);
claimedToolIds.push(message.id);
for (const row of rows) {
const key = collectorMaterialKey(row);
if (!key || persistedCollectorMaterialKeys.current.has(key)) continue;
persistedCollectorMaterialKeys.current.add(key);
claimedKeys.push(key);
pending.push(row);
if (pending.length >= remaining) break;
}
if (pending.length >= remaining) break;
}
if (!pending.length) return;
const sessionId = session.id;
collectorPersistChain.current = collectorPersistChain.current
.then(() => actions.persistCollectorMaterials.mutateAsync({
sessionId,
collectorMaterials: pending,
quiet: true,
}))
.catch(() => {
if (collectorSessionIdRef.current !== sessionId) return;
for (const toolId of claimedToolIds) persistedCollectorToolIds.current.delete(toolId);
for (const key of claimedKeys) persistedCollectorMaterialKeys.current.delete(key);
});
}, [
actions.persistCollectorMaterials,
collectorThread.messages,
isChatSession,
session?.id,
session?.status,
]);
// Turn finished without a pending clarification → surface the config card.
// Requiring a non-human tail closes the window between the optimistic user
// message and the run flag flipping, where the turn would look "ended".
@@ -1119,7 +1274,20 @@ function DeepResearchWorkbenchInner() {
if (finishedTurnKey.current === key) return;
finishedTurnKey.current = key;
if (session) {
void actions.updateStatus.mutate({ sessionId: session.id, status: "awaiting_report" });
const leftover = snapshotCollectorMaterials(collectorThread.messages, {
limit: COLLECTOR_PERSIST_LIMIT,
}).filter((item) => {
const materialKey = collectorMaterialKey(item);
if (!materialKey || persistedCollectorMaterialKeys.current.has(materialKey)) return false;
persistedCollectorMaterialKeys.current.add(materialKey);
return true;
});
persistedCollectorKey.current = `${session.id}:${persistedCollectorMaterialKeys.current.size}`;
void actions.updateStatus.mutate({
sessionId: session.id,
status: "awaiting_report",
...(leftover.length ? { collectorMaterials: leftover } : {}),
});
}
}, [
actions.updateStatus,
@@ -1127,10 +1295,45 @@ function DeepResearchWorkbenchInner() {
collectorPendingClarification,
collectorStreaming,
collectorTailIsHuman,
collectorThread.messages,
isChatSession,
session,
]);
// After a refresh the collector messages reload from the thread. Persist
// anything the incremental writer missed so 「选择结构再生成」 still has rows.
useEffect(() => {
if (!isChatSession || !session?.id) return;
if (session.status !== "awaiting_report") return;
const leftover = snapshotCollectorMaterials(collectorThread.messages, {
limit: COLLECTOR_PERSIST_LIMIT,
}).filter((item) => {
const materialKey = collectorMaterialKey(item);
if (!materialKey || persistedCollectorMaterialKeys.current.has(materialKey)) return false;
persistedCollectorMaterialKeys.current.add(materialKey);
return true;
});
if (!leftover.length) return;
const persistKey = `${session.id}:${persistedCollectorMaterialKeys.current.size}`;
if (persistedCollectorKey.current === persistKey) return;
persistedCollectorKey.current = persistKey;
void actions.persistCollectorMaterials.mutateAsync({
sessionId: session.id,
collectorMaterials: leftover,
}).catch(() => {
persistedCollectorKey.current = "";
for (const item of leftover) {
persistedCollectorMaterialKeys.current.delete(collectorMaterialKey(item));
}
});
}, [
actions.persistCollectorMaterials,
collectorThread.messages,
isChatSession,
session?.id,
session?.status,
]);
const collectedMaterialCount = useMemo(
() => countCollectorMaterials(collectorThread.messages),
[collectorThread.messages],
@@ -1384,18 +1587,21 @@ function DeepResearchWorkbenchInner() {
if (!streamJobId || (!isRunning && !isAwaitingReview && !replayingTerminalTrace)) return;
if (replayingTerminalTrace && replayedTraceJobs.current.has(streamJobId)) return;
if (replayingTerminalTrace) replayedTraceJobs.current.add(streamJobId);
seenEventKeys.current = new Set();
liveEventSerial.current = 0;
pendingReportEvents.current = [];
setRun({ ...EMPTY_RUN, jobId: streamJobId });
// Keep the sandbox closed until a report delta is available. `useReportSandbox`
// opens it at that first visible write, rather than during model thinking
// or after the report has already finished. If the user has already opened
// another file card, leave that view alone.
const keepCurrentSandbox = Boolean(
inlineGenerationSession && sandboxSessionId !== inlineGenerationSession.id,
inlineGenerationSessionRef.current && sandboxSessionId !== inlineGenerationSessionRef.current.id,
);
if (!replayingTerminalTrace && !keepCurrentSandbox) setSandboxOpen(false);
const jobChanged = boundStreamJobRef.current !== streamJobId;
if (jobChanged) {
boundStreamJobRef.current = streamJobId;
seenEventKeys.current = new Set();
liveEventSerial.current = 0;
pendingReportEvents.current = [];
setRun({ ...EMPTY_RUN, jobId: streamJobId });
// Only close the sandbox when a *new* job starts. Reconnecting the same
// write stream used to wipe the live draft and flip auto-open off, so
// thinking could finish while the right panel stayed closed.
if (!replayingTerminalTrace && !keepCurrentSandbox) setSandboxOpen(false);
}
const flushReportEvents = () => {
reportAnimationFrame.current = null;
@@ -1471,6 +1677,13 @@ function DeepResearchWorkbenchInner() {
: eventKey(event);
if (seenEventKeys.current.has(key)) return;
seenEventKeys.current.add(key);
const firstWriteToken = (
event.type === "report_delta" || event.type === "report_chunk"
) && typeof event.payload?.delta === "string" && event.payload.delta.trim();
if (firstWriteToken && !keepCurrentSandbox) {
setSandboxOpen(true);
openReportRef.current();
}
if (
event.type === "plan_delta" ||
event.type === "plan_reasoning" ||
@@ -1503,8 +1716,9 @@ function DeepResearchWorkbenchInner() {
},
onTerminal: () => {
if (!replayingTerminalTrace) replayedTraceJobs.current.add(streamJobId);
if (inlineGenerationSession) {
void getResearchSession(inlineGenerationSession.id).then((completed) => {
const liveInline = inlineGenerationSessionRef.current;
if (liveInline) {
void getResearchSession(liveInline.id).then((completed) => {
setInlineGeneratedReport((current) => current?.session.id === completed.id
? { ...current, session: completed }
: current);
@@ -1513,7 +1727,7 @@ function DeepResearchWorkbenchInner() {
console.error("刷新新报告状态失败:", error);
});
}
void Promise.all([refetchSession(), refetchSessions(), refetchSources(), refetchReportVariants()]);
refetchLiveJobRef.current();
},
onError: (detail) => setRun((current) => current.jobId === streamJobId ? { ...current, error: detail } : current),
},
@@ -1527,15 +1741,25 @@ function DeepResearchWorkbenchInner() {
}
pendingReportEvents.current = [];
};
}, [activeJobId, inlineGenerationSession, isAwaitingReview, isRunning, refetchReportVariants, refetchSession, refetchSessions, refetchSources, sandboxSessionId, session?.status, streamJobId]);
}, [activeJobId, isAwaitingReview, isRunning, sandboxSessionId, session?.status, streamJobId]);
useEffect(() => {
if (!session?.reportMarkdown) return;
// A background regeneration writes into `run` for the child job. Do not
// copy the parent markdown over that live stream.
if (inlineGenerationSession && inlineGenerationSession.id !== session.id) return;
setRun((current) => current.jobId === streamJobId ? { ...current, report: session.reportMarkdown! } : current);
}, [inlineGenerationSession, session?.id, session?.reportMarkdown, streamJobId]);
// A new write must start from a blank sandbox. Copying the previous
// durable report into `run` opened the panel with stale text, then the
// model only thought (summary / reasoning) and the sandbox never grew.
if (isRunning || isAwaitingReview) return;
const lastJobId = typeof session.usage?.lastJobId === "string" ? session.usage.lastJobId : null;
setRun((current) => {
if (current.jobId !== streamJobId) return current;
if (current.jobId && lastJobId !== current.jobId) return current;
if (current.report === session.reportMarkdown) return current;
return { ...current, report: session.reportMarkdown! };
});
}, [inlineGenerationSession, isAwaitingReview, isRunning, session?.id, session?.reportMarkdown, session?.usage?.lastJobId, streamJobId]);
// ── actions ────────────────────────────────────────────────────────────────
@@ -1553,6 +1777,10 @@ function DeepResearchWorkbenchInner() {
setFollowUpStream(null);
pendingInitialQuery.current = null;
finishedTurnKey.current = null;
persistedCollectorKey.current = "";
persistedCollectorToolIds.current = new Set();
persistedCollectorMaterialKeys.current = new Set();
collectorPersistChain.current = Promise.resolve();
}, []);
// 历史报告不抢占对话区域:切换研究记录时总是先折叠右侧沙箱;用户可通过
@@ -1602,6 +1830,10 @@ function DeepResearchWorkbenchInner() {
});
pendingInitialQuery.current = query;
finishedTurnKey.current = null;
persistedCollectorKey.current = "";
persistedCollectorToolIds.current = new Set();
persistedCollectorMaterialKeys.current = new Set();
collectorPersistChain.current = Promise.resolve();
setSelectedId(created.id);
setSandboxOpen(false);
} catch (error) {
@@ -1918,10 +2150,16 @@ function DeepResearchWorkbenchInner() {
if (!activeJobId) return;
try {
await actions.cancelJob.mutateAsync(activeJobId);
toast.success("已请求停止研究");
toast.success("已停止撰写");
await Promise.all([refetchSession(), refetchSessions()]);
} catch (error) {
toast.error(error instanceof Error ? error.message : "停止研究失败");
const message = error instanceof Error ? error.message : "停止研究失败";
if (/not in a cancellable state/i.test(message) || /409/.test(message)) {
toast.success("已停止撰写");
await Promise.all([refetchSession(), refetchSessions()]);
return;
}
toast.error(message);
}
}, [actions.cancelJob, activeJobId, refetchSession, refetchSessions]);
@@ -1959,14 +2197,23 @@ function DeepResearchWorkbenchInner() {
const handleReopenReportCard = useCallback(async () => {
if (!session) return;
try {
await actions.updateStatus.mutateAsync({ sessionId: session.id, status: "awaiting_report" });
await actions.updateStatus.mutateAsync({
sessionId: session.id,
status: "awaiting_report",
collectorMaterials: snapshotCollectorMaterials(collectorThread.messages, {
limit: COLLECTOR_PERSIST_LIMIT,
}),
});
await refetchSession();
} catch (error) {
toast.error(error instanceof Error ? error.message : "打开报告配置失败");
}
}, [actions.updateStatus, refetchSession, session]);
}, [actions.updateStatus, collectorThread.messages, refetchSession, session]);
const handleConfirmReportCard = useCallback(async (config: Record<string, unknown>) => {
const handleConfirmReportCard = useCallback(async (
config: Record<string, unknown>,
extras?: { writeWithoutMaterials?: boolean },
) => {
if (!session) return;
// Keep the composer's explicit choice authoritative when the collection
// phase turns into a report-writing job.
@@ -1974,19 +2221,44 @@ function DeepResearchWorkbenchInner() {
? { fast_model: selectedModel, smart_model: selectedModel, strategic_model: selectedModel }
: {};
try {
await actions.startJob.mutateAsync({
const writeWithoutMaterials = extras?.writeWithoutMaterials === true;
const collectorMaterials = writeWithoutMaterials
? []
: snapshotCollectorMaterials(collectorThread.messages, { limit: WRITE_FALLBACK_MATERIAL_LIMIT });
console.warn("[深度研究] browser_snapshot_sending", {
sessionId: session.id,
// First writing must harvest the collector thread before entering the
// shared static-material report engine. Structural regeneration can
// read already-persisted sources, but a first report cannot assume
// that selected source rows exist yet.
entry: "chat",
config: { ...config, ...modelConfig },
collectorMaterials: snapshotCollectorMaterials(collectorThread.messages),
messageCount: collectorThread.messages?.length ?? 0,
toolMessageCount: (collectorThread.messages ?? []).filter((message) => message.type === "tool").length,
snapshotCount: collectorMaterials.length,
snapshotLimit: WRITE_FALLBACK_MATERIAL_LIMIT,
writeWithoutMaterials,
sampleTitles: collectorMaterials.slice(0, 5).map((item) => item.title),
});
const started = await actions.startJob.mutateAsync({
sessionId: session.id,
entry: "chat",
query: session.query,
title: session.title,
config: { ...config, ...modelConfig },
collectorMaterials,
forceNoMaterials: writeWithoutMaterials,
});
console.warn("[深度研究] browser_snapshot_accepted", started);
if (writeWithoutMaterials) {
toast.warning("已按无素材模式启动撰写,对话里收集到的资料本次不会送入写作。");
} else if (!collectorMaterials.length) {
toast.warning("未传入检索素材,将按无素材模式撰写。请看控制台 [深度研究] browser_snapshot_sending");
}
setSandboxOpen(false);
toast.success("开始撰写研究报告");
} catch (error) {
console.error("[深度研究] write_report_failed", {
sessionId: session.id,
query: session.query,
title: session.title,
detail: error instanceof Error ? error.message : String(error),
error,
});
toast.error(error instanceof Error ? error.message : "启动报告撰写失败");
}
}, [actions.startJob, collectorThread.messages, selectedModel, session]);
@@ -2182,7 +2454,7 @@ function DeepResearchWorkbenchInner() {
imageGenerationAvailable={imageGenerationAvailable}
imageLimit={capabilities.data?.imageGeneration?.maxImages ?? 4}
submitting={actions.startJob.isPending}
onConfirm={(config) => void handleConfirmReportCard(config)}
onConfirm={(config, extras) => void handleConfirmReportCard(config, extras)}
onContinueCollecting={handleContinueCollecting}
/>
);
@@ -507,7 +507,7 @@ export function ReportConfigCard({
imageGenerationAvailable: boolean;
imageLimit: number;
submitting: boolean;
onConfirm: (config: Record<string, unknown>) => void;
onConfirm: (config: Record<string, unknown>, extras?: { writeWithoutMaterials?: boolean }) => void;
onContinueCollecting: () => void;
}) {
const [mode, setMode] = useState<ResearchMode>(
@@ -521,6 +521,7 @@ export function ReportConfigCard({
retrievalSkills: retrievalSkillsOverride ?? defaultOptions.retrievalSkills,
}));
const [generateImages, setGenerateImages] = useState(defaultGenerateImages);
const [writeWithoutMaterials, setWriteWithoutMaterials] = useState(false);
const [advancedOpen, setAdvancedOpen] = useState(false);
// The collector composer and this confirmation card are both reachable while
@@ -572,9 +573,11 @@ export function ReportConfigCard({
下方选项沿用原研究配置并可调整;确认后将基于以上对话收集到的资料撰写报告。
{modelLabel ? <>写作模型:<span className="font-medium text-foreground">{modelLabel}</span>。</> : null}
</p>
<div className="mt-2 inline-flex items-center gap-1.5 rounded-md border border-primary/15 bg-primary/[0.06] px-2 py-1 text-[11px] text-primary">
<div className={`mt-2 inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-[11px] ${writeWithoutMaterials ? "border-amber-500/30 bg-amber-500/10 text-amber-800 dark:text-amber-200" : "border-primary/15 bg-primary/[0.06] text-primary"}`}>
<span className="font-medium">已收集 {materialCount} 条资料</span>
<span className="text-primary/70">将作为本次报告的写作依据</span>
<span className={writeWithoutMaterials ? "opacity-80" : "text-primary/70"}>
{writeWithoutMaterials ? "本次不送入写作,按无素材模式成稿" : "将作为本次报告的写作依据"}
</span>
</div>
</div>
</div>
@@ -621,6 +624,19 @@ export function ReportConfigCard({
)}
</div>
<label className="mt-3 flex cursor-pointer items-start gap-2 rounded-lg border border-border/70 bg-background/60 p-2.5 text-[11px] leading-4">
<input
type="checkbox"
checked={writeWithoutMaterials}
onChange={(event) => setWriteWithoutMaterials(event.target.checked)}
className="mt-0.5 accent-primary"
/>
<span>
<span className="block font-medium text-foreground">无素材写作</span>
忽略对话里已收集的资料,直接走无素材成稿,用来确认这条链路是否生效。已落库的来源不会被删除。
</span>
</label>
<div className="mt-3 overflow-hidden rounded-xl border border-border/70">
<button
type="button"
@@ -710,7 +726,14 @@ export function ReportConfigCard({
<MessageSquarePlus className="mr-1.5 h-3.5 w-3.5" />
继续补充资料
</Button>
<Button size="sm" disabled={submitting} onClick={() => onConfirm(buildReportCardConfig(mode, generateImages, options))}>
<Button
size="sm"
disabled={submitting}
onClick={() => onConfirm(
buildReportCardConfig(mode, generateImages, options),
{ writeWithoutMaterials },
)}
>
{submitting && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
<Sparkles className="mr-1.5 h-3.5 w-3.5" />
开始撰写报告
@@ -53,6 +53,13 @@ interface UseReportSandboxOptions {
writing: boolean;
/** First visible Markdown has arrived; the host can expand the right panel then. */
onFirstReportContent?: () => void;
/**
* The writing run has produced something observable (report text or model
* reasoning). Report text streams as non-durable live frames, so on a
* multi-worker backend it can lag well behind the actual write; this lets
* the host open the sandbox on the first sign of progress instead.
*/
activity?: boolean;
/**
* A structural regeneration writes a sibling file in the background. Leave
* the currently displayed report in the sandbox until the user opens that
@@ -66,6 +73,7 @@ export function useReportSandbox({
report,
writing,
onFirstReportContent,
activity = false,
autoOpen = true,
}: UseReportSandboxOptions) {
const { select, setOpen, openStreamedArtifact } = useArtifacts();
@@ -95,12 +103,35 @@ export function useReportSandbox({
wasWritingRef.current = writing;
}, [writing]);
const hadReportRef = useRef(Boolean(report.trim()));
useEffect(() => {
const hasReport = Boolean(report.trim());
// Keep the panel closed through reasoning. When the first Markdown token
// arrives, allow auto-open even if an earlier empty stub was prepared.
if (writing && hasReport && !hadReportRef.current) autoOpenedRef.current = false;
hadReportRef.current = hasReport;
}, [report, writing]);
// 每帧 upsert stub。普通空帧不抹掉已渲染的文件,但写作过程中的
// 空正文表示后端要求重置报告版本(report_reset)或新稿尚未出首字,
// 此时必须清掉旧版,避免把首版失败文本继续展示在沙箱里。
//
// 清掉的是**正文**而不是文件本身:多 worker 部署下 report_delta 属于
// 进程内 live 帧,观察端可能连在另一个 worker 上,首帧要等 0.8s 的
// 落库轮询(report_chunk)才到。若此时把 stub 置为 null,右栏就没有
// 可打开的虚拟文件,"开始撰写即打开沙箱" 会整段失效。
useEffect(() => {
if (!report) {
if (writing) setStubMessage(null);
if (writing) {
setStubMessage({
id: messageId,
type: "ai",
content: "",
tool_calls: [
{ id: toolCallId, name: "write_file", args: { path: REPORT_OUTPUT_PATH, content: "" } },
],
});
}
return;
}
setStubMessage((prev) => {
@@ -121,14 +152,14 @@ export function useReportSandbox({
});
}, [messageId, report, toolCallId, writing]);
// 报告首帧到达且仍在写入 → 自动展开右栏并选中虚拟 URL。
// 思考结束后的报告正文首帧到达且仍在写入 → 自动展开右栏并选中虚拟 URL。
// openStreamedArtifact 尊重用户手动关闭(suppress),不会强行弹回。
useEffect(() => {
if (!autoOpen || !report.trim() || !writing || autoOpenedRef.current) return;
if (!autoOpen || (!report.trim() && !activity) || !writing || autoOpenedRef.current) return;
autoOpenedRef.current = true;
onFirstReportContent?.();
openStreamedArtifact(virtualUrl, artifactPreviewKey(virtualUrl));
}, [autoOpen, onFirstReportContent, openStreamedArtifact, report, virtualUrl, writing]);
}, [activity, autoOpen, onFirstReportContent, openStreamedArtifact, report, virtualUrl, writing]);
/** 手动打开(头部「报告」按钮 / 完成卡「查看报告」):始终选中虚拟 URL。 */
const openReport = useCallback(() => {