import { CAPABILITY_ACTIVITY, CAPABILITY_CATALOG, type KnownCapabilityId } from "@umt/contracts"; import type { ApprovalView, OptionsView, StreamItem, 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) ?? ""); /** * 「查了 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; 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[] } | { 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: [] }; 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: [] }; 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 "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; } /** 一段进度的起止时间(毫秒);没有工具调用为 null */ export function activitySpan(activity: ActivityItem[]): number | null { const calls = activity.flatMap((a) => (a.kind === "tool" ? [a.call] : [])); if (!calls.length) return null; const start = Math.min(...calls.map((c) => Date.parse(c.startedAt))); const end = Math.max(...calls.map((c) => Date.parse(c.endedAt ?? c.startedAt))); return Number.isFinite(start) && Number.isFinite(end) ? end - start : null; } /** 长正文:超过 ~12 行或 600 字按长文处理(收起时限高) */ export function isLongReport(text: string): boolean { return text.length > 600 || text.split("\n").length > 12; }