feat: 新增 agentfx 任务深链与研判报告入库流程
支持 goPath=agentfx 的 iframe 嵌入问答、内置研判助手种子与报告导入协助卡,并完善深度研究流式报告能力。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
6e9e89a6cb
commit
52b9cbf707
34 files changed
+4488
-392
No files matched your search
@@ -0,0 +1,378 @@
|
||||
"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();
|
||||
}
|
||||
Reference in new issue
Block a user