Files
deerflow-code/frontend-web/src/core/threads/use-research-report-stream.ts
T
董宏凯andCursor 52b9cbf707 feat: 新增 agentfx 任务深链与研判报告入库流程
支持 goPath=agentfx 的 iframe 嵌入问答、内置研判助手种子与报告导入协助卡,并完善深度研究流式报告能力。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 16:32:22 +08:00

379 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
/**
* 聊天「写作模式」内联深度研究报告的流式状态。
*
* 后端 `deep_research_report` 工具把引擎事件经 LangGraph custom 通道
* (`get_stream_writer`)以 `{type: "deep_research_report", event: {...}}`
* 帧发到前端 `onCustomEvent`;本模块按 threadId 归约这些帧,供聊天页注入
* 进度步骤条(`deep_research_progress` 虚拟工具调用)与 report.md 的
* `write_file` stub 流式预览(复用沙箱 write-file 虚拟 URL 机制)。
*
* 与工作台 SSE 不同:custom 事件不落库、断线不重放,刷新页面即丢——
* 报告完成后 md 已持久化在聊天线程 artifacts,由真实产物接管展示。
*/
import { useCallback, useSyncExternalStore } from "react";
/** 深度研究 basic 管线的阶段序(与后端 events.ALL_PHASES 对齐的子集)。 */
const PHASE_ORDER = [
"initializing",
"planning",
"collecting",
"curating",
"compressing",
"writing",
"done",
] as const;
function phaseRank(phase: string): number {
const index = PHASE_ORDER.indexOf(phase as (typeof PHASE_ORDER)[number]);
return index === -1 ? 0 : index;
}
export type ResearchReportStreamState = {
/** "active" | "success" | "error"(含被取消/断流)。 */
status: "active" | "success" | "error";
/** 工具调用 id(帧里的 jobId),一次写作轮一个。 */
jobId: string;
phase: string;
queries: string[];
completedQueries: number;
sourceCount: number;
/** report_delta/report_chunk 按 offset 去重拼装的报告全文。 */
reportText: string;
/** report_thinking 增量(写作思考,可选展示)。 */
thinking: string;
error: string | null;
};
type Listener = () => void;
type StoreEntry = {
state: ResearchReportStreamState | null;
listeners: Set<Listener>;
emitTimer: ReturnType<typeof setTimeout> | null;
};
const EMPTY_ACTIVE: ResearchReportStreamState = {
status: "active",
jobId: "",
phase: "initializing",
queries: [],
completedQueries: 0,
sourceCount: 0,
reportText: "",
thinking: "",
error: null,
};
const stores = new Map<string, StoreEntry>();
function getStore(threadId: string): StoreEntry {
let store = stores.get(threadId);
if (!store) {
store = { state: null, listeners: new Set(), emitTimer: null };
stores.set(threadId, store);
}
return store;
}
/**
* token 帧的通知合并间隔(ms)。report_delta / report_thinking 是 token 级
* 高频帧(每秒可达数十帧);状态本身同步更新(字符串拼接很便宜),但订阅
* 通知若逐帧发出,会让聊天页整棵树(长消息列表 + 沙箱 Markdown 预览)按
* token 频率重渲染,页面直接卡死。工作台用 requestAnimationFrame 批处理
* (DeepResearchWorkbench),聊天这里等效地按固定间隔合并——每秒最多
* 10 次通知,流式观感不受影响。终态帧(tool_finished)与
* finalize/clear 立即 flush,结束态不滞后。
*/
const EMIT_COALESCE_MS = 100;
function emitNow(store: StoreEntry) {
if (store.emitTimer !== null) {
clearTimeout(store.emitTimer);
store.emitTimer = null;
}
for (const listener of store.listeners) listener();
}
function emitCoalesced(store: StoreEntry) {
if (store.emitTimer !== null) return;
store.emitTimer = setTimeout(() => {
store.emitTimer = null;
for (const listener of store.listeners) listener();
}, EMIT_COALESCE_MS);
}
export function clearResearchReportStream(threadId: string) {
const store = getStore(threadId);
if (store.state === null) return;
store.state = null;
emitNow(store);
}
/** run 结束(成功/失败/取消/断流)时收尾仍处于 active 的状态。 */
export function finalizeResearchReportStream(threadId: string) {
const store = getStore(threadId);
if (store.state?.status !== "active") return;
store.state = { ...store.state, status: "error", error: store.state.error ?? "已中断" };
emitNow(store);
}
/** 与工作台 appendReportDelta 相同的 offset 去重追加。 */
function appendWithOffset(current: string, delta: string, offset: unknown): string {
if (!delta) return current;
if (typeof offset !== "number" || !Number.isFinite(offset) || offset < 0) return current + delta;
if (offset >= current.length) return current + delta;
if (current.slice(offset, offset + delta.length) === delta) return current;
return current.slice(0, offset) + delta;
}
/** onCustomEvent 深度研究帧的 event 载荷。 */
export type ResearchReportEventFrame = {
type: string;
phase?: string;
payload?: Record<string, unknown>;
};
export function ingestResearchReportEvent(threadId: string, frame: ResearchReportEventFrame) {
const store = getStore(threadId);
const jobId = typeof (frame as { jobId?: unknown }).jobId === "string"
? String((frame as { jobId?: unknown }).jobId)
: "";
const previous = store.state;
// 新一轮工具调用(jobId 变化)从零开始,旧状态自然淘汰。
const base: ResearchReportStreamState = previous && previous.jobId === jobId
? previous
: { ...EMPTY_ACTIVE, jobId };
const payload = frame.payload ?? {};
let next = base;
switch (frame.type) {
case "phase_changed":
next = { ...base, phase: typeof payload.to === "string" ? payload.to : base.phase };
break;
case "queries_planned":
next = {
...base,
queries: Array.isArray(payload.queries)
? payload.queries.filter((q): q is string => typeof q === "string")
: base.queries,
completedQueries: 0,
};
break;
case "query_completed":
next = {
...base,
completedQueries: base.completedQueries + 1,
sourceCount: base.sourceCount + (typeof payload.resultCount === "number" ? payload.resultCount : 0),
};
break;
case "sources_curated": {
const selected = Array.isArray(payload.selectedIds) ? payload.selectedIds.length : null;
next = {
...base,
sourceCount: selected !== null && selected > 0 ? selected : base.sourceCount,
};
break;
}
case "report_delta":
case "report_chunk": {
if (typeof payload.target === "string" && payload.target !== "report") break;
const delta = typeof payload.delta === "string" ? payload.delta : "";
const reportText = appendWithOffset(base.reportText, delta, payload.offset);
next = reportText === base.reportText ? base : { ...base, reportText };
break;
}
case "report_thinking": {
const delta = typeof payload.delta === "string" ? payload.delta : "";
next = delta ? { ...base, thinking: base.thinking + delta } : base;
break;
}
case "report_completed":
next = {
...base,
phase: "writing",
sourceCount: typeof payload.sourceCount === "number" && payload.sourceCount > 0
? payload.sourceCount
: base.sourceCount,
};
break;
case "warning":
case "error": {
const message = typeof payload.message === "string" ? payload.message : "";
if (payload.recoverable === false && message) {
next = { ...base, error: message };
}
break;
}
case "tool_finished":
next = {
...base,
status: payload.status === "success" ? "success" : "error",
error: typeof payload.error === "string" ? payload.error : base.error,
phase: payload.status === "success" ? "done" : base.phase,
};
break;
default:
break;
}
if (frame.phase && next === base) next = { ...base, phase: frame.phase };
if (next !== previous) {
store.state = next;
// 状态同步更新(peek/快照立即可见);通知按 token 帧频率合并,终态立即。
if (frame.type === "tool_finished") {
emitNow(store);
} else {
emitCoalesced(store);
}
}
}
/** 订阅某线程的报告流状态;无进行中/已结束状态时返回 null。 */
export function useResearchReportStream(threadId: string | null | undefined): ResearchReportStreamState | null {
const subscribe = useCallback(
(listener: Listener) => {
if (!threadId) return () => {};
return subscribeResearchReportStream(threadId, listener);
},
[threadId],
);
const getSnapshot = useCallback(() => (threadId ? getStore(threadId).state : null), [threadId]);
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}
/**
* 直接订阅某线程的通知(节流合并后触发,非逐 token 帧);返回退订函数。
* `useResearchReportStream` 内部也走这里,测试可直接验证合并语义。
*/
export function subscribeResearchReportStream(threadId: string, listener: Listener): () => void {
const store = getStore(threadId);
store.listeners.add(listener);
return () => {
store.listeners.delete(listener);
};
}
/** 非订阅式快照读取(测试 / 调试用)。 */
export function peekResearchReportStream(threadId: string): ResearchReportStreamState | null {
return getStore(threadId).state;
}
// ── 聊天页注入消息构造 ────────────────────────────────────────────────────────
export const RESEARCH_REPORT_OUTPUT_PATH = "/mnt/user-data/outputs/report.md";
export type ResearchProgressToolCall = {
id: string;
name: "deep_research_progress";
args: {
label: string;
detail: string;
status: "complete" | "active";
thinking?: string;
};
};
/** 结构与工作台 buildWritingProgressToolCalls 相同;步骤固定为 basic 管线四段。 */
export function buildResearchProgressToolCalls(state: ResearchReportStreamState): ResearchProgressToolCall[] {
const rank = phaseRank(state.phase);
const finished = state.status !== "active";
const effectiveRank = finished && state.status === "success" ? PHASE_ORDER.length - 1 : rank;
const stages = [
{
id: "plan",
label: "拆解检索问题",
boundary: phaseRank("collecting"),
detail: state.queries.length > 0
? `已拆出 ${state.queries.length} 个检索子问题`
: "正在围绕课题拆解检索子问题",
},
{
id: "collect",
label: "检索参考资料",
boundary: phaseRank("compressing"),
detail: state.queries.length > 0
? `已检索 ${Math.min(state.completedQueries, state.queries.length)}/${state.queries.length} 个子问题 · 累计 ${state.sourceCount} 条资料`
: state.sourceCount > 0
? `已检索到 ${state.sourceCount} 条资料`
: "正在逐个子问题检索资料",
},
{
id: "write",
label: "撰写研究报告",
boundary: PHASE_ORDER.length,
detail: state.reportText
? "正在按「通用分析结构」逐字撰写,右侧沙箱同步预览"
: "正在整理资料并开始撰写报告",
},
];
const activeIndex = stages.findIndex((stage) => effectiveRank < stage.boundary);
const visibleLast = activeIndex === -1 ? stages.length - 1 : activeIndex;
return stages.slice(0, visibleLast + 1).map((stage, index) => {
const complete = effectiveRank >= stage.boundary;
const failed = finished && state.status === "error" && index === visibleLast && !complete;
return {
id: `research-progress-${state.jobId}-${stage.id}`,
name: "deep_research_progress" as const,
args: {
label: failed ? `${stage.label}(已中断)` : stage.label,
detail: failed && state.error ? `${stage.detail};${state.error}` : stage.detail,
status: complete ? ("complete" as const) : ("active" as const),
...(stage.id === "write" && state.thinking.trim() ? { thinking: state.thinking } : {}),
},
};
});
}
export type ResearchFeedMessage = {
id: string;
type: "ai";
content: string;
tool_calls: {
id: string;
name: string;
args: Record<string, unknown>;
}[];
};
/**
* 组装聊天页要注入的两条虚拟 ai 消息:进度步骤条 + report.md 的 write_file
* stub(沙箱 write-file 虚拟 URL 预览的数据源)。仅在报告尚无正文或仍在
* 流式时输出 stub —— 完成后由真实 artifacts 接管。
*/
export function buildResearchFeedMessages(state: ResearchReportStreamState): ResearchFeedMessage[] {
const messages: ResearchFeedMessage[] = [
{
id: `research-progress-${state.jobId}`,
type: "ai",
content: "",
tool_calls: buildResearchProgressToolCalls(state),
},
];
if (state.reportText.trim()) {
messages.push({
id: `research-report-${state.jobId}`,
type: "ai",
content: "",
tool_calls: [
{
id: `research-report-${state.jobId}-write`,
name: "write_file",
args: { path: RESEARCH_REPORT_OUTPUT_PATH, content: state.reportText },
},
],
});
}
return messages;
}
/** write-file 虚拟 URL(ArtifactFileDetail 用它从 stub 消息读流式内容)。 */
export function buildResearchReportVirtualUrl(state: ResearchReportStreamState): string {
return new URL(
`write-file:${RESEARCH_REPORT_OUTPUT_PATH}?message_id=${encodeURIComponent(`research-report-${state.jobId}`)}&tool_call_id=${encodeURIComponent(`research-report-${state.jobId}-write`)}&language=markdown`,
).toString();
}