Files
rootandClaude Opus 5.5 3692793dff feat(agent): 抽屉与会话页统一成一个对话界面,子会话在父会话里实时可见
- 抽屉和会话页渲染同一套 GoalComposer / AgentRunPanel;抽屉标题即最近会话切换,
  头部只有新对话、在页面打开、关闭;去掉右下角悬浮按钮和最小化小窗
- 会话列表合一:对话与巡检混排带类型小标签,搜索 + 一个状态下拉,只列顶层会话
  (GET /agent/runs?roots=true,新增可选参数)
- 子会话卡片:由镜像的 subagent_* 事件折成,显示任务、状态、当前步骤、用时、调用次数、费用,
  可展开看过程、可打开;顶部「N 个子会话运行中」跳到卡片;subagent_completed 加可选 durationMs
- 回答去内部字段:汇报规则禁止照抄键值与字段表;展示层 stripInternalFields 把字段行 / 字段表移进技术信息
- 开场建议按页面给 2 条完整问法、点了直接发;指令只留 /先给方案,/重跑 改成「重新运行」按钮

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-08 13:56:38 +08:00

446 lines
19 KiB
TypeScript
Raw Permalink 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, SubagentView, 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) ?? "");
/** agent.spawn 的结果是"已有同目标子会话"(输出 reused = true;摘要可能被截断,按字段文本判定) */
export const reusedSpawn = (outputSummary: string | null | undefined): boolean =>
!!outputSummary && /"reused"\s*:\s*true/.test(outputSummary);
/**
* 「查了 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;
// 同目标去重返回已有子会话(AG-4):没有新派出,不计「派出了 N 个子会话」
if (c.capabilityId === "agent.spawn" && reusedSpawn(c.outputSummary)) 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[]; subagents: SubagentView[] }
| { 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: [], subagents: [] };
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: [], subagents: [] };
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 "subagent": {
// 子会话卡片跟在派出它的那段进度行下面(在结论正文之前);不切开进度行,
// 一次派两个就是一行「派出了 2 个子会话」+ 两张卡片
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.subagents.push(it.view);
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;
}
/**
* 一段进度的时长(毫秒):从这一轮开始(since,如会话开始 / 用户消息的时间)或首个步骤,
* 到最后一个步骤结束或结论正文开始(until)。只算工具起止会漏掉模型思考的时间(整轮 7 秒显示成 0 秒)。
* 没有任何步骤为 null。
*/
export function activitySpan(
activity: ActivityItem[],
bounds: { since?: string | null; until?: string | null } = {},
): number | null {
if (!activity.length) return null;
const starts = activity.map((a) => Date.parse(a.kind === "tool" ? a.call.startedAt : a.at));
const ends = activity.map((a) => Date.parse(a.kind === "tool" ? (a.call.endedAt ?? a.call.startedAt) : a.at));
const since = bounds.since ? Date.parse(bounds.since) : Number.NaN;
const until = bounds.until ? Date.parse(bounds.until) : Number.NaN;
const start = Math.min(...starts.filter(Number.isFinite), ...(Number.isFinite(since) ? [since] : []));
const end = Math.max(...ends.filter(Number.isFinite), ...(Number.isFinite(until) ? [until] : []));
return Number.isFinite(start) && Number.isFinite(end) && end >= start ? end - start : null;
}
/** 长正文:超过 ~12 行或 600 字按长文处理(收起时限高) */
export function isLongReport(text: string): boolean {
return text.length > 600 || text.split("\n").length > 12;
}
// ── 调用计数与会话统计(AG-4) ──
/** MCP 工具:外部 MCP 服务的工具名(`mcp:` / `mcp__` 前缀或 `域__动作`);内置能力不算 */
export const isMcpTool = (id: string): boolean =>
!(id in CAPABILITY_CATALOG) && (/^mcp(?::|__|\.)/i.test(id) || id.includes("__"));
/** 已发起的调用数(被拒绝的没执行,不算;运行中的算) */
export function callCounts(calls: Pick<ToolCallView, "capabilityId" | "status" | "outputSummary">[]): {
tools: number;
mcp: number;
} {
let tools = 0;
let mcp = 0;
for (const c of calls) {
if (c.status === "denied" || authDenial(c)) continue;
if (isMcpTool(c.capabilityId)) mcp++;
else tools++;
}
return { tools, mcp };
}
/** 「已调用 3 次工具、1 次 MCP」(结束后的进度行用「调用」);都为 0 时空串 */
export function callCountText(n: { tools: number; mcp: number }, verb = "已调用"): string {
const parts = [n.tools ? `${n.tools} 次工具` : "", n.mcp ? `${n.mcp} 次 MCP` : ""].filter(Boolean);
return parts.length ? `${verb} ${parts.join("、")}` : "";
}
/**
* 会话统计一行:「工具调用 12 次 · token 45K · 剩 155K(77%) · 费用 $0.03」。
* MCP 只在有调用时写;没设 token 上限时不写剩余。数字用 K / M 简写(formatTokens 由调用方传入,保持纯函数)。
*/
export function sessionStatsParts(
s: {
toolCalls: number;
mcpCalls: number;
totalTokens: number | null;
maxTokens: number | null;
costUsd: number | null;
},
fmt: { tokens(n: number): string; usd(n: number): string },
): string[] {
const out = [`工具调用 ${s.toolCalls} 次`];
if (s.mcpCalls) out.push(`MCP ${s.mcpCalls} 次`);
const used = s.totalTokens ?? 0;
out.push(`token ${fmt.tokens(used)}`);
if (s.maxTokens && s.maxTokens > 0) {
const left = Math.max(0, s.maxTokens - used);
out.push(`剩 ${fmt.tokens(left)}(${Math.round((left / s.maxTokens) * 100)}%)`);
}
out.push(`费用 ${fmt.usd(s.costUsd ?? 0)}`);
return out;
}
/** 最后一段结论正文(汇报原文);没有时 null */
export function lastReportText(items: readonly StreamItem[]): string | null {
for (let i = items.length - 1; i >= 0; i--) {
const it = items[i];
if (it?.kind === "text" && it.text.trim()) return it.text;
}
return null;
}
// ── 子会话卡片(纯函数) ──
export type SubagentState = "running" | "waiting" | "done" | "failed" | "stopped";
/**
* 子会话卡片的状态:镜像里收到 subagent_completed 就按它的结果;还没收到时用快照里的子会话状态
* (等待、已停止更新得更及时);都没有按运行中。
*/
export function subagentState(view: Pick<SubagentView, "status">, nodeStatus?: string | null): SubagentState {
switch (view.status ?? nodeStatus) {
case "succeeded":
return "done";
case "failed":
case "budget_exceeded":
return "failed";
case "cancelled":
return "stopped";
case "waiting":
return "waiting";
default:
return "running";
}
}
/** 子会话已运行多久(毫秒):结束的取 durationMs 或起止时间,运行中的到 now */
export function subagentElapsed(
view: Pick<SubagentView, "startedAt" | "endedAt" | "durationMs">,
now: number,
finishedAt?: string | null,
): number {
if (view.durationMs !== null) return view.durationMs;
const start = Date.parse(view.startedAt);
const end = view.endedAt ? Date.parse(view.endedAt) : finishedAt ? Date.parse(finishedAt) : now;
return Number.isFinite(start) && Number.isFinite(end) ? Math.max(0, end - start) : 0;
}
/**
* 卡片上的一句结果:服务端摘要是「核查结论\n 模型汇报(未经核查):…」,有模型汇报时取它的第一句,
* 否则取摘要第一行。
*/
export function subagentResultLine(summary: string | null | undefined): string | null {
const t = summary?.trim();
if (!t) return null;
const model = /模型汇报(未经核查):([\s\S]*)$/.exec(t)?.[1]?.trim();
const src = model || t;
return (
src
.split("\n")
.find((l) => l.trim())
?.trim() ?? null
);
}
/** 会话里的全部子会话卡片(只看直接派出的;按出现顺序) */
export function subagentViews(items: readonly StreamItem[]): SubagentView[] {
return items.flatMap((i) => (i.kind === "subagent" ? [i.view] : []));
}