工具调用折叠块补齐耗时与时间
- 新增 toolCallGroupPresentation.ts 的耗时 / 时间纯函数:toolCallDurationMs、formatToolCallDuration(0.4s / 12.3s / 2m 5s)、turnToolCallDurationMs、formatTurnDuration(42秒 / 4分钟 / 5分钟 45秒)、formatClockTime、turnToolCallTimeLabel - ToolCallGroup:块头右侧显示总用时(min(startedAt) → max(updatedAt))与该回合结束时间(拿得到同回合用户消息时间时显示 发送 → 结束) - ToolCallGroup:展开态每行右侧显示该工具自己的耗时;startedAt 为 0 或时间倒序时不显示耗时(不出现 0s / 负数),running 行显示 执行中 - ToolCallGroup:块与行都带 data-duration-ms(无法计算时为空串),块尾补一行 结束于 HH:mm - ProjectSupervisorView:按回合把用户消息的 updatedAt 传给块,用于 发送 → 结束 时间显示 - 用例:耗时格式化边界(0 / <1s / 整秒 / ≥60s / 时间倒序)、总用时、每行耗时、无时间戳不渲染耗时 - 技术方案文档补耗时 / 时间 / data-* 契约
This commit is contained in:
@@ -70,6 +70,16 @@ function directCodexTurnMessageId(turnId: string, role: 'user' | 'assistant') {
|
||||
return `direct-codex:${turnId}:${role}`;
|
||||
}
|
||||
|
||||
/** 从 `direct-codex:<turnId>:assistant` 反解回合 id;不是这个形状返回 `null`。 */
|
||||
function directCodexTurnIdFromAssistantMessageId(messageId: string) {
|
||||
const prefix = 'direct-codex:';
|
||||
const suffix = ':assistant';
|
||||
if (!messageId.startsWith(prefix) || !messageId.endsWith(suffix)) {
|
||||
return null;
|
||||
}
|
||||
return messageId.slice(prefix.length, -suffix.length);
|
||||
}
|
||||
|
||||
type RuntimePanelProps = ComponentProps<typeof ProjectSupervisorRuntimePanel>;
|
||||
|
||||
function directStatusTitle(status: string | null | undefined) {
|
||||
@@ -233,6 +243,7 @@ export function ProjectSupervisorView({
|
||||
const streamTurnId = activeTurnId?.trim() ?? '';
|
||||
const toolCallsByAnchor = new Map<string, GameCreatorDirectToolCall[]>();
|
||||
const liveToolCalls: GameCreatorDirectToolCall[] = [];
|
||||
let liveToolCallTurnId = '';
|
||||
if (directCodex) {
|
||||
for (const call of toolCalls) {
|
||||
const expected = directCodexTurnMessageId(call.turnId, 'assistant');
|
||||
@@ -249,10 +260,24 @@ export function ProjectSupervisorView({
|
||||
continue;
|
||||
}
|
||||
if (streamTurnId && call.turnId === streamTurnId) {
|
||||
if (!liveToolCallTurnId) {
|
||||
liveToolCallTurnId = call.turnId;
|
||||
}
|
||||
liveToolCalls.push(call);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 该回合用户消息的 `updatedAt`:拿得到就在块头显示「发送 → 结束」,拿不到只显示结束时间。
|
||||
const userMessageUpdatedAtForTurn = (turnId: string) => {
|
||||
if (!turnId) {
|
||||
return 0;
|
||||
}
|
||||
const userId = directCodexTurnMessageId(turnId, 'user');
|
||||
return (
|
||||
visibleMessages.find((message) => message.messageId === userId)
|
||||
?.updatedAt ?? 0
|
||||
);
|
||||
};
|
||||
const emptyState =
|
||||
directCodex &&
|
||||
visibleMessages.length === 0 &&
|
||||
@@ -363,11 +388,17 @@ export function ProjectSupervisorView({
|
||||
const anchoredToolCalls = message.messageId
|
||||
? (toolCallsByAnchor.get(message.messageId) ?? [])
|
||||
: [];
|
||||
const anchoredTurnId = message.messageId
|
||||
? directCodexTurnIdFromAssistantMessageId(message.messageId)
|
||||
: null;
|
||||
return (
|
||||
<Fragment key={message.messageId ?? `${message.role}-${index}`}>
|
||||
{anchoredToolCalls.length > 0 ? (
|
||||
<ToolCallGroup
|
||||
calls={anchoredToolCalls}
|
||||
userSentAt={userMessageUpdatedAtForTurn(
|
||||
anchoredTurnId ?? '',
|
||||
)}
|
||||
className="message-tool-call"
|
||||
/>
|
||||
) : null}
|
||||
@@ -383,6 +414,7 @@ export function ProjectSupervisorView({
|
||||
{liveToolCalls.length > 0 ? (
|
||||
<ToolCallGroup
|
||||
calls={liveToolCalls}
|
||||
userSentAt={userMessageUpdatedAtForTurn(liveToolCallTurnId)}
|
||||
className="message-tool-call"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -10,26 +10,36 @@ import { useId, useState } from 'react';
|
||||
|
||||
import type { GameCreatorDirectToolCall } from '../../app/types';
|
||||
import {
|
||||
formatClockTime,
|
||||
formatToolCallDuration,
|
||||
formatTurnDuration,
|
||||
toolCallDurationMs,
|
||||
toolCallGroupSummary,
|
||||
toolCallRowText,
|
||||
turnToolCallDurationMs,
|
||||
turnToolCallEndedAt,
|
||||
turnToolCallTimeLabel,
|
||||
} from './toolCallGroupPresentation';
|
||||
|
||||
/**
|
||||
* 一回合的工具调用折叠块(Codex 风格):
|
||||
* 块头一行汇总,展开态每行一条工具(行可二级展开看命令 / 文件明细 / 输出)。
|
||||
* 块头一行汇总 + 该回合总用时 + 结束时间,展开态每行一条工具(行可二级展开看命令 / 文件明细 / 输出)。
|
||||
* 契约见 `docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md`。
|
||||
*
|
||||
* 无障碍:块头与每一行都是 `<button aria-expanded>` + `hidden` 控制正文,
|
||||
* 所以 Tab 可达、Enter/Space 可切换、读屏能读到展开状态与 `aria-label`。
|
||||
* 文案规则(纯函数)在 `toolCallGroupPresentation.ts`。
|
||||
* 文案与耗时规则(纯函数)在 `toolCallGroupPresentation.ts`。
|
||||
*/
|
||||
|
||||
/** 一回合的工具调用折叠块;空集合不渲染。 */
|
||||
export function ToolCallGroup({
|
||||
calls,
|
||||
userSentAt = 0,
|
||||
className,
|
||||
}: {
|
||||
calls: GameCreatorDirectToolCall[];
|
||||
/** 同一回合用户消息的 `updatedAt`;拿不到就传 0,只显示结束时间。 */
|
||||
userSentAt?: number | null;
|
||||
className?: string;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
@@ -41,6 +51,10 @@ export function ToolCallGroup({
|
||||
(left, right) => left.startedAt - right.startedAt,
|
||||
);
|
||||
const summary = toolCallGroupSummary(orderedCalls);
|
||||
const totalDurationMs = turnToolCallDurationMs(orderedCalls);
|
||||
const durationText = formatTurnDuration(totalDurationMs);
|
||||
const timeLabel = turnToolCallTimeLabel(orderedCalls, userSentAt);
|
||||
const headLabel = durationText ? `${summary},用时 ${durationText}` : summary;
|
||||
const status = orderedCalls.some((call) => call.status === 'running')
|
||||
? 'running'
|
||||
: orderedCalls.some((call) => call.status === 'failed')
|
||||
@@ -55,6 +69,7 @@ export function ToolCallGroup({
|
||||
}
|
||||
data-testid="agent-tool-call-group"
|
||||
data-status={status}
|
||||
data-duration-ms={totalDurationMs ?? ''}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@@ -62,14 +77,22 @@ export function ToolCallGroup({
|
||||
data-testid="agent-tool-call-group-head"
|
||||
aria-expanded={expanded}
|
||||
aria-controls={bodyId}
|
||||
aria-label={summary}
|
||||
title={summary}
|
||||
aria-label={headLabel}
|
||||
title={headLabel}
|
||||
onClick={() => setExpanded((current) => !current)}
|
||||
>
|
||||
<span className="agent-tool-call-group-icon" aria-hidden="true">
|
||||
<ToolCallKindIcon kind={orderedCalls[0]?.kind ?? 'other'} />
|
||||
</span>
|
||||
<span className="agent-tool-call-group-summary">{summary}</span>
|
||||
{timeLabel ? (
|
||||
<span className="agent-tool-call-group-time">{timeLabel}</span>
|
||||
) : null}
|
||||
{durationText ? (
|
||||
<span className="agent-tool-call-group-duration">
|
||||
{`用时 ${durationText}`}
|
||||
</span>
|
||||
) : null}
|
||||
<ChevronDown
|
||||
className="agent-tool-call-group-chevron"
|
||||
size={14}
|
||||
@@ -86,6 +109,14 @@ export function ToolCallGroup({
|
||||
<ToolCallRow key={call.id} call={call} />
|
||||
))}
|
||||
</ul>
|
||||
{timeLabel ? (
|
||||
<p
|
||||
className="agent-tool-call-group-foot"
|
||||
data-testid="agent-tool-call-group-end-time"
|
||||
>
|
||||
{`结束于 ${formatClockTime(turnToolCallEndedAt(orderedCalls))}`}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
@@ -96,13 +127,21 @@ function ToolCallRow({ call }: { call: GameCreatorDirectToolCall }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const detailId = useId();
|
||||
const text = toolCallRowText(call);
|
||||
const durationMs = toolCallDurationMs(call);
|
||||
const durationText = formatToolCallDuration(durationMs);
|
||||
const statusText =
|
||||
call.status === 'running'
|
||||
? '执行中'
|
||||
: call.status === 'failed'
|
||||
? '失败'
|
||||
: '';
|
||||
const rowLabel = [text, statusText].filter(Boolean).join(',');
|
||||
const rowLabel = [
|
||||
text,
|
||||
statusText,
|
||||
durationText ? `耗时 ${durationText}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(',');
|
||||
const changes = call.detail.changes ?? [];
|
||||
const detailCommand = call.detail.command?.trim() ?? '';
|
||||
const detailOutput = call.detail.output?.trim() ?? '';
|
||||
@@ -114,6 +153,7 @@ function ToolCallRow({ call }: { call: GameCreatorDirectToolCall }) {
|
||||
data-testid="agent-tool-call-row"
|
||||
data-kind={call.kind}
|
||||
data-status={call.status}
|
||||
data-duration-ms={durationMs ?? ''}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@@ -131,6 +171,9 @@ function ToolCallRow({ call }: { call: GameCreatorDirectToolCall }) {
|
||||
{statusText ? (
|
||||
<span className="agent-tool-call-row-status">{statusText}</span>
|
||||
) : null}
|
||||
{durationText ? (
|
||||
<span className="agent-tool-call-row-duration">{durationText}</span>
|
||||
) : null}
|
||||
{hasDetail ? (
|
||||
<ChevronDown
|
||||
className="agent-tool-call-row-chevron"
|
||||
|
||||
+123
@@ -92,3 +92,126 @@ function toolCallRowSummary(call: GameCreatorDirectToolCall) {
|
||||
}
|
||||
return call.title.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 单条工具的耗时(毫秒)。
|
||||
* `startedAt` 为 0(缺失)或 `updatedAt < startedAt`(时间倒序)时返回 `null`:
|
||||
* 这两种情况不显示耗时,不显示 `0s` / 负数。
|
||||
*/
|
||||
export function toolCallDurationMs(
|
||||
call: Pick<GameCreatorDirectToolCall, 'startedAt' | 'updatedAt'>,
|
||||
): number | null {
|
||||
const startedAt = Number.isFinite(call.startedAt) ? call.startedAt : 0;
|
||||
const updatedAt = Number.isFinite(call.updatedAt) ? call.updatedAt : 0;
|
||||
if (startedAt <= 0 || updatedAt < startedAt) {
|
||||
return null;
|
||||
}
|
||||
return updatedAt - startedAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* 单条工具的耗时文案:`0.4s`(<1s)/ `12.3s`(<60s,整秒省略小数)/ `2m 5s`(≥60s)。
|
||||
* 无法计算的耗时(`null` / 0 / 负数)返回 `null`。
|
||||
*/
|
||||
export function formatToolCallDuration(ms: number | null | undefined) {
|
||||
if (ms === null || ms === undefined || !Number.isFinite(ms) || ms <= 0) {
|
||||
return null;
|
||||
}
|
||||
if (ms < 60000) {
|
||||
const tenths = Math.max(1, Math.round(ms / 100));
|
||||
if (tenths < 600) {
|
||||
const value = tenths / 10;
|
||||
return Number.isInteger(value) ? `${value}s` : `${value.toFixed(1)}s`;
|
||||
}
|
||||
}
|
||||
const totalSeconds = Math.max(60, Math.round(ms / 1000));
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const restSeconds = totalSeconds % 60;
|
||||
return restSeconds === 0 ? `${minutes}m` : `${minutes}m ${restSeconds}s`;
|
||||
}
|
||||
|
||||
/** 一回合总用时:该回合所有工具的 `min(startedAt)` → `max(updatedAt)`;取不到返回 `null`。 */
|
||||
export function turnToolCallDurationMs(
|
||||
calls: Array<Pick<GameCreatorDirectToolCall, 'startedAt' | 'updatedAt'>>,
|
||||
): number | null {
|
||||
let minStartedAt = Number.POSITIVE_INFINITY;
|
||||
let maxUpdatedAt = Number.NEGATIVE_INFINITY;
|
||||
for (const call of calls) {
|
||||
const startedAt = Number.isFinite(call.startedAt) ? call.startedAt : 0;
|
||||
const updatedAt = Number.isFinite(call.updatedAt) ? call.updatedAt : 0;
|
||||
if (startedAt > 0) {
|
||||
minStartedAt = Math.min(minStartedAt, startedAt);
|
||||
}
|
||||
if (updatedAt > 0) {
|
||||
maxUpdatedAt = Math.max(maxUpdatedAt, updatedAt);
|
||||
}
|
||||
}
|
||||
if (!Number.isFinite(minStartedAt) || !Number.isFinite(maxUpdatedAt)) {
|
||||
return null;
|
||||
}
|
||||
if (maxUpdatedAt < minStartedAt) {
|
||||
return null;
|
||||
}
|
||||
return maxUpdatedAt - minStartedAt;
|
||||
}
|
||||
|
||||
/** 块头总用时文案:`42秒` / `4分钟` / `5分钟 45秒`;无法计算的耗时返回 `null`。 */
|
||||
export function formatTurnDuration(ms: number | null | undefined) {
|
||||
if (ms === null || ms === undefined || !Number.isFinite(ms) || ms <= 0) {
|
||||
return null;
|
||||
}
|
||||
const seconds = Math.max(1, Math.round(ms / 1000));
|
||||
if (seconds < 60) {
|
||||
return `${seconds}秒`;
|
||||
}
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const restSeconds = seconds % 60;
|
||||
return restSeconds === 0
|
||||
? `${minutes}分钟`
|
||||
: `${minutes}分钟 ${restSeconds}秒`;
|
||||
}
|
||||
|
||||
/** 该回合的结束时间:`max(updatedAt)`;取不到返回 0。 */
|
||||
export function turnToolCallEndedAt(
|
||||
calls: Array<Pick<GameCreatorDirectToolCall, 'updatedAt'>>,
|
||||
) {
|
||||
let maxUpdatedAt = 0;
|
||||
for (const call of calls) {
|
||||
if (Number.isFinite(call.updatedAt) && call.updatedAt > maxUpdatedAt) {
|
||||
maxUpdatedAt = call.updatedAt;
|
||||
}
|
||||
}
|
||||
return maxUpdatedAt;
|
||||
}
|
||||
|
||||
/** 本地 `HH:mm`;时间戳缺失(0 / 非法)返回 `null`,不编造时间。 */
|
||||
export function formatClockTime(timestamp: number | null | undefined) {
|
||||
if (
|
||||
timestamp === null ||
|
||||
timestamp === undefined ||
|
||||
!Number.isFinite(timestamp) ||
|
||||
timestamp <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const date = new Date(timestamp);
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 块头时间文案:该回合结束时间(`max(updatedAt)` 的本地 `HH:mm`);
|
||||
* 能拿到同回合用户消息时间(`updatedAt > 0`)时显示「发送 → 结束」,取不到就只显示结束时间。
|
||||
*/
|
||||
export function turnToolCallTimeLabel(
|
||||
calls: Array<Pick<GameCreatorDirectToolCall, 'updatedAt'>>,
|
||||
userSentAt: number | null | undefined,
|
||||
) {
|
||||
const endLabel = formatClockTime(turnToolCallEndedAt(calls));
|
||||
if (!endLabel) {
|
||||
return null;
|
||||
}
|
||||
const sentLabel = formatClockTime(userSentAt ?? 0);
|
||||
return sentLabel ? `${sentLabel} → ${endLabel}` : endLabel;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user