- 会话流:用户消息 → 一行进度 → 结论正文 → 没做的事 → 一行实际结果。进度行把一段里的工具调用、思考和过程中的话 聚成一行(1 次也折叠),按能力 verb/noun 人话计数(「查了站点、2 个下载任务和媒体库 · 1 项没做 · 3 秒」), 展开每步一行;工具名、调用 ID、幂等键、输入输出进技术信息。去掉回合头、「模型说的」重复卡和系统核实大卡。 - agent.plan / agent.propose_options 不作为工具调用出现(旧事件回放同样跳过);被拒调用不出红卡, 回合末尾一行「没做:检测站点,需要你批准 [批准并重试]」(非计划类能力建一次性持续授权后重试)。 - 方案卡:每个方案一个按钮,点一下就选定;推荐项第一位、主按钮;按钮写服务端给的动作要点;风险折叠; 「都不要…」点开才出原因框;选完收成一行。审批卡只留摘要和批准 / 拒绝,计划 ID、hash、版本、到期进技术信息。 - 会话页去掉右侧栏和页头目标,步骤 / 子会话 / 模型 / 预算 / ID 进顶部概况条;输入区只有输入框和发送 / 停止, / 指令 /先给方案、/重跑(「先看方案再执行」开关原本只是新建时的本地状态,不需要迁移)。 - 抽屉去掉「当前 / 历史」Tab:头部「历史」弹层(最近 20 条)+「新对话」;上下文显示成名称标签,可去掉。 - 会话列表:搜索 + 全部 / 等你处理 / 失败;等你处理置顶,子会话收在父会话下,正常结束不加徽章。 - 入口页与空态给 3 个快捷目标。mock 加审查报告 3 个示例的演示数据;smoke 改测历史弹层。 - 组件测试:聚合、隐藏、拒绝一行、方案一键选定、历史置顶与子会话、指令。 Co-Authored-By: Claude Opus 5.5 <[email protected]>
269 lines
11 KiB
TypeScript
269 lines
11 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; 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 计数,
|
||
* 同一动作的对象合并,动作按第一次出现排序;被拒绝和隐藏的调用不计入。超过 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" || authDenial(c)) continue;
|
||
let g = groups.find((x) => x.verb === a.verb);
|
||
if (!g) {
|
||
g = { verb: a.verb, 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.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(",");
|
||
}
|
||
|
||
/** 正在进行的那一步:「正在检测站点」 */
|
||
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;
|
||
}
|