- Waiting plans (approval / disk space / automation off / queued) are
active-segment rows on /download-tasks/board, merged by created_at,
counted, with approve / cancel actions; ?plan= deep links and legacy
/downloads/plans routes redirect to the panel
- Download detail shows "why this download" (source, purpose, replaced
version, reasons); raw evaluation in tech info
- Failed / cancelled / expired plans appear in the entity download
history; rejected / revoked / expired approvals close draft plans
- New GET /acquisition-plans/{id}/why, POST /acquisition-plans/{id}/cancel
- User-facing copy no longer says 下载计划; glossary updated
Co-Authored-By: Claude Opus 5.5 <[email protected]>
301 lines
13 KiB
TypeScript
301 lines
13 KiB
TypeScript
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<string, { title?: string }>)[id as KnownCapabilityId]?.title ?? id;
|
||
|
||
// ── 拒绝判定 ──
|
||
|
||
/**
|
||
* 授权校验拒绝:能力处理器前的授权回调失败时,事件是 tool_call_end(isError)+ 错误码 unauthorized/forbidden,
|
||
* 不是 tool_call_rejected(那个只在白名单/预算拦截时发)。
|
||
*/
|
||
export function authDenial(call: Pick<ToolCallView, "status" | "outputSummary">): 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<ToolCallView, "capabilityId" | "status" | "outputSummary">[]): 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<ToolCallView, "capabilityId">): 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<ToolCallView, "status" | "outputSummary">): 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<string, unknown>;
|
||
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<StreamItem, { kind: "compaction" }> }
|
||
| { kind: "end"; key: string; at: string; item: Extract<StreamItem, { kind: "end" }> };
|
||
|
||
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<ConvBlock, { kind: "turn" }> | null = null;
|
||
let seg: Extract<TurnPart, { kind: "segment" }> | 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;
|
||
}
|