feat(web): session stats line, live turn status, drop replaced-ID list (AG-4)

- Remove the "换掉的标识" mapping list under humanized reports; the raw
  report text is kept only as "原文" in the result line's 技术信息 (no mapping).
- Session stats line above the overview bar, always shown: tool calls, MCP
  calls (only when any), tokens used and left to the budget (K/M + percent),
  cost; live while running, fixed after the run ends.
- Live turn status: "思考中 / 调用工具中 / 回答中 · 已调用 N 次工具、M 次 MCP";
  after the turn the count merges into each progress line ("调用 N 次工具").
- agent.spawn calls answered with an existing same-goal child (reused=true)
  are not counted in "派出了 N 个子会话".

Screenshots (desktop + mobile): apps/web/test/screenshots/ag4/ (gitignored).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-30 17:58:15 +08:00
1 parent 793b87e3b6
commit 919dc02200
5 files changed
+263 -39

No files matched your search

@@ -60,6 +60,10 @@ const cjkJoin = (a: string, b: string) => (/^\d/.test(b) ? `${a} ${b}` : `${a}${
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 项失败」,
@@ -71,6 +75,8 @@ export function activitySummary(calls: Pick<ToolCallView, "capabilityId" | "stat
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: [] };
@@ -298,3 +304,65 @@ export function activitySpan(activity: ActivityItem[]): number | null {
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;
}