import { CAPABILITY_ACTIVITY, CAPABILITY_CATALOG, type KnownCapabilityId } from "@umt/contracts"; import type { ApprovalView, OptionsView, StreamItem, SubagentView, ToolCallView } from "./use-agent-stream.ts"; // 会话流的排版模型(AGUI-3,纯函数,单测覆盖): // 用户消息 → 一行进度(这一段里的工具调用、思考和过程中的话) → 结论正文 → 没做的事 → 一行实际结果。 // 方案卡 / 审批卡 / 上下文压缩把一回合切成几段,各段有自己的进度行和正文。 export const capabilityTitle = (id: string): string => (CAPABILITY_CATALOG as Record)[id as KnownCapabilityId]?.title ?? id; // ── 拒绝判定 ── /** * 授权校验拒绝:能力处理器前的授权回调失败时,事件是 tool_call_end(isError)+ 错误码 unauthorized/forbidden, * 不是 tool_call_rejected(那个只在白名单/预算拦截时发)。 */ export function authDenial(call: Pick): string | null { if (call.status !== "failed" || !call.outputSummary) return null; try { const e = (JSON.parse(call.outputSummary) as { error?: { code?: string; message?: string } }).error; if (e && (e.code === "unauthorized" || e.code === "forbidden")) return e.message ?? e.code; } catch { const m = /"code":"(unauthorized|forbidden)"(?:,"message":"([^"]*)")?/.exec(call.outputSummary); if (m) return m[2] ?? m[1] ?? "未授权"; } return null; } /** 被服务端拒绝(白名单拦截或授权校验失败) */ export const isDenied = (call: ToolCallView) => call.status === "denied" || authDenial(call) !== null; /** 拒绝的一行说明:需要批准 / 这次会话没开放 / 次数用完 */ export function deniedReason(call: ToolCallView): { text: string; approvable: boolean } { const auth = authDenial(call); if (auth) { if (/先看方案/.test(auth)) return { text: "要先选定方案", approvable: false }; if (/自动化总开关|只读对账/.test(auth)) return { text: "自动执行已关闭", approvable: false }; return { text: "需要你批准", approvable: true }; } if (call.reason && /预算|次数/.test(call.reason)) return { text: "工具调用次数已用完", approvable: false }; return { text: "这次会话没开放这个能力", approvable: false }; } // ── 进度行的人话计数 ── type Group = { verb: string; bare: boolean; nouns: { noun: string; unit: string | null; n: number }[] }; function activityOf(id: string) { return ( CAPABILITY_ACTIVITY[id as KnownCapabilityId] ?? { verb: "执行", noun: capabilityTitle(id) === id ? "其他操作" : capabilityTitle(id), unit: "次", } ); } /** 中文与数字之间空一格;顿号旁不空 */ const cjkJoin = (a: string, b: string) => (/^\d/.test(b) ? `${a} ${b}` : `${a}${b}`); const joinNouns = (xs: string[]) => xs.length <= 1 ? (xs[0] ?? "") : cjkJoin(`${xs.slice(0, -1).join("、")}和`, xs.at(-1) ?? ""); /** agent.spawn 的结果是"已有同目标子会话"(输出 reused = true;摘要可能被截断,按字段文本判定) */ export const reusedSpawn = (outputSummary: string | null | undefined): boolean => !!outputSummary && /"reused"\s*:\s*true/.test(outputSummary); /** * 「查了 1 个站点、2 个下载任务和媒体库,搜了 3 次资源」:按能力元数据的 verb / noun 计数, * 同一动作的对象合并,动作按第一次出现排序;被拒绝、失败和隐藏的调用不计入(失败的在进度行另计「N 项失败」, * 否则 3 次被预算拒绝的派发 + 2 次成功会写成「派出了 5 个子会话」)。超过 4 种动作只写前 3 种。 */ export function activitySummary(calls: Pick[]): string { const groups: Group[] = []; for (const c of calls) { const a = activityOf(c.capabilityId); if (a.hidden) continue; if (c.status === "denied" || c.status === "failed" || authDenial(c)) continue; // 同目标去重返回已有子会话(AG-4):没有新派出,不计「派出了 N 个子会话」 if (c.capabilityId === "agent.spawn" && reusedSpawn(c.outputSummary)) continue; let g = groups.find((x) => x.verb === a.verb); if (!g) { g = { verb: a.verb, bare: !!a.bare, nouns: [] }; groups.push(g); } const n = g.nouns.find((x) => x.noun === a.noun); if (n) n.n++; else g.nouns.push({ noun: a.noun, unit: a.unit, n: 1 }); } const text = (g: Group) => cjkJoin( g.bare ? g.verb : `${g.verb}了`, joinNouns(g.nouns.map((x) => (x.unit ? `${x.n} ${x.unit}${x.noun}` : x.noun))), ); if (groups.length > 4) return `${groups.slice(0, 3).map(text).join(",")}等 ${groups.length} 类操作`; return groups.map(text).join(","); } // ── 步骤进度 ── type StepLike = { status: "pending" | "running" | "done" | "skipped" | "failed"; title: string }; /** * 步骤进度(概况条与展开的步骤清单共用一个口径):分子只数完成的步骤,跳过的单独计。 * 「4/6 · 跳过 2」;全部结束且有跳过时状态写「完成 4 · 跳过 2」,不再写「全部完成」。 * pct 按已结束(完成 + 跳过 + 失败)算进度条宽度。 */ export function stepProgress(steps: readonly StepLike[]) { const done = steps.filter((s) => s.status === "done").length; const skipped = steps.filter((s) => s.status === "skipped").length; const failedStep = steps.find((s) => s.status === "failed") ?? null; const failed = steps.filter((s) => s.status === "failed").length; const total = steps.length; const current = steps.find((s) => s.status === "running") ?? steps.find((s) => s.status === "pending") ?? null; const count = `${done}/${total}${skipped ? ` · 跳过 ${skipped}` : ""}`; const status = failedStep ? `失败:${failedStep.title}` : current ? `当前:${current.title}` : skipped ? `完成 ${done} · 跳过 ${skipped}` : "全部完成"; const pct = total ? Math.round(((done + skipped + failed) / total) * 100) : 0; return { done, skipped, failed, total, current, failedStep, count, status, pct }; } /** 正在进行的那一步:「正在检测站点」 */ export function runningText(call: Pick): string { const a = activityOf(call.capabilityId); return `正在${a.verb}${a.noun}`; } /** 毫秒 → 「41 秒」「2 分 5 秒」 */ export function durationLabel(ms: number): string { const s = Math.max(0, Math.round(ms / 1000)); if (s < 60) return `${s} 秒`; const m = Math.floor(s / 60); return s % 60 ? `${m} 分 ${s % 60} 秒` : `${m} 分钟`; } /** * 工具结果的一句话摘要(展开后每步一行的「→ 3 条结果」):错误取 message;数组 / items 取条数; * 对象取 summary / message;其余不写(原文在技术信息里)。 */ export function toolSummary(call: Pick): string | null { const raw = call.outputSummary?.trim(); if (!raw) return call.status === "running" ? "等待结果…" : null; const clip = (t: string) => (t.length > 80 ? `${t.slice(0, 80)}…` : t); let v: unknown; try { v = JSON.parse(raw); } catch { // 截断的 JSON 不上屏;普通文本截一句 return /^[[{]/.test(raw) ? null : clip(raw.replace(/\s+/g, " ")); } if (Array.isArray(v)) return `${v.length} 条`; if (v && typeof v === "object") { const o = v as Record; const err = o.error as { message?: unknown; code?: unknown } | undefined; if (err && typeof err === "object") return clip(String(err.message ?? err.code ?? "失败")); for (const k of ["summary", "message"]) if (typeof o[k] === "string") return clip(o[k] as string); for (const k of ["items", "results", "releases", "candidates", "sites", "tasks"]) if (Array.isArray(o[k])) return `${(o[k] as unknown[]).length} 条`; return null; } return clip(String(v)); } // ── 分段 ── /** 授权器已在步骤处发起审批 / 正在等审批(interaction.ts inlineApproval 的原因文字) */ const WAITING_APPROVAL = /已在步骤处发起审批|等待管理员审批/; export type ActivityItem = | { kind: "tool"; key: string; call: ToolCallView } | { kind: "thinking"; key: string; text: string; at: string } | { kind: "note"; key: string; text: string; at: string }; export type BodyText = { key: string; text: string; at: string }; export type TurnPart = | { kind: "segment"; key: string; activity: ActivityItem[]; body: BodyText[]; subagents: SubagentView[] } | { kind: "options"; key: string; at: string; view: OptionsView } | { kind: "approval"; key: string; at: string; view: ApprovalView } | { kind: "compaction"; key: string; at: string; item: Extract } | { kind: "end"; key: string; at: string; item: Extract }; export type ConvBlock = | { kind: "user"; key: string; at: string; text: string; pending: boolean } | { kind: "turn"; key: string; at: string; parts: TurnPart[]; denied: ToolCallView[] }; const itemAt = (it: StreamItem) => (it.kind === "tool" ? it.call.startedAt : it.at); /** * 纯函数:事件展示项 + 追加的用户消息 → 用户消息与智能体回合。 * 回合内:进度行收工具调用、思考和工具之间的话;最后一个工具之后的文字是结论正文。 * 被拒绝的调用不单独出卡,收进回合末尾的「没做」;已转成审批卡的(同能力的审批项在后面)不算。 */ export function buildConversation( items: StreamItem[], userMessages: { at: string; text: string; pending?: boolean }[] = [], ): ConvBlock[] { type Entry = | { at: string; order: number; item: StreamItem } | { at: string; order: number; user: (typeof userMessages)[number] }; const entries: Entry[] = [ ...items.map((item, order) => ({ at: itemAt(item), order, item })), ...userMessages.map((user, i) => ({ at: user.at, order: items.length + i, user })), ]; // 同一时刻:事件保持原顺序,用户消息排在后面 entries.sort((a, b) => a.at.localeCompare(b.at) || a.order - b.order); const out: ConvBlock[] = []; let turn: Extract | null = null; let seg: Extract | null = null; let texts: BodyText[] = []; const closeSegment = () => { if (!turn) return; if (seg || texts.length) { const s = seg ?? { kind: "segment" as const, key: `s${texts[0]?.key}`, activity: [], body: [], subagents: [] }; s.body = texts; turn.parts.push(s); } seg = null; texts = []; }; const openTurn = (at: string, key: string) => { if (!turn) { turn = { kind: "turn", key: `t${key}`, at, parts: [], denied: [] }; out.push(turn); } return turn; }; const segment = (key: string) => { if (!seg) seg = { kind: "segment", key: `s${key}`, activity: [], body: [], subagents: [] }; return seg; }; for (const e of entries) { if ("user" in e) { closeSegment(); turn = null; out.push({ kind: "user", key: `u${e.at}`, at: e.at, text: e.user.text, pending: !!e.user.pending }); continue; } const it = e.item; const t = openTurn(e.at, it.key); switch (it.kind) { case "text": texts.push({ key: it.key, text: it.text, at: it.at }); break; case "thinking": case "tool": { const s = segment(it.key); // 工具之间的话算过程,收进进度行 for (const x of texts) s.activity.push({ kind: "note", key: x.key, text: x.text, at: x.at }); texts = []; s.activity.push( it.kind === "tool" ? { kind: "tool", key: it.key, call: it.call } : { kind: "thinking", key: it.key, text: it.text, at: it.at }, ); if (it.kind === "tool" && isDenied(it.call)) t.denied.push(it.call); break; } case "options": closeSegment(); t.parts.push({ kind: "options", key: it.key, at: it.at, view: it.view }); break; case "approval": closeSegment(); t.parts.push({ kind: "approval", key: it.key, at: it.at, view: it.view }); // 这次拒绝已经转成审批卡:不再算"没做" t.denied = t.denied.filter((c) => c.capabilityId !== it.view.capabilityId); break; case "subagent": { // 子会话卡片跟在派出它的那段进度行下面(在结论正文之前);不切开进度行, // 一次派两个就是一行「派出了 2 个子会话」+ 两张卡片 const s = segment(it.key); for (const x of texts) s.activity.push({ kind: "note", key: x.key, text: x.text, at: x.at }); texts = []; s.subagents.push(it.view); break; } case "compaction": closeSegment(); t.parts.push({ kind: "compaction", key: it.key, at: it.at, item: it }); break; case "end": closeSegment(); t.parts.push({ kind: "end", key: it.key, at: it.at, item: it }); break; } } closeSegment(); for (const b of out) if (b.kind === "turn") // 等待审批的那次拒绝(授权器已在步骤处发起审批)不算没做;恢复后重放成功的同一调用也不算 b.denied = b.denied.filter((c) => isDenied(c) && !WAITING_APPROVAL.test(authDenial(c) ?? "")); return out; } /** * 一段进度的时长(毫秒):从这一轮开始(since,如会话开始 / 用户消息的时间)或首个步骤, * 到最后一个步骤结束或结论正文开始(until)。只算工具起止会漏掉模型思考的时间(整轮 7 秒显示成 0 秒)。 * 没有任何步骤为 null。 */ export function activitySpan( activity: ActivityItem[], bounds: { since?: string | null; until?: string | null } = {}, ): number | null { if (!activity.length) return null; const starts = activity.map((a) => Date.parse(a.kind === "tool" ? a.call.startedAt : a.at)); const ends = activity.map((a) => Date.parse(a.kind === "tool" ? (a.call.endedAt ?? a.call.startedAt) : a.at)); const since = bounds.since ? Date.parse(bounds.since) : Number.NaN; const until = bounds.until ? Date.parse(bounds.until) : Number.NaN; const start = Math.min(...starts.filter(Number.isFinite), ...(Number.isFinite(since) ? [since] : [])); const end = Math.max(...ends.filter(Number.isFinite), ...(Number.isFinite(until) ? [until] : [])); return Number.isFinite(start) && Number.isFinite(end) && end >= start ? end - start : null; } /** 长正文:超过 ~12 行或 600 字按长文处理(收起时限高) */ export function isLongReport(text: string): boolean { return text.length > 600 || text.split("\n").length > 12; } // ── 调用计数与会话统计(AG-4) ── /** MCP 工具:外部 MCP 服务的工具名(`mcp:` / `mcp__` 前缀或 `域__动作`);内置能力不算 */ export const isMcpTool = (id: string): boolean => !(id in CAPABILITY_CATALOG) && (/^mcp(?::|__|\.)/i.test(id) || id.includes("__")); /** 已发起的调用数(被拒绝的没执行,不算;运行中的算) */ export function callCounts(calls: Pick[]): { tools: number; mcp: number; } { let tools = 0; let mcp = 0; for (const c of calls) { if (c.status === "denied" || authDenial(c)) continue; if (isMcpTool(c.capabilityId)) mcp++; else tools++; } return { tools, mcp }; } /** 「已调用 3 次工具、1 次 MCP」(结束后的进度行用「调用」);都为 0 时空串 */ export function callCountText(n: { tools: number; mcp: number }, verb = "已调用"): string { const parts = [n.tools ? `${n.tools} 次工具` : "", n.mcp ? `${n.mcp} 次 MCP` : ""].filter(Boolean); return parts.length ? `${verb} ${parts.join("、")}` : ""; } /** * 会话统计一行:「工具调用 12 次 · token 45K · 剩 155K(77%) · 费用 $0.03」。 * MCP 只在有调用时写;没设 token 上限时不写剩余。数字用 K / M 简写(formatTokens 由调用方传入,保持纯函数)。 */ export function sessionStatsParts( s: { toolCalls: number; mcpCalls: number; totalTokens: number | null; maxTokens: number | null; costUsd: number | null; }, fmt: { tokens(n: number): string; usd(n: number): string }, ): string[] { const out = [`工具调用 ${s.toolCalls} 次`]; if (s.mcpCalls) out.push(`MCP ${s.mcpCalls} 次`); const used = s.totalTokens ?? 0; out.push(`token ${fmt.tokens(used)}`); if (s.maxTokens && s.maxTokens > 0) { const left = Math.max(0, s.maxTokens - used); out.push(`剩 ${fmt.tokens(left)}(${Math.round((left / s.maxTokens) * 100)}%)`); } out.push(`费用 ${fmt.usd(s.costUsd ?? 0)}`); return out; } /** 最后一段结论正文(汇报原文);没有时 null */ export function lastReportText(items: readonly StreamItem[]): string | null { for (let i = items.length - 1; i >= 0; i--) { const it = items[i]; if (it?.kind === "text" && it.text.trim()) return it.text; } return null; } // ── 子会话卡片(纯函数) ── export type SubagentState = "running" | "waiting" | "done" | "failed" | "stopped"; /** * 子会话卡片的状态:镜像里收到 subagent_completed 就按它的结果;还没收到时用快照里的子会话状态 * (等待、已停止更新得更及时);都没有按运行中。 */ export function subagentState(view: Pick, nodeStatus?: string | null): SubagentState { switch (view.status ?? nodeStatus) { case "succeeded": return "done"; case "failed": case "budget_exceeded": return "failed"; case "cancelled": return "stopped"; case "waiting": return "waiting"; default: return "running"; } } /** 子会话已运行多久(毫秒):结束的取 durationMs 或起止时间,运行中的到 now */ export function subagentElapsed( view: Pick, now: number, finishedAt?: string | null, ): number { if (view.durationMs !== null) return view.durationMs; const start = Date.parse(view.startedAt); const end = view.endedAt ? Date.parse(view.endedAt) : finishedAt ? Date.parse(finishedAt) : now; return Number.isFinite(start) && Number.isFinite(end) ? Math.max(0, end - start) : 0; } /** * 卡片上的一句结果:服务端摘要是「核查结论\n 模型汇报(未经核查):…」,有模型汇报时取它的第一句, * 否则取摘要第一行。 */ export function subagentResultLine(summary: string | null | undefined): string | null { const t = summary?.trim(); if (!t) return null; const model = /模型汇报(未经核查):([\s\S]*)$/.exec(t)?.[1]?.trim(); const src = model || t; return ( src .split("\n") .find((l) => l.trim()) ?.trim() ?? null ); } /** 会话里的全部子会话卡片(只看直接派出的;按出现顺序) */ export function subagentViews(items: readonly StreamItem[]): SubagentView[] { return items.flatMap((i) => (i.kind === "subagent" ? [i.view] : [])); }