Files
UHD_Media_Tool/apps/web/app/components/agent/conversation-model.ts
T
rootandClaude Opus 5.5 a9147e647d feat(downloads): fold download plans into download panel (DL-3)
- 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]>
2026-09-30 01:15:25 +08:00

301 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}