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
@@ -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(() => {
|
||||
|
||||
Reference in new issue
Block a user