fix: 速度胶囊每轮只显示一次 + 技能调用按 run 合并统计

速度胶囊:原本每条 AI 消息都渲染,agentic 多轮 LLM 调用会出现多个胶囊。改为只在每轮 turn-final 消息(下一条 human 之前的最后一条 AI 消息)显示。

技能统计:tool_call_metrics 改为按 (run_id, skill_name) upsert 合并——同一 run 内同一技能的多次调用/重试合并为一行;任一成功即记成功,全部失败则记一行 error 并累积所有报错信息;非技能调用不受影响。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
吉鑫升andClaude Opus 4.7 committed 2026-05-19 17:22:17 +08:00
1 parent 4c38055d38
commit 028c5a34e6
5 files changed
+167 -17

No files matched your search

@@ -88,7 +88,13 @@ export function MessageTokenUsage({
// otherwise wait until usage arrives so the bar doesn't render on its own.
const usage = getUsageMetadata(message);
const showUsage = enabled && !isLoading && Boolean(usage);
const showSpeed = speedEnabled && resolvedMetric !== undefined && resolvedMetric.durationMs > 0;
// The speed pill is shown once per turn — only on the turn-final AI message
// — so a turn with several LLM calls no longer renders a pill per call.
const showSpeed =
speedEnabled &&
resolvedMetric !== undefined &&
resolvedMetric.durationMs > 0 &&
resolvedMetric.isTurnFinal;
if (!showUsage && !showSpeed) {
return null;
@@ -36,6 +36,13 @@ export interface MessageMetric {
* only to add a "~" prefix when the value is approximate.
*/
fromUsage: boolean;
/**
* True iff this is the turn-final AI message — the last AI message before
* the next human message. An agentic turn makes several LLM calls, each its
* own AI message; the speed pill is shown only on the turn-final one so a
* turn renders a single pill instead of one per call.
*/
isTurnFinal: boolean;
}
type Sample = { at: number; chars: number };
@@ -161,6 +168,25 @@ export function useMessageMetrics(
return () => window.clearInterval(interval);
}, [isLoading]);
// Identify the turn-final AI message of each turn — the last AI message
// (with a recorded metric) before the next human message. Only these show
// the speed pill, collapsing a multi-LLM-call turn to a single pill.
const turnFinalIds = new Set<string>();
let pendingTurnFinal: string | null = null;
for (let i = 0; i < messages.length; i++) {
const message = messages[i];
if (!message) continue;
if (message.type === "human") {
if (pendingTurnFinal) turnFinalIds.add(pendingTurnFinal);
pendingTurnFinal = null;
continue;
}
if (isAiMessage(message) && timingRef.current.has(getMessageId(message, i))) {
pendingTurnFinal = getMessageId(message, i);
}
}
if (pendingTurnFinal) turnFinalIds.add(pendingTurnFinal);
// Build the snapshot map. We compute on every render so consumers see the
// freshest duration; the underlying timing storage is the source of truth.
const out = new Map<string, MessageMetric>();
@@ -212,6 +238,7 @@ export function useMessageMetrics(
durationMs,
isActive,
fromUsage,
isTurnFinal: turnFinalIds.has(id),
});
}