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

@@ -54,10 +54,13 @@ import {
activitySummary,
authDenial,
buildConversation,
callCounts,
callCountText,
capabilityTitle,
deniedReason,
durationLabel,
isDenied,
lastReportText,
runningText,
toolSummary,
} from "./conversation-model.ts";
@@ -271,6 +274,7 @@ export function StepSummary({
const denied = calls.filter(isDenied).length;
const failed = calls.filter((c) => c.status === "failed" && !isDenied(c)).length;
const span = activitySpan(activity);
const counted = running ? "" : callCountText(callCounts(calls), "调用");
const thinking = activity.some((a) => a.kind === "thinking");
const head = current
? `${done ? `${done},` : ""}${runningText(current)}…`
@@ -292,6 +296,7 @@ export function StepSummary({
<span className="min-w-0 truncate">{head}</span>
{denied ? <span className="shrink-0">· {denied} 项没做</span> : null}
{failed ? <span className="shrink-0 text-destructive">· {failed} 项失败</span> : null}
{counted ? <span className="shrink-0 tabular-nums">· {counted}</span> : null}
{span !== null && !running ? <span className="shrink-0 tabular-nums">· {durationLabel(span)}</span> : null}
<ChevronRight aria-hidden className={cn("size-3.5 shrink-0", CHEVRON_CLASS, open && "rotate-90")} />
</button>
@@ -473,15 +478,32 @@ export function AgentConversation({
// 等选方案 / 等审批时卡片本身就是提示,不再显示"等待中…"
const lastPart = lastBlock?.kind === "turn" ? lastBlock.parts.at(-1) : undefined;
const cardWaiting = run.status === "waiting" && (lastPart?.kind === "options" || lastPart?.kind === "approval");
const showTyping = active && tail?.kind !== "text" && tail?.kind !== "thinking" && !toolRunning && !cardWaiting;
// 本轮的实时状态(AG-4):「思考中 · 已调用 3 次工具、1 次 MCP」;本轮结束后计数并入各段进度行
const turnCalls =
lastBlock?.kind === "turn"
? lastBlock.parts.flatMap((p) =>
p.kind === "segment" ? p.activity.flatMap((a) => (a.kind === "tool" ? [a.call] : [])) : [],
)
: [];
const turnCount = callCountText(callCounts(turnCalls));
const showTyping = active && !cardWaiting;
const typingState =
run.status === "waiting"
? `${run.summary === "等待选择方案" || run.summary === "等待审批" ? run.summary : "等待中"}`
: run.status === "queued"
? "排队中"
: toolRunning
? "调用工具中"
: tail?.kind === "text"
? "回答中"
: "思考中";
const typing = showTyping ? (
<span className="inline-flex items-center gap-2 text-meta text-muted-foreground">
<MessageTyping label={run.status === "waiting" ? "等待中" : "正在处理"} />
{run.status === "waiting"
? `${run.summary === "等待选择方案" || run.summary === "等待审批" ? run.summary : "等待中"}…`
: run.status === "queued"
? "排队中…"
: "正在处理…"}
<span className="inline-flex items-center gap-2 text-meta text-muted-foreground" data-slot="turn-status">
<MessageTyping label={typingState} />
<span className="tabular-nums">
{typingState}
{turnCount ? ` · ${turnCount}` : "…"}
</span>
</span>
) : null;
@@ -521,7 +543,6 @@ export function AgentConversation({
{/* 流式中按原文显示(半截 Markdown 会闪),结束后按安全子集渲染 */}
{human ? <Markdown text={human.text} /> : t.text}
</StreamingResponse>
{human?.replaced.length ? <ReplacedIds replaced={human.replaced} /> : null}
</div>
);
})}
@@ -565,32 +586,6 @@ export function AgentConversation({
);
}
/** 汇报里被换掉的原文(ID、能力、原因码),收在技术信息里 */
function ReplacedIds({ replaced }: { replaced: { from: string; to: string }[] }) {
const uniq = [...new Map(replaced.map((r) => [r.from, r] as const)).values()];
return (
<TechInfo
title="正文原文"
className="mt-1"
items={[
{
label: "换掉的标识",
value: (
<ul className="space-y-0.5">
{uniq.map((r) => (
<li key={r.from}>
{r.from} → {r.to || "(省略)"}
</li>
))}
</ul>
),
wide: true,
},
]}
/>
);
}
// ── 实际结果 ──
/** 实际结果的一句话(服务端从下载计划 / 下载任务 / 整理记录汇总,不采信模型文本) */
@@ -655,6 +650,8 @@ export function ResultLine({
]
: [];
const bad = report?.state === "failed" || report?.state === "partial";
const last = lastReportText(live.items);
const rawReport = last && humanizeReport(last).replaced.length ? last : null;
const err = run.error ? humanizeReason(String((run.error as { message?: unknown }).message ?? "")) : null;
return (
<section aria-label="实际结果" className="flex w-full min-w-0 flex-col gap-1 border-t border-border/60 pt-2">
@@ -701,6 +698,8 @@ export function ResultLine({
{ label: "费用上限", value: run.budget.maxCostUsd ? formatUsd(run.budget.maxCostUsd) : null },
{ label: "时长上限", value: run.budget.maxDurationSec ? `${run.budget.maxDurationSec} 秒` : null },
{ label: "汇总", value: report?.summary ?? null, wide: true, mono: false },
// 正文里的 ID / 能力名 / 原因码已换成人话;排查用的汇报原文只放这里
{ label: "原文", value: rawReport, wide: true, mono: false },
{ label: "会话 ID", value: run.id, wide: true },
{ label: "原始错误", value: run.error ? JSON.stringify(run.error) : null, wide: true },
]}