feat(web): 智能体对话重做(AGUI-3)

- 会话流:用户消息 → 一行进度 → 结论正文 → 没做的事 → 一行实际结果。进度行把一段里的工具调用、思考和过程中的话
  聚成一行(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]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-29 22:41:34 +08:00
1 parent 2ab6d452dd
commit 6fcf82afb0
17 files changed
+2381 -1846

No files matched your search

@@ -0,0 +1,268 @@
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;
}