diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx index d285bcb7e..af2336e9a 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx @@ -63,13 +63,23 @@ import { type ResourceReferenceInputHandle, } from './ResourceReferenceInput'; import type { ChatComposerDraft, ChatReference } from './resourceReferences'; -import { ToolCallCard } from './ToolCallCard'; +import { ToolCallGroup } from './ToolCallGroup'; /** 与 `App.tsx` 的回合消息 id 同构:`direct-codex::`。 */ function directCodexTurnMessageId(turnId: string, role: 'user' | 'assistant') { return `direct-codex:${turnId}:${role}`; } +/** 从 `direct-codex::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; function directStatusTitle(status: string | null | undefined) { @@ -227,37 +237,47 @@ export function ProjectSupervisorView({ }, [settingsOpen]); const runBusy = runtimePanelProps.controlBusy || Boolean(directProcessDetail) || submitting; - // 工具调用卡片按回合分组:契约要求插在**同一回合最后一条 assistant 消息之后**。 - // 当前正在跑的回合还没有 assistant 消息落盘,锚到窗口里最后一条 assistant 消息; - // 历史回合一律锚到自己那条 `direct-codex::assistant`,不回落到别的回合。 - const liveAssistantMessageId = [...visibleMessages] - .reverse() - .find((message) => message.role === 'assistant')?.messageId; + // 工具调用折叠块按回合分组,插在**同一回合 assistant 消息之前**(Codex 是「工具在上、答复在下」)。 + // 历史回合锚到自己那条 `direct-codex::assistant`,不回落到别的回合; + // 正在跑的回合还没有 assistant 消息落盘,先落在消息流末尾,等那条消息落盘后回到它之前。 + const streamTurnId = activeTurnId?.trim() ?? ''; const toolCallsByAnchor = new Map(); + const liveToolCalls: GameCreatorDirectToolCall[] = []; + let liveToolCallTurnId = ''; if (directCodex) { - const streamTurnId = activeTurnId?.trim() ?? ''; for (const call of toolCalls) { const expected = directCodexTurnMessageId(call.turnId, 'assistant'); const hasPersistedAssistant = visibleMessages.some( (message) => message.messageId === expected, ); - const anchor = - call.turnId === streamTurnId - ? (liveAssistantMessageId ?? null) - : hasPersistedAssistant - ? expected - : null; - if (!anchor) { + if (hasPersistedAssistant) { + const bucket = toolCallsByAnchor.get(expected); + if (bucket) { + bucket.push(call); + } else { + toolCallsByAnchor.set(expected, [call]); + } continue; } - const bucket = toolCallsByAnchor.get(anchor); - if (bucket) { - bucket.push(call); - } else { - toolCallsByAnchor.set(anchor, [call]); + 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 && @@ -368,24 +388,36 @@ export function ProjectSupervisorView({ const anchoredToolCalls = message.messageId ? (toolCallsByAnchor.get(message.messageId) ?? []) : []; + const anchoredTurnId = message.messageId + ? directCodexTurnIdFromAssistantMessageId(message.messageId) + : null; return ( + {anchoredToolCalls.length > 0 ? ( + + ) : null}
- {anchoredToolCalls.map((call) => ( - - ))}
); })} + {liveToolCalls.length > 0 ? ( + + ) : null} {designReasoning ? (
显示思考过程 diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ToolCallCard.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ToolCallCard.tsx deleted file mode 100644 index 7548fd090..000000000 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ToolCallCard.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import { - ChevronDown, - FileText, - Globe, - Minimize2, - Terminal, - Wrench, -} from 'lucide-react'; -import { useId, useState } from 'react'; - -import type { GameCreatorDirectToolCall } from '../../app/types'; - -/** - * 工具调用卡片(Codex 风格): - * 折叠态一行摘要,展开态看命令与文件明细。契约见 - * `docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md`。 - * - * 无障碍:折叠/展开是一只 ` - - - ); -} - -function toolCallChangeKindLabel(kind: string) { - if (kind === 'add') { - return '新增'; - } - if (kind === 'delete') { - return '删除'; - } - return '修改'; -} - -function ToolCallIcon({ kind }: { kind: string }) { - switch (kind) { - case 'command': - return ; - case 'file_change': - return ; - case 'web_search': - return ; - case 'context_compaction': - return ; - default: - return ; - } -} diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ToolCallGroup.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ToolCallGroup.tsx new file mode 100644 index 000000000..62fbf8f27 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ToolCallGroup.tsx @@ -0,0 +1,234 @@ +import { + ChevronDown, + FileText, + Globe, + Minimize2, + Terminal, + Wrench, +} from 'lucide-react'; +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`。 + * + * 无障碍:块头与每一行都是 ` + + + ); +} + +/** 展开态的一行工具;行可二级展开看命令 / 文件明细 / 输出。 */ +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, + durationText ? `耗时 ${durationText}` : '', + ] + .filter(Boolean) + .join(','); + const changes = call.detail.changes ?? []; + const detailCommand = call.detail.command?.trim() ?? ''; + const detailOutput = call.detail.output?.trim() ?? ''; + const hasDetail = + Boolean(detailCommand) || Boolean(detailOutput) || changes.length > 0; + return ( +
  • + + +
  • + ); +} + +function toolCallChangeKindLabel(kind: string) { + if (kind === 'add') { + return '新增'; + } + if (kind === 'delete') { + return '删除'; + } + return '修改'; +} + +function ToolCallKindIcon({ kind }: { kind: string }) { + switch (kind) { + case 'command': + return ; + case 'file_change': + return ; + case 'web_search': + return ; + case 'context_compaction': + return ; + default: + return ; + } +} diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/toolCallGroupPresentation.ts b/apps/ai-game-creator-shell/src/features/project-workspace/toolCallGroupPresentation.ts new file mode 100644 index 000000000..9c928252a --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/toolCallGroupPresentation.ts @@ -0,0 +1,217 @@ +import type { + GameCreatorDirectToolCall, + GameCreatorDirectToolCallKind, +} from '../../app/types'; + +/** + * 工具调用折叠块的纯文案计算:汇总 / 行文案。 + * 契约见 `docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md`。 + * + * 与组件分文件:`ToolCallGroup.tsx` 只导出组件,纯函数放这里(react-refresh 要求 + * 组件文件不导出非组件值,也便于单测直接断言文案规则)。 + */ + +/** 汇总文案里 kind 的固定顺序:command → file_change → mcp_tool → web_search → context_compaction → other。 */ +const TOOL_CALL_KIND_ORDER: GameCreatorDirectToolCallKind[] = [ + 'command', + 'file_change', + 'mcp_tool', + 'web_search', + 'context_compaction', + 'other', +]; + +const TOOL_CALL_KIND_LABELS: Record = { + command: '命令', + file_change: '文件变更', + mcp_tool: '工具调用', + web_search: '联网搜索', + context_compaction: '上下文整理', + other: '其他操作', +}; + +/** 行文案动词:`已运行 {summary}` / `已编辑 {summary}` / …,`context_compaction` 不带 summary。 */ +const TOOL_CALL_ROW_VERBS: Partial< + Record +> = { + command: '已运行', + file_change: '已编辑', + mcp_tool: '已调用', + web_search: '已搜索', + other: '已执行', +}; + +/** 汇总文案:按 kind 计数、固定顺序拼成 `已执行 5 个命令、2 个文件变更`;空集合返回空串。 */ +export function toolCallGroupSummary(calls: GameCreatorDirectToolCall[]) { + const counts = new Map(); + for (const call of calls) { + counts.set(call.kind, (counts.get(call.kind) ?? 0) + 1); + } + const parts: string[] = []; + const append = (kind: string) => { + const count = counts.get(kind) ?? 0; + if (count <= 0) { + return; + } + counts.delete(kind); + const label = + TOOL_CALL_KIND_LABELS[kind as GameCreatorDirectToolCallKind] ?? + '其他操作'; + parts.push(`${count} 个${label}`); + }; + for (const kind of TOOL_CALL_KIND_ORDER) { + append(kind); + } + // 契约外的 kind:不丢计数,落到末尾的「其他操作」。 + for (const kind of [...counts.keys()]) { + append(kind); + } + return parts.length > 0 ? `已执行 ${parts.join('、')}` : ''; +} + +/** 一行工具的文案:`已运行 npm run build` / `已整理上下文`。 */ +export function toolCallRowText(call: GameCreatorDirectToolCall) { + if (call.kind === 'context_compaction') { + return '已整理上下文'; + } + const summary = toolCallRowSummary(call); + const verb = TOOL_CALL_ROW_VERBS[call.kind] ?? '已执行'; + return summary ? `${verb} ${summary}` : verb; +} + +function toolCallRowSummary(call: GameCreatorDirectToolCall) { + const summary = call.summary.trim(); + if (summary) { + return summary; + } + if (call.kind === 'file_change') { + const firstPath = call.detail.changes?.[0]?.path?.trim(); + if (firstPath) { + return firstPath; + } + } + return call.title.trim(); +} + +/** + * 单条工具的耗时(毫秒)。 + * `startedAt` 为 0(缺失)或 `updatedAt < startedAt`(时间倒序)时返回 `null`: + * 这两种情况不显示耗时,不显示 `0s` / 负数。 + */ +export function toolCallDurationMs( + call: Pick, +): 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>, +): 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>, +) { + 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>, + userSentAt: number | null | undefined, +) { + const endLabel = formatClockTime(turnToolCallEndedAt(calls)); + if (!endLabel) { + return null; + } + const sentLabel = formatClockTime(userSentAt ?? 0); + return sentLabel ? `${sentLabel} → ${endLabel}` : endLabel; +} diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 57b125c85..13cc06f9b 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -11293,59 +11293,64 @@ button.design-workspace-tree__entry:hover, } /* ============================================================ - 工具调用卡片(2026-09):Codex 风格可折叠卡片 + 工具调用折叠块(2026-09):一回合一个块,Codex 风格 ============================================================ 契约见 `docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md`: - 折叠态一行摘要(图标 / 标题 / 摘要 / 状态点 / 状态 / 折角),展开态看命令、文件明细与输出。 + 块头一行浅底圆角条目(图标 / 汇总 / 结束时间 / 总用时 / 折角),展开态每行一条工具, + 行右侧是对齐的耗时,行可二级展开看命令、文件明细与输出。 折叠由 ` -