1216 lines
51 KiB
TypeScript
1216 lines
51 KiB
TypeScript
import { apiFetch } from "@/core/api/fetch-client";
|
||
import { getBackendBaseURL } from "@/core/config";
|
||
import { OptType, writeYLog } from "@/core/log/y-log";
|
||
|
||
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
|
||
import { reportRoundtableError, stageForRoundtableAgent } from "@/roundtable-planning/api/diagnostics-report";
|
||
import { createInlineReasoningDeltaFormatter } from "@/roundtable-planning/lib/stream-reasoning";
|
||
|
||
export type ThreadIdMap = Record<string, string>;
|
||
|
||
export interface SeatReadyEvent {
|
||
agentName: string;
|
||
threadId: string;
|
||
displayName: string;
|
||
description: string;
|
||
}
|
||
|
||
export interface CoordinatorReadyEvent {
|
||
mainAgentName: string;
|
||
threadId: string;
|
||
}
|
||
|
||
export interface InitMultiAgentRequest {
|
||
agentNames: string[];
|
||
mainAgentName: string;
|
||
model?: string;
|
||
/** Fires the moment each sub-agent's thread is ready (real completion order). */
|
||
onSeatReady?: (event: SeatReadyEvent) => void;
|
||
/** Fires when the coordinator's thread + agent have been created. */
|
||
onCoordinatorReady?: (event: CoordinatorReadyEvent) => void;
|
||
signal?: AbortSignal;
|
||
}
|
||
|
||
export interface InitMultiAgentResponse {
|
||
status: "success";
|
||
main_agent_name: string;
|
||
thread_ids: ThreadIdMap;
|
||
}
|
||
|
||
/**
|
||
* Open the /api/multi-agent/init SSE stream. Fires per-seat / coordinator
|
||
* callbacks as events arrive so the UI can light up roles individually, and
|
||
* resolves with the final {main_agent_name, thread_ids} once init_done lands.
|
||
* If the server emits an "error" frame mid-stream, the promise rejects.
|
||
*/
|
||
export async function initMultiAgent(
|
||
payload: InitMultiAgentRequest,
|
||
): Promise<InitMultiAgentResponse> {
|
||
// Timing telemetry for /api/multi-agent/init — enabled by default so we can
|
||
// see where time goes when users report "Step 2 init feels slow". Disable
|
||
// with `window.__MULTI_AGENT_INIT_TIMING__ = false` in devtools.
|
||
const timingEnabled =
|
||
typeof window === "undefined"
|
||
? false
|
||
: (window as unknown as { __MULTI_AGENT_INIT_TIMING__?: boolean }).__MULTI_AGENT_INIT_TIMING__ !== false;
|
||
const t0 = performance.now();
|
||
let tFirstByte = 0;
|
||
let tCoord = 0;
|
||
let seatCount = 0;
|
||
const tlog = (label: string) => {
|
||
if (!timingEnabled) return;
|
||
const elapsed = (performance.now() - t0).toFixed(0);
|
||
console.debug(`[init-timing] +${elapsed}ms ${label}`);
|
||
};
|
||
tlog("fetch.start");
|
||
|
||
const res = await apiFetch(`${getBackendBaseURL()}/api/multi-agent/init`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
agent_names: payload.agentNames,
|
||
main_agent_name: payload.mainAgentName,
|
||
model: payload.model,
|
||
}),
|
||
signal: payload.signal,
|
||
});
|
||
tlog(`fetch.headers status=${res.status}`);
|
||
if (!res.ok || !res.body) {
|
||
const text = await res.text().catch(() => "");
|
||
reportRoundtableError({
|
||
stage: "step2_init", event: "init_http_error",
|
||
message: `第二步会商初始化失败 (HTTP ${res.status}): ${text.slice(0, 500)}`,
|
||
httpStatus: res.status,
|
||
detail: { agentNames: payload.agentNames, mainAgentName: payload.mainAgentName },
|
||
});
|
||
throw new Error(`init multi-agent failed (${res.status}): ${text}`);
|
||
}
|
||
|
||
const reader = res.body.getReader();
|
||
const decoder = new TextDecoder();
|
||
let buffer = "";
|
||
let mainAgentName = "";
|
||
let threadIds: ThreadIdMap | null = null;
|
||
let aborted = false;
|
||
let errorDetail: string | null = null;
|
||
|
||
try {
|
||
while (true) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
if (tFirstByte === 0) {
|
||
tFirstByte = performance.now();
|
||
tlog(`first-byte (TTFB-after-headers=${(tFirstByte - t0).toFixed(0)}ms)`);
|
||
}
|
||
buffer += decoder.decode(value, { stream: true });
|
||
const lines = buffer.split("\n");
|
||
buffer = lines.pop() ?? "";
|
||
|
||
for (const rawLine of lines) {
|
||
const line = rawLine.trimEnd();
|
||
if (!line.startsWith("data:")) continue;
|
||
const data = line.slice(5).trim();
|
||
if (!data) continue;
|
||
let parsed: unknown;
|
||
try {
|
||
parsed = JSON.parse(data);
|
||
} catch {
|
||
continue;
|
||
}
|
||
if (!parsed || typeof parsed !== "object") continue;
|
||
const obj = parsed as Record<string, unknown>;
|
||
const event = typeof obj.event === "string" ? obj.event : "";
|
||
switch (event) {
|
||
case "seat_ready": {
|
||
seatCount += 1;
|
||
tlog(`seat_ready #${seatCount} ${String(obj.agent_name ?? "?")}`);
|
||
payload.onSeatReady?.({
|
||
agentName: String(obj.agent_name ?? ""),
|
||
threadId: String(obj.thread_id ?? ""),
|
||
displayName: String(obj.display_name ?? ""),
|
||
description: String(obj.description ?? ""),
|
||
});
|
||
break;
|
||
}
|
||
case "coordinator_ready": {
|
||
tCoord = performance.now();
|
||
tlog(`coordinator_ready (since-first-seat=${(tCoord - tFirstByte).toFixed(0)}ms)`);
|
||
payload.onCoordinatorReady?.({
|
||
mainAgentName: String(obj.main_agent_name ?? ""),
|
||
threadId: String(obj.thread_id ?? ""),
|
||
});
|
||
break;
|
||
}
|
||
case "init_done": {
|
||
tlog(`init_done seats=${seatCount}`);
|
||
mainAgentName = String(obj.main_agent_name ?? "");
|
||
threadIds = (obj.thread_ids ?? {}) as ThreadIdMap;
|
||
break;
|
||
}
|
||
case "error": {
|
||
errorDetail = String(obj.detail ?? "unknown init error");
|
||
break;
|
||
}
|
||
default:
|
||
// Unknown event — ignore for forward-compat.
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
} catch (err) {
|
||
aborted = (err as Error)?.name === "AbortError" || payload.signal?.aborted === true;
|
||
if (!aborted) {
|
||
reportRoundtableError({
|
||
stage: "step2_init", event: "init_stream_exception",
|
||
message: `第二步会商初始化中断(网络不可达 / 连接中断 / 超时):${(err as Error)?.message ?? String(err)}`,
|
||
detail: { seatCount },
|
||
});
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
tlog(`total ${(performance.now() - t0).toFixed(0)}ms (seats=${seatCount})`);
|
||
|
||
if (errorDetail) {
|
||
reportRoundtableError({
|
||
stage: "step2_init", event: "init_error_frame",
|
||
message: `第二步会商初始化报错:${errorDetail.slice(0, 800)}`,
|
||
});
|
||
throw new Error(`init multi-agent failed: ${errorDetail}`);
|
||
}
|
||
if (aborted) {
|
||
throw new DOMException("init aborted", "AbortError");
|
||
}
|
||
if (!threadIds || !mainAgentName) {
|
||
reportRoundtableError({
|
||
stage: "step2_init", event: "init_incomplete",
|
||
message: "第二步会商初始化流提前结束(未收到 init_done,可能连接被中断)",
|
||
detail: { seatCount, hasThreadIds: !!threadIds, mainAgentName },
|
||
});
|
||
throw new Error("init multi-agent stream ended without init_done");
|
||
}
|
||
return {
|
||
status: "success",
|
||
main_agent_name: mainAgentName,
|
||
thread_ids: threadIds,
|
||
};
|
||
}
|
||
|
||
export type StreamAgentType = "leader" | "special";
|
||
|
||
export type LeaderDispatch = [string, string];
|
||
|
||
/**
|
||
* High-level phases the SSE stream goes through. Used by the UI to show
|
||
* "what the backend is doing" while the user waits — most of the time before
|
||
* a first token is upstream/LLM latency, which feels like a stall otherwise.
|
||
*
|
||
* - `connecting` — fetch sent, no frames yet
|
||
* - `connected` — got the `event: metadata` frame (run_id assigned)
|
||
* - `streaming` — first AI text chunk arrived; tokens flowing
|
||
* - `tool_calling` — model emitted a tool_call chunk (e.g. agent_orchestration,
|
||
* present_files); usually means the run is about to interrupt
|
||
* - `tool_result` — a tool result message came back; brief, may go back to streaming
|
||
*/
|
||
export type StreamPhase = "connecting" | "connected" | "streaming" | "tool_calling" | "tool_result";
|
||
|
||
export interface StreamMultiAgentRequest {
|
||
agentType: StreamAgentType;
|
||
dAgentThreadId: ThreadIdMap;
|
||
agentName: string;
|
||
/**
|
||
* 人类可读名称(中文/英文)。仅 special 走广播时使用 —— 后端把它拼进
|
||
* 「子智能体X完成Y工作,交付内容为:...」里,这样总控 LLM 在下一轮看到自己
|
||
* 历史里的交付摘要时能用"语义化的名字"识别,而不是 UUID 字符串(LLM 对
|
||
* UUID 不友好,会导致重复派活)。后端缺失时 fallback 到 agentName。
|
||
*/
|
||
displayName?: string;
|
||
newMessage: string;
|
||
model?: string;
|
||
/**
|
||
* 仅 special 用。席位执行模式(快速/思考/专业问答/多智能体问答)派生的运行参数,
|
||
* 覆盖后端 `roundtable_run_policy` 的 thinking/reasoning 默认值。省略时后端按 policy
|
||
* 默认执行(向后兼容,旧前端不传不破坏)。三者通常一起传(见 `lib/seat-mode.ts`)。
|
||
*/
|
||
thinkingEnabled?: boolean;
|
||
reasoningEffort?: "minimal" | "low" | "medium" | "high";
|
||
/** 仅 special 用;ultra 档置真,给席位解锁 `task` 子代理工具。 */
|
||
subagentEnabled?: boolean;
|
||
/**
|
||
* 仅 special 用。会商席位「技能识别强化」开关(沿用所选业务链条配置)。置 true 时后端把该
|
||
* 席位配置的技能(含 SKILL.md 路径 + 必须先 read_file 的硬指令)拼进本轮任务,补偿弱模型。
|
||
* 与该席位 agent 自身 config.yaml 的同名开关取 OR。省略 = false(关)。
|
||
*/
|
||
seatSkillDirective?: boolean;
|
||
/**
|
||
* 深链接业务码(rwfx→6BF 等)。**仅方案总结报告(summary)单例消费**:非空 → 后端按业务链抽取规范
|
||
* 强制注入「完整性核对」指令,让总结核对产出相对业务链完不完整。普通会商省略 = 不注入。
|
||
*/
|
||
businessCode?: string | null;
|
||
skillStopNames?: string[];
|
||
/**
|
||
* 仅 leader 用。置 true 表示这是「派活后让总控判断继续/综合」的轮次:后端会把各已交付
|
||
* 席位的**完整交付正文**额外注入总控输入,供其写最终方案。派活决策轮(首轮等)传 false/省略,
|
||
* 后端只给摘要,使总控上下文精简、响应快。
|
||
*/
|
||
synthesisMode?: boolean;
|
||
/**
|
||
* 仅为兼容旧协议保留。总控 prompt 属于总控私有上下文,前端恒传 true,后端也会无条件隔离。
|
||
*/
|
||
suppressPreBroadcast?: boolean;
|
||
/**
|
||
* 仅 special 用。置 true 表示这是 DAG 并行批次里的席位:后端按 `seat_parallel`
|
||
* run policy **禁用** write_file / bash / str_replace / present_files 等写文件 /
|
||
* 沙箱工具(§4),避免同批多席位并发抢同一沙箱。产物以消息正文交付。后端缺失
|
||
* 该字段时按 false 处理(普通 seat policy),旧前端不受影响。
|
||
*/
|
||
parallelNoFile?: boolean;
|
||
/**
|
||
* 仅 special 用。前序席位的**完整交付** `[{name, content}]`。后端注入 run context
|
||
* (`roundtable_peer_deliveries`),席位用 `read_peer_delivery` 工具**按需**取全文
|
||
* (平时只看广播摘要)。不进 prompt → 不调工具就不耗 token。
|
||
*/
|
||
peerDeliveries?: Array<{ name: string; content: string }>;
|
||
/**
|
||
* 用户随干预消息上传的参考文件(已经过 `/api/threads/{thread_id}/uploads`)。
|
||
* 透传给后端 `additional_kwargs.files`,由 UploadsMiddleware 注入清单 + 解锁
|
||
* read_file 等工具。字段 `path` 用上传返回的 `virtual_path`。
|
||
*/
|
||
files?: Array<{ filename: string; size: number; path: string; status?: string }>;
|
||
signal?: AbortSignal;
|
||
/** Fires for every non-status data frame the upstream emits. */
|
||
onUpstreamFrame?: (frame: unknown) => void;
|
||
/**
|
||
* Fires for every AI text chunk parsed out of LangGraph messages-tuple frames.
|
||
* `text` is the incremental delta (concatenate to rebuild the message);
|
||
* `messageId` is stable per AI message so callers can group deltas.
|
||
*/
|
||
onTextDelta?: (text: string, messageId: string) => void;
|
||
/**
|
||
* Fires whenever the inferred SSE phase changes. The UI can use this to
|
||
* show progressive status messages during the inevitable first-token wait.
|
||
* `name` is the tool name when phase is `tool_calling` (or empty otherwise).
|
||
* `args` 是 tool_calling 阶段的工具入参(取自上游 LangGraph messages-tuple
|
||
* 帧中当前 tool call 的 args),供 UI 渲染路径 chip / 代码块等子节点。
|
||
* `toolCallId` 可将同名工具的连续调用与同一次调用的增量参数区分开。
|
||
*/
|
||
onPhaseChange?: (
|
||
phase: StreamPhase,
|
||
name?: string,
|
||
args?: Record<string, unknown>,
|
||
toolCallId?: string,
|
||
) => void;
|
||
/**
|
||
* 模型自动容错:后端检测到当前模型出问题(兜底错误文案 / 交付为空等),已自动切到下一个
|
||
* 模型并即将重跑本轮时触发。上层据此把该角色「正在流式的失败气泡」重置(清空已累计的增量
|
||
* 文本)并给用户一条「『X』模型异常,已切换『Y』继续」的提示。此回调可能多次触发(连续多个
|
||
* 模型都失败时)。触发后,本次 streamMultiAgent 仍会继续用新模型流式,最终照常 resolve。
|
||
*/
|
||
onModelSwitch?: (info: ModelSwitchInfo) => void;
|
||
}
|
||
|
||
/** 模型自动容错通知(`streamMultiAgent` 的 `onModelSwitch` 回调入参)。 */
|
||
export interface ModelSwitchInfo {
|
||
/** 角色:leader / seat / seat_parallel / report / summary / dashboard / ingest。 */
|
||
role: string;
|
||
/** 出问题的智能体 agent_name。 */
|
||
agentName: string;
|
||
/** 失败的模型名。 */
|
||
failedModel: string;
|
||
/** 已切换到的下一个模型名。 */
|
||
nextModel: string;
|
||
/** 失败原因(已翻译成简短中文,如「服务繁忙」)。 */
|
||
reason: string;
|
||
}
|
||
|
||
export interface ClarificationPayload {
|
||
/**
|
||
* Stable id for this clarification — the originating `ask_clarification`
|
||
* tool_call_id. Lets the UI key per-question selections when the agent asks
|
||
* several clarifications in one turn (Step 1 multi-question support).
|
||
* Omitted on the Step 2 leader path (single clarification per turn).
|
||
*/
|
||
id?: string;
|
||
/** Raw question text from the model's ask_clarification args. */
|
||
question: string;
|
||
/** Optional category hint from the model (e.g. "missing_info"). */
|
||
clarificationType: string;
|
||
/** Free-text reason the model needs clarification. */
|
||
context: string;
|
||
/** Suggested answer chips (may be empty). */
|
||
options: unknown[];
|
||
/** Whether the user is allowed to type a custom answer. */
|
||
allowCustom: boolean;
|
||
/** When false, option cards are single-select and send on click. Omitted → inferred from clarificationType. */
|
||
allowMultiple?: boolean;
|
||
}
|
||
|
||
export interface StreamMultiAgentResult {
|
||
/** For leader runs: dispatched [agentName, task] tuples. Empty = consensus. */
|
||
dispatched: LeaderDispatch[];
|
||
/** For special runs: the completion status string. */
|
||
message: string | null;
|
||
/** Final assistant text emitted by the underlying agent. */
|
||
content: string;
|
||
/** Echoed agent_name of the agent that produced this run. */
|
||
agentName: string | null;
|
||
/** 实际产出本轮结果的模型(发生模型自动容错时即最终切换到的模型);后端未下发时为 null。 */
|
||
modelUsed?: string | null;
|
||
/**
|
||
* Populated when the leader called ask_clarification — the run ended without
|
||
* dispatching, and the model is asking the user a question instead. When
|
||
* present, callers should render `content` (or `clarification.question` as
|
||
* a fallback) as a question and collect a user reply rather than treating
|
||
* an empty `dispatched` as consensus.
|
||
*/
|
||
clarification: ClarificationPayload | null;
|
||
/**
|
||
* 席位本轮无可见正文(仅思考 / 断流 / 空串)。此时 `content` 为空,调用方不得把
|
||
* 流式过程文本当成已交付,也不得写入 priorDeliveries。
|
||
*/
|
||
invalidDelivery?: { reason: string } | null;
|
||
}
|
||
|
||
/**
|
||
* Best-effort partial JSON parser for accumulated `tool_call_chunks.args`.
|
||
*
|
||
* LangChain emits tool args as a series of string fragments (one delta per
|
||
* chunk). To get progressive `args.path` / `args.content` during streaming —
|
||
* the way `useStream` does in the main chat — we concatenate the deltas and
|
||
* try to parse. When the JSON is still mid-token (e.g. `{"path":"/mnt`) we
|
||
* close any open string + brackets and retry, so callers can read partially
|
||
* available keys even before the full payload arrives.
|
||
*
|
||
* Returns null when nothing useful can be extracted yet.
|
||
*/
|
||
function parsePartialJson(s: string): Record<string, unknown> | null {
|
||
if (!s) return null;
|
||
try {
|
||
const v = JSON.parse(s);
|
||
return v && typeof v === "object" ? (v as Record<string, unknown>) : null;
|
||
} catch {
|
||
// fall through to best-effort close + retry
|
||
}
|
||
let trimmed = s;
|
||
// A dangling `\` would turn our injected closing `"` into an escape; drop it.
|
||
if (trimmed.endsWith("\\")) trimmed = trimmed.slice(0, -1);
|
||
let inString = false;
|
||
let escaped = false;
|
||
const stack: string[] = [];
|
||
for (const ch of trimmed) {
|
||
if (escaped) {
|
||
escaped = false;
|
||
continue;
|
||
}
|
||
if (ch === "\\") {
|
||
escaped = true;
|
||
continue;
|
||
}
|
||
if (inString) {
|
||
if (ch === '"') inString = false;
|
||
continue;
|
||
}
|
||
if (ch === '"') {
|
||
inString = true;
|
||
continue;
|
||
}
|
||
if (ch === "{") stack.push("}");
|
||
else if (ch === "[") stack.push("]");
|
||
else if (ch === "}" || ch === "]") stack.pop();
|
||
}
|
||
let closed = trimmed;
|
||
if (inString) {
|
||
closed += '"';
|
||
} else {
|
||
// Mid-key / dangling separator (`{"path":`) can't be balanced cleanly.
|
||
closed = closed.replace(/[,:]\s*$/, "");
|
||
}
|
||
while (stack.length) closed += stack.pop();
|
||
try {
|
||
const v = JSON.parse(closed);
|
||
return v && typeof v === "object" ? (v as Record<string, unknown>) : null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Identify the final status frame our backend appends after the LangGraph
|
||
* stream. Required keys are `status` (array for leader, string for special)
|
||
* and `content` (final assistant text); `agent_name` is also expected.
|
||
*/
|
||
function isFinalStatusFrame(value: unknown): value is {
|
||
status: LeaderDispatch[] | string;
|
||
content?: string;
|
||
agent_name?: string;
|
||
question?: string;
|
||
clarification_type?: string;
|
||
clarification_context?: string;
|
||
options?: unknown[];
|
||
allow_custom?: boolean;
|
||
allow_multiple?: boolean;
|
||
} {
|
||
if (!value || typeof value !== "object") return false;
|
||
if (!("status" in value) || !("content" in value)) return false;
|
||
const status = (value as { status: unknown }).status;
|
||
if (typeof status === "string") return true;
|
||
if (Array.isArray(status)) {
|
||
return status.every(
|
||
(item) =>
|
||
Array.isArray(item) && item.length === 2 && typeof item[0] === "string" && typeof item[1] === "string",
|
||
);
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Extract an AI text delta from a LangGraph `messages-tuple` data frame.
|
||
* Frames look like `[chunk, metadata]`; an AI text chunk carries `type` in
|
||
* {"ai","AIMessageChunk"} and a `content` that is either a string or a list of
|
||
* structured content blocks. Returns null when the frame is not an AI text
|
||
* chunk (e.g., tool calls, tool results, value snapshots).
|
||
*/
|
||
function extractAiTextDelta(
|
||
parsed: unknown,
|
||
): { content: string; reasoning: string; id: string } | null {
|
||
if (!Array.isArray(parsed) || parsed.length < 1) return null;
|
||
const chunk = parsed[0];
|
||
if (!chunk || typeof chunk !== "object") return null;
|
||
const obj = chunk as Record<string, unknown>;
|
||
const type = String(obj.type ?? "").toLowerCase();
|
||
// Accept any AI-shaped message chunk LangChain might emit across versions:
|
||
// "ai" (older), "AIMessageChunk" (newer streaming), or plain "AIMessage".
|
||
if (type !== "ai" && type !== "aimessagechunk" && type !== "aimessage") return null;
|
||
const content = obj.content;
|
||
let text = "";
|
||
if (typeof content === "string") {
|
||
text = content;
|
||
} else if (Array.isArray(content)) {
|
||
for (const item of content) {
|
||
if (typeof item === "string") text += item;
|
||
else if (item && typeof item === "object") {
|
||
const block = item as Record<string, unknown>;
|
||
const blockText = block.text;
|
||
if (typeof blockText === "string") text += blockText;
|
||
}
|
||
}
|
||
}
|
||
const additionalKwargs = obj.additional_kwargs;
|
||
const directReasoning = obj.reasoning_content ?? obj.reasoning;
|
||
const nestedReasoning =
|
||
additionalKwargs && typeof additionalKwargs === "object"
|
||
? (additionalKwargs as Record<string, unknown>).reasoning_content ??
|
||
(additionalKwargs as Record<string, unknown>).reasoning
|
||
: undefined;
|
||
const rawReasoning = nestedReasoning ?? directReasoning;
|
||
const reasoning = typeof rawReasoning === "string" ? rawReasoning : "";
|
||
if (!text && !reasoning) return null;
|
||
const id = typeof obj.id === "string" ? obj.id : "";
|
||
return { content: text, reasoning, id };
|
||
}
|
||
|
||
/**
|
||
* Open an SSE connection to /api/multi-agent/run/stream and resolve once the
|
||
* stream completes. The final frame (`{status: ...}`) is interpreted; every
|
||
* other `data:` frame is forwarded to `onUpstreamFrame` so the caller can do
|
||
* its own rendering (we don't try to mimic LangGraph SDK semantics here).
|
||
*/
|
||
export async function streamMultiAgent(
|
||
req: StreamMultiAgentRequest,
|
||
): Promise<StreamMultiAgentResult> {
|
||
// 整段包一层兜底:除已在具体分支上报的(HTTP 错误 / error 帧 / worker error 事件)外,
|
||
// 再兜住 fetch 直接 reject(网络不可达 / 连接被拒)与流式中途中断(连接重置 / 超时),
|
||
// 做到「能收集到的报错越全越好」。`reported` 防止与具体分支重复上报。
|
||
let reported = false;
|
||
try {
|
||
const res = await apiFetch(`${getBackendBaseURL()}/api/multi-agent/run/stream`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
agent_type: req.agentType,
|
||
d_agent_thread_id: req.dAgentThreadId,
|
||
agent_name: req.agentName,
|
||
// 仅 special 用到;后端缺失时 fallback 到 agent_name,旧前端也不破坏。
|
||
display_name: req.displayName,
|
||
new_message: req.newMessage,
|
||
model: req.model,
|
||
// 席位模式派生参数(仅 special);undefined 时不带上 → 后端按 policy 默认执行。
|
||
thinking_enabled: req.thinkingEnabled,
|
||
reasoning_effort: req.reasoningEffort,
|
||
subagent_enabled: req.subagentEnabled,
|
||
// 仅 special 用;会商技能识别强化(沿用所选业务链条开关)。省略 → 后端按 false(关)处理。
|
||
seat_skill_directive: req.seatSkillDirective ?? false,
|
||
// 仅 summary 单例用;深链接业务码 → 后端注入业务链完整性核对。省略/空 → 不注入。
|
||
business_code: req.businessCode ?? null,
|
||
skill_stop_names: req.skillStopNames ?? [],
|
||
// 仅 leader 用;综合轮让后端注入各席位完整交付正文。省略 → 后端按 false 处理。
|
||
synthesis_mode: req.synthesisMode ?? false,
|
||
// 总控 prompt 永不进入席位 thread;后端同样强制执行,避免旧调用方破坏角色隔离。
|
||
suppress_pre_broadcast: req.suppressPreBroadcast ?? true,
|
||
// 仅 special 用;DAG 并行批次禁写文件(seat_parallel policy)。省略 → 后端按 false 处理。
|
||
parallel_no_file: req.parallelNoFile ?? false,
|
||
// 仅 special 用;前序席位完整交付,后端注入 context 供 read_peer_delivery 按需取全文。
|
||
peer_deliveries: req.peerDeliveries ?? [],
|
||
// 用户随干预上传的参考文件;仅在有文件时带上,后端注入 additional_kwargs.files。
|
||
files: req.files && req.files.length > 0 ? req.files : undefined,
|
||
}),
|
||
signal: req.signal,
|
||
});
|
||
|
||
if (!res.ok || !res.body) {
|
||
const text = await res.text().catch(() => "");
|
||
// 用户实际遇到的报错(含进第三步常见的 502):HTTP 层就失败、SSE 都没建起来。
|
||
reportRoundtableError({
|
||
stage: stageForRoundtableAgent(req.agentType, req.agentName),
|
||
event: "run_stream_http_error",
|
||
message: `「${req.displayName || req.agentName}」调用失败 (HTTP ${res.status}): ${text.slice(0, 500)}`,
|
||
httpStatus: res.status,
|
||
agentId: req.agentName,
|
||
agentName: req.displayName || req.agentName,
|
||
detail: { agentType: req.agentType },
|
||
});
|
||
reported = true;
|
||
throw new Error(`run/stream failed (${res.status}): ${text}`);
|
||
}
|
||
|
||
const reader = res.body.getReader();
|
||
const decoder = new TextDecoder();
|
||
let buffer = "";
|
||
// Track the last `event:` line so logging can show which event a `data:`
|
||
// belongs to. The frontend doesn't actually dispatch by event name — every
|
||
// data frame goes through the same parser — but the label is invaluable
|
||
// for diagnosing why a sub-agent stream "looks empty".
|
||
let lastEvent = "";
|
||
let frameSeq = 0;
|
||
let gotFinalStatus = false;
|
||
|
||
const debugLabel = `[stream:${req.agentType}:${req.agentName}]`;
|
||
// Stream 帧日志(frame# / delta)仅 opt-in 开启。默认关闭,避免 Step 2 流式期间
|
||
// 刷屏(含 wujie 嵌入时子应用 devtools);需要排查 SSE 时在控制台执行:
|
||
// `window.__MULTI_AGENT_DEBUG__ = true`
|
||
const debugEnabled =
|
||
typeof window !== "undefined" &&
|
||
(window as unknown as { __MULTI_AGENT_DEBUG__?: boolean }).__MULTI_AGENT_DEBUG__ === true;
|
||
|
||
const result: StreamMultiAgentResult = {
|
||
dispatched: [],
|
||
message: null,
|
||
content: "",
|
||
agentName: null,
|
||
clarification: null,
|
||
modelUsed: null,
|
||
invalidDelivery: null,
|
||
};
|
||
|
||
// Inferred SSE phase + a tiny helper so we only fire onPhaseChange when the
|
||
// value actually changes (the reader loop sees many frames per phase).
|
||
//
|
||
// `write_file` 的 `content`、`str_replace` 的 old_str/new_str 会在每个
|
||
// tool_call chunk 中持续增长。它们属于实时预览数据,已经由 onUpstreamFrame
|
||
// 写入 stub;若用它们判断 phase 变化,会在单次工具调用里反复触发 React 状态
|
||
// 更新,严重时会形成 Maximum update depth loop。因此这里只比较工具的身份入参。
|
||
const phaseArgsSignature = (args?: Record<string, unknown>) => {
|
||
if (!args) return "";
|
||
const streamedPayloadKeys = new Set([
|
||
"content",
|
||
"old_str",
|
||
"new_str",
|
||
"patch",
|
||
"diff",
|
||
]);
|
||
try {
|
||
return JSON.stringify(
|
||
Object.fromEntries(
|
||
Object.entries(args)
|
||
.filter(([key]) => !streamedPayloadKeys.has(key))
|
||
.sort(([left], [right]) => left.localeCompare(right)),
|
||
),
|
||
);
|
||
} catch {
|
||
return "[unserializable]";
|
||
}
|
||
};
|
||
let currentPhase: StreamPhase = "connecting";
|
||
let currentPhaseName = "";
|
||
let currentPhaseArgsSignature = "";
|
||
let currentPhaseToolCallId = "";
|
||
req.onPhaseChange?.("connecting");
|
||
// The normal chat renders provider reasoning from a distinct message field.
|
||
// Roundtable's bespoke SSE reader keeps that field inline so its existing
|
||
// `<think>` renderer remains the single display path for Steps 2 and 3.
|
||
const inlineAiDelta = createInlineReasoningDeltaFormatter();
|
||
const setPhase = (
|
||
next: StreamPhase,
|
||
name?: string,
|
||
args?: Record<string, unknown>,
|
||
toolCallId?: string,
|
||
) => {
|
||
const nextName = name ?? "";
|
||
const nextArgsSignature = phaseArgsSignature(args);
|
||
const nextToolCallId = toolCallId ?? "";
|
||
// 同 phase、同工具、同身份入参的帧只是流式重复帧,不通知 UI。保留 path/filepaths
|
||
// 等身份变化,确保工具参数从不完整变完整时仍可自动打开沙箱。
|
||
if (
|
||
next === currentPhase &&
|
||
nextName === currentPhaseName &&
|
||
nextArgsSignature === currentPhaseArgsSignature &&
|
||
nextToolCallId === currentPhaseToolCallId
|
||
) {
|
||
return;
|
||
}
|
||
currentPhase = next;
|
||
currentPhaseName = nextName;
|
||
currentPhaseArgsSignature = nextArgsSignature;
|
||
currentPhaseToolCallId = nextToolCallId;
|
||
req.onPhaseChange?.(next, name, args, toolCallId);
|
||
};
|
||
|
||
/**
|
||
* 累积 tool_call_chunks(delta args 字符串) —— 与 LangChain `AIMessageChunk`
|
||
* 合并的内部行为对齐,补主聊天靠 `useStream` 暗中做的那部分工作。Key 是 chunk.index
|
||
* (并发 tool 调用通常 0/1/2 区分);chunk.id 变化时视作"同一 index 上的新调用"并重置。
|
||
* 每帧累积后用 `parsePartialJson` 尽力 parse,成功就把合成的 `tool_calls[]` 注入
|
||
* 到 messages-tuple 帧的 chunkObj 上,让上层 `onUpstreamFrame` 和 `onPhaseChange`
|
||
* 都能拿到完整的 `args.path` / `args.content` 来做"流式期间打开沙箱 + 实时预览"。
|
||
*/
|
||
const toolCallAcc = new Map<
|
||
number,
|
||
{ id?: string; name: string; argsStr: string }
|
||
>();
|
||
|
||
type SyntheticToolCall = {
|
||
id?: string;
|
||
name: string;
|
||
args: Record<string, unknown>;
|
||
};
|
||
|
||
const ingestToolCallChunks = (chunks: unknown): void => {
|
||
if (!Array.isArray(chunks)) return;
|
||
for (const tcc of chunks) {
|
||
if (!tcc || typeof tcc !== "object") continue;
|
||
const tccObj = tcc as Record<string, unknown>;
|
||
const idx = typeof tccObj.index === "number" ? tccObj.index : 0;
|
||
let entry = toolCallAcc.get(idx);
|
||
if (!entry) {
|
||
entry = { name: "", argsStr: "" };
|
||
toolCallAcc.set(idx, entry);
|
||
}
|
||
if (typeof tccObj.id === "string" && tccObj.id) {
|
||
if (entry.id && entry.id !== tccObj.id) {
|
||
// 同一 index 上换了新 tool_call,重置累积(不太常见,defensive)。
|
||
entry.name = "";
|
||
entry.argsStr = "";
|
||
}
|
||
entry.id = tccObj.id;
|
||
}
|
||
if (typeof tccObj.name === "string" && tccObj.name) entry.name = tccObj.name;
|
||
if (typeof tccObj.args === "string") entry.argsStr += tccObj.args;
|
||
}
|
||
};
|
||
|
||
const buildSyntheticToolCalls = (): SyntheticToolCall[] => {
|
||
const out: SyntheticToolCall[] = [];
|
||
for (const entry of toolCallAcc.values()) {
|
||
if (!entry.name) continue;
|
||
const parsed = parsePartialJson(entry.argsStr);
|
||
if (parsed) {
|
||
out.push({ id: entry.id, name: entry.name, args: parsed });
|
||
}
|
||
}
|
||
return out;
|
||
};
|
||
|
||
while (true) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
const chunk = decoder.decode(value, { stream: true });
|
||
buffer += chunk;
|
||
|
||
const lines = buffer.split("\n");
|
||
buffer = lines.pop() ?? "";
|
||
|
||
for (const rawLine of lines) {
|
||
const line = rawLine.trimEnd();
|
||
if (!line) continue;
|
||
if (line.startsWith("event:")) {
|
||
lastEvent = line.slice(6).trim();
|
||
// First metadata frame = run accepted by upstream. Move phase out
|
||
// of "connecting" so the UI can switch from "调度中" to "已连接".
|
||
if (lastEvent === "metadata" && currentPhase === "connecting") {
|
||
setPhase("connected");
|
||
}
|
||
continue;
|
||
}
|
||
if (!line.startsWith("data:")) continue;
|
||
const payload = line.slice(5).trim();
|
||
if (!payload) continue;
|
||
|
||
let parsed: unknown;
|
||
try {
|
||
parsed = JSON.parse(payload);
|
||
} catch {
|
||
if (debugEnabled) console.debug(debugLabel, `bad JSON in event=${lastEvent}:`, payload.slice(0, 200));
|
||
continue;
|
||
}
|
||
|
||
frameSeq += 1;
|
||
if (debugEnabled) console.debug(debugLabel, `frame#${frameSeq} event=${lastEvent}`, parsed);
|
||
|
||
// worker 把 run 内异常(常见:第二步席位/Step3 模型调用失败)发成 `event: error`
|
||
// SSE 帧 `{message,name}`——它既不是 HTTP 错误也不是 `{status:"error"}` 终帧,
|
||
// 单独在这里捕获上报,否则这类「模型没调用成功」的报错会漏收集。
|
||
if (lastEvent === "error") {
|
||
const obj = parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : { raw: parsed };
|
||
reportRoundtableError({
|
||
stage: stageForRoundtableAgent(req.agentType, req.agentName),
|
||
event: "run_worker_error_event",
|
||
message: `「${req.displayName || req.agentName}」运行报错:${String(obj.message ?? obj.name ?? "").slice(0, 800)}`,
|
||
agentId: req.agentName,
|
||
agentName: req.displayName || req.agentName,
|
||
detail: { ...obj, agentType: req.agentType, framesBeforeError: frameSeq },
|
||
});
|
||
}
|
||
|
||
// 模型自动容错帧:后端检测到当前模型出问题,已切到下一个模型并即将重跑本轮。
|
||
// 不是终态帧(无 content)→ 不结束流。清空已累计的(失败尝试的)tool_call 增量,
|
||
// 把 phase 退回 connected,并通知上层重置该角色气泡 + 给用户提示。随后新模型的
|
||
// 流式增量会照常通过 onTextDelta 继续累计到(已被上层清空的)气泡。
|
||
if (
|
||
parsed &&
|
||
typeof parsed === "object" &&
|
||
(parsed as Record<string, unknown>).status === "model_switch"
|
||
) {
|
||
const obj = parsed as Record<string, unknown>;
|
||
toolCallAcc.clear();
|
||
currentPhase = "connected";
|
||
req.onModelSwitch?.({
|
||
role: typeof obj.role === "string" ? obj.role : "",
|
||
agentName: typeof obj.agent_name === "string" ? obj.agent_name : req.agentName,
|
||
failedModel: typeof obj.failed_model === "string" ? obj.failed_model : "",
|
||
nextModel: typeof obj.next_model === "string" ? obj.next_model : "",
|
||
reason: typeof obj.reason === "string" ? obj.reason : "",
|
||
});
|
||
continue;
|
||
}
|
||
|
||
// Phase inference from LangGraph messages-tuple frames. We only flip
|
||
// to tool_calling/tool_result on chunks that clearly indicate them;
|
||
// everything else with non-empty AI text means we're streaming.
|
||
if (lastEvent === "messages" && Array.isArray(parsed) && parsed.length > 0) {
|
||
const chunk = parsed[0];
|
||
if (chunk && typeof chunk === "object") {
|
||
const chunkObj = chunk as Record<string, unknown>;
|
||
const chunkType = String(chunkObj.type ?? "").toLowerCase();
|
||
const toolCalls = chunkObj.tool_calls;
|
||
const toolCallChunks = chunkObj.tool_call_chunks;
|
||
const content = chunkObj.content;
|
||
const streamDelta = extractAiTextDelta(parsed);
|
||
|
||
// 1) 累积本帧的 tool_call_chunks delta(若有),再合成可能可读的 tool_calls。
|
||
// 主聊天 useStream 在 SDK 层做了这件事,我们在自定义 SSE 里手动补上。
|
||
ingestToolCallChunks(toolCallChunks);
|
||
// Some providers serialize `tool_calls` before their `args` have
|
||
// been decoded, leaving args as the raw JSON string. Treating that
|
||
// as a complete call suppresses the accumulated tool_call_chunks,
|
||
// so write_file reaches the sandbox with a path but no content.
|
||
const hasCompleteToolCalls =
|
||
Array.isArray(toolCalls) &&
|
||
toolCalls.length > 0 &&
|
||
toolCalls.every(
|
||
(toolCall) =>
|
||
!!toolCall &&
|
||
typeof toolCall === "object" &&
|
||
!!(toolCall as Record<string, unknown>).args &&
|
||
typeof (toolCall as Record<string, unknown>).args === "object",
|
||
);
|
||
let synthetic: SyntheticToolCall[] = [];
|
||
if (!hasCompleteToolCalls && toolCallAcc.size > 0) {
|
||
synthetic = buildSyntheticToolCalls();
|
||
if (synthetic.length > 0) {
|
||
// 把累积出的 tool_calls 注入回帧的 chunkObj —— 这样下面 onUpstreamFrame
|
||
// 那边的 handleUpstreamFrame 只读 tool_calls 字段就能拿到完整 args,
|
||
// 不必再单独做一份累积。
|
||
chunkObj.tool_calls = synthetic;
|
||
}
|
||
}
|
||
|
||
const effectiveToolCalls: unknown[] | null = hasCompleteToolCalls
|
||
? (toolCalls as unknown[])
|
||
: synthetic.length > 0
|
||
? synthetic
|
||
: null;
|
||
|
||
const hasToolCall =
|
||
(effectiveToolCalls !== null && effectiveToolCalls.length > 0) ||
|
||
(Array.isArray(toolCallChunks) && toolCallChunks.length > 0);
|
||
|
||
if (chunkType === "tool") {
|
||
setPhase("tool_result");
|
||
} else if (hasToolCall) {
|
||
// Pick the active (latest) call's name + args. A leader can dispatch
|
||
// several seats with the same `agent_orchestration` tool in one run;
|
||
// choosing the first accumulated call would leave the UI stuck on an
|
||
// earlier assignment until the final response arrives.
|
||
// 优先用累积出的 / 完整的 tool_calls(含可读 args 对象);若都还不到火候,
|
||
// 回退到 tool_call_chunks 拿名字(args 还是 string,不透传)。
|
||
const activeCall =
|
||
effectiveToolCalls && effectiveToolCalls.length > 0
|
||
? (effectiveToolCalls[effectiveToolCalls.length - 1] as Record<string, unknown>)
|
||
: Array.isArray(toolCallChunks) && toolCallChunks.length > 0
|
||
? (toolCallChunks[toolCallChunks.length - 1] as Record<string, unknown>)
|
||
: null;
|
||
const toolName =
|
||
activeCall && typeof activeCall.name === "string" ? activeCall.name : "";
|
||
const args =
|
||
activeCall &&
|
||
typeof activeCall.args === "object" &&
|
||
activeCall.args !== null
|
||
? (activeCall.args as Record<string, unknown>)
|
||
: undefined;
|
||
const toolCallId =
|
||
activeCall && typeof activeCall.id === "string" ? activeCall.id : undefined;
|
||
setPhase("tool_calling", toolName, args, toolCallId);
|
||
} else if (
|
||
(chunkType === "ai" || chunkType === "aimessagechunk" || chunkType === "aimessage") &&
|
||
(Boolean(streamDelta) ||
|
||
(typeof content === "string" && content.length > 0) ||
|
||
(Array.isArray(content) && content.length > 0))
|
||
) {
|
||
setPhase("streaming");
|
||
}
|
||
}
|
||
}
|
||
|
||
if (isFinalStatusFrame(parsed)) {
|
||
gotFinalStatus = true;
|
||
if (Array.isArray(parsed.status)) {
|
||
result.dispatched = parsed.status;
|
||
} else {
|
||
result.message = parsed.status;
|
||
// "clarification" status carries the leader's ask_clarification
|
||
// payload. Extract the structured fields so the page can render
|
||
// a question (or chip choices) instead of mistaking the run for
|
||
// a consensus.
|
||
if (parsed.status === "clarification") {
|
||
const clarificationType =
|
||
typeof parsed.clarification_type === "string" ? parsed.clarification_type : "";
|
||
result.clarification = {
|
||
question: typeof parsed.question === "string" ? parsed.question : "",
|
||
clarificationType,
|
||
context:
|
||
typeof parsed.clarification_context === "string" ? parsed.clarification_context : "",
|
||
options: Array.isArray(parsed.options) ? parsed.options : [],
|
||
allowCustom: parsed.allow_custom !== false,
|
||
allowMultiple: resolveAllowMultiple(
|
||
typeof parsed.allow_multiple === "boolean" ? parsed.allow_multiple : undefined,
|
||
clarificationType,
|
||
),
|
||
};
|
||
}
|
||
if (parsed.status === "invalid_delivery") {
|
||
const reasonRaw = (parsed as Record<string, unknown>).reason;
|
||
result.invalidDelivery = {
|
||
reason: typeof reasonRaw === "string" && reasonRaw ? reasonRaw : "empty",
|
||
};
|
||
result.content = "";
|
||
}
|
||
}
|
||
if (typeof parsed.content === "string" && !result.invalidDelivery) {
|
||
result.content = parsed.content;
|
||
}
|
||
if (typeof parsed.agent_name === "string") {
|
||
result.agentName = parsed.agent_name;
|
||
}
|
||
{
|
||
const mu = (parsed as Record<string, unknown>).model_used;
|
||
if (typeof mu === "string" && mu) result.modelUsed = mu;
|
||
}
|
||
// SSE error 帧(后端 stream_generator 在席位/Step3 run 失败时下发
|
||
// `{status:"error", content, agent_name}`):上报,便于收集用户侧报错。
|
||
if (result.message === "error") {
|
||
reportRoundtableError({
|
||
stage: stageForRoundtableAgent(req.agentType, result.agentName || req.agentName),
|
||
event: "run_stream_error_frame",
|
||
message: `「${req.displayName || req.agentName}」运行报错:${(result.content || "").slice(0, 800)}`,
|
||
agentId: result.agentName || req.agentName,
|
||
agentName: req.displayName || req.agentName,
|
||
detail: { agentType: req.agentType, framesBeforeError: frameSeq },
|
||
});
|
||
}
|
||
continue;
|
||
}
|
||
|
||
const delta = extractAiTextDelta(parsed);
|
||
if (delta) {
|
||
const text = inlineAiDelta(delta);
|
||
if (debugEnabled) console.debug(debugLabel, `→ delta (${text.length} chars)`);
|
||
if (text) req.onTextDelta?.(text, delta.id);
|
||
}
|
||
|
||
req.onUpstreamFrame?.(parsed);
|
||
}
|
||
}
|
||
|
||
if (debugEnabled) {
|
||
console.debug(debugLabel, `stream ended after ${frameSeq} frames; final content length=${result.content.length}`);
|
||
}
|
||
if (req.agentType === "special" && !gotFinalStatus) {
|
||
result.invalidDelivery = { reason: "truncated" };
|
||
result.content = "";
|
||
}
|
||
return result;
|
||
} catch (err) {
|
||
if (!reported && req.signal?.aborted !== true && (err as Error)?.name !== "AbortError") {
|
||
reportRoundtableError({
|
||
stage: stageForRoundtableAgent(req.agentType, req.agentName),
|
||
event: "run_stream_exception",
|
||
message: `「${req.displayName || req.agentName}」调用异常(网络不可达 / 连接中断 / 超时):${(err as Error)?.message ?? String(err)}`,
|
||
agentId: req.agentName,
|
||
agentName: req.displayName || req.agentName,
|
||
detail: { agentType: req.agentType },
|
||
});
|
||
}
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
export interface SessionArtifactFile {
|
||
/** Virtual path under the sandbox, e.g. `mnt/user-data/outputs/foo.md`. */
|
||
path: string;
|
||
/** Bare filename (basename of `path`). */
|
||
name: string;
|
||
sizeBytes: number;
|
||
/** MIME type guessed from the extension, or null when unknown. */
|
||
mimeType: string | null;
|
||
/** ISO 8601 UTC timestamp of last modification. */
|
||
modifiedAt: string;
|
||
}
|
||
|
||
export interface SessionArtifactsResponse {
|
||
threadId: string;
|
||
files: SessionArtifactFile[];
|
||
}
|
||
|
||
/**
|
||
* List every file written to `/mnt/user-data/outputs/` for a thread. Used by
|
||
* the Step 3 final-report view to surface a thread's produced artifacts so the
|
||
* user can preview/download them via {@link buildArtifactDownloadUrl}.
|
||
*
|
||
* Returns an empty `files` array when the thread produced no artifacts (the
|
||
* backend treats a missing outputs directory as empty, not 404).
|
||
*/
|
||
export async function listSessionArtifacts(
|
||
threadId: string,
|
||
options: { signal?: AbortSignal } = {},
|
||
): Promise<SessionArtifactsResponse> {
|
||
const res = await apiFetch(
|
||
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}/artifacts`,
|
||
{ method: "GET", signal: options.signal },
|
||
);
|
||
if (!res.ok) {
|
||
const text = await res.text().catch(() => "");
|
||
throw new Error(`list artifacts failed (${res.status}): ${text}`);
|
||
}
|
||
const json = (await res.json()) as {
|
||
thread_id: string;
|
||
files: Array<{
|
||
path: string;
|
||
name: string;
|
||
size_bytes: number;
|
||
mime_type: string | null;
|
||
modified_at: string;
|
||
}>;
|
||
};
|
||
const result = {
|
||
threadId: json.thread_id,
|
||
files: (json.files ?? []).map((f) => ({
|
||
path: f.path,
|
||
name: f.name,
|
||
sizeBytes: f.size_bytes,
|
||
mimeType: f.mime_type,
|
||
modifiedAt: f.modified_at,
|
||
})),
|
||
};
|
||
writeYLog({
|
||
optType: OptType.search,
|
||
optDetail: "CM助手-全量-会商产物-查询",
|
||
url: `/api/threads/${threadId}/artifacts`,
|
||
reqParam: { threadId },
|
||
returnParam: { count: result.files.length },
|
||
});
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* Build a URL that previews (inline) or downloads (attachment) an artifact
|
||
* file. The `path` must be a virtual path returned by
|
||
* {@link listSessionArtifacts} (e.g. `mnt/user-data/outputs/foo.md`).
|
||
*/
|
||
export function buildArtifactDownloadUrl(
|
||
threadId: string,
|
||
virtualPath: string,
|
||
options: { download?: boolean } = {},
|
||
): string {
|
||
const segments = virtualPath.split("/").map((s) => encodeURIComponent(s)).join("/");
|
||
const base = `${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}/artifacts/${segments}`;
|
||
return options.download ? `${base}?download=true` : base;
|
||
}
|
||
|
||
export interface ReportThreadResponse {
|
||
/** Always the built-in singleton id `roundtable-report`. */
|
||
agentName: string;
|
||
/** Freshly created lightweight thread to run the report agent on. */
|
||
threadId: string;
|
||
}
|
||
|
||
/**
|
||
* Create a thread for the Step 3「结果绘制」visualization agent
|
||
* (`roundtable-report`, a built-in singleton — this only spins up a cheap
|
||
* thread, it never re-creates the agent). Run it afterwards with
|
||
* {@link streamMultiAgent} (`agentType: "special"`,
|
||
* `agentName: "roundtable-report"`, `dAgentThreadId: { [agentName]: threadId }`).
|
||
*/
|
||
export async function initReportThread(
|
||
options: { signal?: AbortSignal } = {},
|
||
): Promise<ReportThreadResponse> {
|
||
const res = await apiFetch(`${getBackendBaseURL()}/api/multi-agent/report/init`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: "{}",
|
||
signal: options.signal,
|
||
});
|
||
if (!res.ok) {
|
||
const text = await res.text().catch(() => "");
|
||
reportRoundtableError({
|
||
stage: "step3_report", event: "init_thread_http_error",
|
||
message: `结果绘制线程创建失败 (HTTP ${res.status}): ${text.slice(0, 500)}`,
|
||
httpStatus: res.status, agentId: "roundtable-report", agentName: "结果绘制",
|
||
});
|
||
throw new Error(`init report thread failed (${res.status}): ${text}`);
|
||
}
|
||
const json = (await res.json()) as { agent_name: string; thread_id: string };
|
||
return { agentName: json.agent_name, threadId: json.thread_id };
|
||
}
|
||
|
||
/**
|
||
* Create a thread for the Step 3「大屏多页」dashboard-data agent
|
||
* (`roundtable-dashboard`, a built-in singleton — distinct from `roundtable-report`;
|
||
* its run policy disables ALL tools so it only emits a ```report-json block). Run it
|
||
* with {@link streamMultiAgent} (`agentType: "special"`, `agentName: "roundtable-dashboard"`,
|
||
* `dAgentThreadId: { [agentName]: threadId }`); the frontend then `extractReportData`s
|
||
* the streamed text. Follow-up Q&A / refinements reuse the SAME thread (do not re-init)
|
||
* so the agent keeps the prior dashboard context.
|
||
*/
|
||
export async function initDashboardThread(
|
||
options: { signal?: AbortSignal } = {},
|
||
): Promise<ReportThreadResponse> {
|
||
const res = await apiFetch(`${getBackendBaseURL()}/api/multi-agent/dashboard/init`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: "{}",
|
||
signal: options.signal,
|
||
});
|
||
if (!res.ok) {
|
||
const text = await res.text().catch(() => "");
|
||
reportRoundtableError({
|
||
stage: "step3_dashboard", event: "init_thread_http_error",
|
||
message: `大屏页面线程创建失败 (HTTP ${res.status}): ${text.slice(0, 500)}`,
|
||
httpStatus: res.status, agentId: "roundtable-dashboard", agentName: "大屏页面",
|
||
});
|
||
throw new Error(`init dashboard thread failed (${res.status}): ${text}`);
|
||
}
|
||
const json = (await res.json()) as { agent_name: string; thread_id: string };
|
||
return { agentName: json.agent_name, threadId: json.thread_id };
|
||
}
|
||
|
||
/**
|
||
* Create a thread for the Step 3「总结报告」summary agent (`roundtable-summary`,
|
||
* a built-in singleton — distinct from `roundtable-report`; this only spins up a
|
||
* cheap thread, it never re-creates the agent). Run it afterwards with
|
||
* {@link streamMultiAgent} (`agentType: "special"`, `agentName: "roundtable-summary"`,
|
||
* `dAgentThreadId: { [agentName]: threadId }`). Follow-up Q&A / refinements reuse
|
||
* the SAME thread (do not re-init) so the agent keeps the report context.
|
||
*/
|
||
export async function initSummaryThread(
|
||
options: { signal?: AbortSignal } = {},
|
||
): Promise<ReportThreadResponse> {
|
||
const res = await apiFetch(`${getBackendBaseURL()}/api/multi-agent/summary/init`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: "{}",
|
||
signal: options.signal,
|
||
});
|
||
if (!res.ok) {
|
||
const text = await res.text().catch(() => "");
|
||
reportRoundtableError({
|
||
stage: "step3_summary", event: "init_thread_http_error",
|
||
message: `方案报告线程创建失败 (HTTP ${res.status}): ${text.slice(0, 500)}`,
|
||
httpStatus: res.status, agentId: "roundtable-summary", agentName: "方案报告",
|
||
});
|
||
throw new Error(`init summary thread failed (${res.status}): ${text}`);
|
||
}
|
||
const json = (await res.json()) as { agent_name: string; thread_id: string };
|
||
return { agentName: json.agent_name, threadId: json.thread_id };
|
||
}
|
||
|
||
/**
|
||
* Create a per-session thread for the position-roundtable action-planning
|
||
* built-in agent. The agent id is fixed; callers persist/reuse the returned
|
||
* thread id for subsequent plan refinements.
|
||
*/
|
||
export async function initPositionActionPlanThread(
|
||
options: { signal?: AbortSignal } = {},
|
||
): Promise<ReportThreadResponse> {
|
||
const res = await apiFetch(`${getBackendBaseURL()}/api/multi-agent/position-action-plan/init`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: "{}",
|
||
signal: options.signal,
|
||
});
|
||
if (!res.ok) {
|
||
const text = await res.text().catch(() => "");
|
||
reportRoundtableError({
|
||
stage: "position_action_plan",
|
||
event: "init_thread_http_error",
|
||
message: `行动规划线程创建失败 (HTTP ${res.status}): ${text.slice(0, 500)}`,
|
||
httpStatus: res.status,
|
||
agentId: "position-action-planner",
|
||
agentName: "行动规划智能体",
|
||
});
|
||
throw new Error(`init position action-plan thread failed (${res.status}): ${text}`);
|
||
}
|
||
const json = (await res.json()) as { agent_name: string; thread_id: string };
|
||
return { agentName: json.agent_name, threadId: json.thread_id };
|
||
}
|
||
|
||
/**
|
||
* Create a thread for the Step 3「入库」structure agent (`roundtable-structure`,
|
||
* a built-in singleton — the same agent that extracts the flowchart, but this run
|
||
* uses the `ingest` run policy so its attached ingest skill actually executes).
|
||
* Run it afterwards with {@link streamMultiAgent} (`agentType: "special"`,
|
||
* `agentName: "roundtable-structure"`, `dAgentThreadId: { [agentName]: threadId }`):
|
||
* the agent picks the suitable ingest skill (its name may contain「六步法」), reads
|
||
* its SKILL.md and calls it — passing the taskId + summary report + structured flow.
|
||
*/
|
||
export async function initStructureThread(
|
||
options: { signal?: AbortSignal } = {},
|
||
): Promise<ReportThreadResponse> {
|
||
const res = await apiFetch(`${getBackendBaseURL()}/api/multi-agent/structure/init`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: "{}",
|
||
signal: options.signal,
|
||
});
|
||
if (!res.ok) {
|
||
const text = await res.text().catch(() => "");
|
||
reportRoundtableError({
|
||
stage: "step3_structure", event: "init_thread_http_error",
|
||
message: `结构化输出线程创建失败 (HTTP ${res.status}): ${text.slice(0, 500)}`,
|
||
httpStatus: res.status, agentId: "roundtable-structure", agentName: "结构化输出",
|
||
});
|
||
throw new Error(`init structure thread failed (${res.status}): ${text}`);
|
||
}
|
||
const json = (await res.json()) as { agent_name: string; thread_id: string };
|
||
return { agentName: json.agent_name, threadId: json.thread_id };
|
||
}
|