diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 3aece9802..fd12809a6 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -57,6 +57,7 @@ import type { DesignView, GameCreatorAgentRuntimeUpdateEvent, GameCreatorChatAgentReply, + GameCreatorDirectToolCall, GameCreatorDirectTurnUpdateEvent, GameCreatorLlmConfigStatus, GameCreatorManifestInvalidatedEvent, @@ -456,6 +457,21 @@ function directCodexConversationMessageId( return `${DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX}${turnId}:${role}`; } +/** 从 `direct-codex::assistant` 反解回合 id;不是这个形状就返回 `null`。 */ +function directCodexTurnIdFromAssistantMessageId(messageId: string) { + const suffix = ':assistant'; + if ( + !messageId.startsWith(DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX) || + !messageId.endsWith(suffix) + ) { + return null; + } + return messageId.slice( + DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX.length, + -suffix.length, + ); +} + export function isDirectCodexTurnAlreadyRunningError(error: unknown) { const message = error instanceof Error ? error.message : String(error); return message @@ -755,6 +771,57 @@ export function App({ } | null>(null); const lastDirectCodexActivityRef = useRef(null); const directCodexConversationTurnSequenceRef = useRef(0); + // 工具调用卡片:按 **id** 归并(实时增量 + 回读历史共用一份),同一 id 只渲染一次。 + // 用 ref 做写入基准,避免同一批事件里多条增量互相覆盖。 + const [directToolCalls, setDirectToolCalls] = useState< + GameCreatorDirectToolCall[] + >([]); + const directToolCallsRef = useRef([]); + /** + * 归并一批工具调用:同 id 覆盖已有条目(`completed` 覆盖 `running`), + * 新 id 追加(保持首次出现顺序)。实时增量与回读历史都走这里,所以同一 id 不会重复渲染。 + */ + function applyDirectToolCalls( + incoming: readonly GameCreatorDirectToolCall[], + ) { + if (incoming.length === 0) { + return; + } + const merged = [...directToolCallsRef.current]; + for (const call of incoming) { + const id = call.id?.trim(); + if (!id) { + continue; + } + const existingIndex = merged.findIndex((existing) => existing.id === id); + const normalized: GameCreatorDirectToolCall = { + ...call, + id, + detail: call.detail ?? { changes: [] }, + }; + // 起点时间取更早的那个:`completed` 事件不一定带 startedAt。 + const existing = existingIndex >= 0 ? merged[existingIndex] : undefined; + if ( + existing && + existing.startedAt > 0 && + (normalized.startedAt === 0 || + existing.startedAt < normalized.startedAt) + ) { + normalized.startedAt = existing.startedAt; + } + if (existingIndex >= 0) { + merged[existingIndex] = normalized; + } else { + merged.push(normalized); + } + } + merged.sort( + (left, right) => + left.startedAt - right.startedAt || left.id.localeCompare(right.id), + ); + directToolCallsRef.current = merged; + setDirectToolCalls(merged); + } const [projectSupervisorSessionId, setProjectSupervisorSessionId] = useState< string | null >(null); @@ -786,6 +853,23 @@ export function App({ setDirectCodexTransientReplyUpdatedAt(null); } + /** 工具调用卡片按项目维度作废:换项目 / 重开历史时整体替换,避免串项目。 */ + function replaceDirectToolCalls(next: readonly GameCreatorDirectToolCall[]) { + const normalized = next + .filter((call) => Boolean(call.id?.trim())) + .map((call) => ({ + ...call, + id: call.id.trim(), + detail: call.detail ?? { changes: [] }, + })) + .sort( + (left, right) => + left.startedAt - right.startedAt || left.id.localeCompare(right.id), + ); + directToolCallsRef.current = normalized; + setDirectToolCalls(normalized); + } + function clearDirectCodexTransientReply(projectPath: string, turnId: string) { const activeTurn = activeDirectCodexTurnRef.current; if ( @@ -1702,6 +1786,15 @@ export function App({ } activeTurn.lastSequence = payload.sequence; activeTurn.receivedDirectUpdate = true; + // 工具调用增量:字段可选,老事件(undefined)走原路径,行为不变。 + if (payload.toolCalls?.length) { + applyDirectToolCalls( + payload.toolCalls.map((call) => ({ + ...call, + turnId: payload.turnId, + })), + ); + } const updatedAt = Number.isFinite(payload.updatedAt) && payload.updatedAt > 0 ? payload.updatedAt @@ -3321,6 +3414,17 @@ export function App({ ? { projectPath: nextProjectPath } : { projectPath: nextProjectPath, agentId: null }, ); + // 工具调用卡片走独立历史文件(`tool-calls.jsonl`)。必须在读完项目对话之后、 + // 任何提前 return 之前回读:direct-codex 下后面那条 design-agent 分支会直接返回, + // 放在它后面等于永远不执行。文件缺失 / 读取失败都只是没有卡片,不能因此把整个 + // 项目打开流程判失败。卡片按项目维度整表替换,同一 id 只渲染一次。 + if (directCodexProductRuntime) { + const persistedToolCalls = await invoke( + 'read_direct_tool_calls', + { projectPath: nextProjectPath }, + ).catch(() => []); + replaceDirectToolCalls(persistedToolCalls); + } let supervisorConversation: LocalConversationResult | null = null; let runtime: AgentRuntimeState | null = null; let runtimeResponseStream: AgentRuntimeResponseStream | null = null; @@ -11520,6 +11624,20 @@ export function App({ 0, messages.length - visibleMessages.length, ); + // 工具调用卡片只保留「当前消息列表里确实有这个回合」的那些:实时回合一进来就能挂上, + // 历史回合只有对应的 assistant 消息还在列表里才渲染,避免旧卡片漂在列表尾部。 + const visibleTurnIds = new Set( + messages + .map((message) => message.messageId) + .filter((messageId): messageId is string => Boolean(messageId)) + .map(directCodexTurnIdFromAssistantMessageId) + .filter((turnId): turnId is string => Boolean(turnId)), + ); + const visibleToolCalls = directToolCalls.filter( + (call) => + visibleTurnIds.has(call.turnId) || + call.turnId === activeDirectCodexTurnRef.current?.turnId, + ); const projectSupervisorTransientReply = projectSupervisorResponseStream?.accumulatedText.trim() ?? ''; const projectSupervisorNeedsUserInput = agentRuntimeNeedsUserInput( @@ -11789,6 +11907,12 @@ export function App({ } pendingCommand={directCodexProductRuntime ? pendingCommand : null} projectPath={localProject?.projectPath ?? projectPath} + toolCalls={visibleToolCalls} + activeTurnId={ + directCodexProductRuntime + ? (activeDirectCodexTurnRef.current?.turnId ?? null) + : null + } transientReply={ planningV2Active ? planningV2TransientReply diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index a89c1698a..5e59dbcf5 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -1087,6 +1087,57 @@ export type GameCreatorDirectTurnActivity = | 'response-finalization' | 'none'; +export type GameCreatorDirectToolCallKind = + | 'command' + | 'file_change' + | 'mcp_tool' + | 'web_search' + | 'context_compaction' + | 'other'; + +export type GameCreatorDirectToolCallStatus = + | 'running' + | 'completed' + | 'failed'; + +export interface GameCreatorDirectToolCallChange { + path: string; + kind: 'add' | 'update' | 'delete' | string; +} + +export interface GameCreatorDirectToolCallDetail { + command?: string; + output?: string; + changes?: GameCreatorDirectToolCallChange[]; +} + +/** + * 一条工具调用(Codex item 的结构化投影)。 + * + * 契约见 `docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md`: + * 字段形状与 Rust 侧 `DirectToolCall`、独立历史文件 + * `.agent/conversations/tool-calls.jsonl` 的 payload 一致(这里少 `turnId` 的变体用于 + * 事件增量,见下面 `GameCreatorDirectTurnToolCall`)。 + */ +export interface GameCreatorDirectToolCall { + schemaVersion: string; + id: string; + turnId: string; + kind: GameCreatorDirectToolCallKind; + title: string; + summary: string; + status: GameCreatorDirectToolCallStatus; + detail: GameCreatorDirectToolCallDetail; + startedAt: number; + updatedAt: number; +} + +/** 事件里下发的增量条目:与持久化同形,去掉 `turnId`(回合 id 在事件顶层)。 */ +export type GameCreatorDirectTurnToolCall = Omit< + GameCreatorDirectToolCall, + 'turnId' +>; + export interface GameCreatorDirectTurnUpdateEvent { projectPath: string; turnId: string; @@ -1094,6 +1145,11 @@ export interface GameCreatorDirectTurnUpdateEvent { status: GameCreatorDirectTurnUpdateStatus; activity?: GameCreatorDirectTurnActivity | null; accumulatedText?: string | null; + /** + * 本回合内**发生变化**的结构化工具调用(只有变化时才带,不是每个 heartbeat 都带全量)。 + * 可选:老版本事件没有这个字段,前端拿到 `undefined` 时必须与改造前行为一致。 + */ + toolCalls?: GameCreatorDirectTurnToolCall[] | null; updatedAt: number; } 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 a94385beb..d285bcb7e 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 @@ -13,11 +13,12 @@ import type { RefObject, UIEventHandler, } from 'react'; -import { useEffect, useRef, useState } from 'react'; +import { Fragment, useEffect, useRef, useState } from 'react'; import type { AgentStatusCard, ChatMessage, + GameCreatorDirectToolCall, GameCreatorDirectTurnUpdateStatus, PendingCommand, PendingUiConfirmation, @@ -62,6 +63,12 @@ import { type ResourceReferenceInputHandle, } from './ResourceReferenceInput'; import type { ChatComposerDraft, ChatReference } from './resourceReferences'; +import { ToolCallCard } from './ToolCallCard'; + +/** 与 `App.tsx` 的回合消息 id 同构:`direct-codex::`。 */ +function directCodexTurnMessageId(turnId: string, role: 'user' | 'assistant') { + return `direct-codex:${turnId}:${role}`; +} type RuntimePanelProps = ComponentProps; @@ -108,6 +115,10 @@ type ProjectSupervisorViewProps = RuntimePanelProps & { pendingConfirmation: PendingUiConfirmation | null; pendingCommand: PendingCommand | null; projectPath: string; + /** 本回合(含历史回读)的工具调用卡片,按 `startedAt` 升序,同一 id 只会出现一次。 */ + toolCalls?: GameCreatorDirectToolCall[]; + /** 当前正在跑的回合 id;卡片在 assistant 消息落盘前锚到它。 */ + activeTurnId?: string | null; showProfessionalCollaboration?: boolean; transientReply: string; designReasoning?: string; @@ -161,6 +172,8 @@ export function ProjectSupervisorView({ pendingConfirmation, pendingCommand, projectPath, + toolCalls = [], + activeTurnId = null, showProfessionalCollaboration = true, transientReply, designReasoning = '', @@ -214,6 +227,37 @@ 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; + const toolCallsByAnchor = new Map(); + 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) { + continue; + } + const bucket = toolCallsByAnchor.get(anchor); + if (bucket) { + bucket.push(call); + } else { + toolCallsByAnchor.set(anchor, [call]); + } + } + } const emptyState = directCodex && visibleMessages.length === 0 && @@ -320,17 +364,28 @@ export function ProjectSupervisorView({ {`显示更早 · 还有 ${hiddenConversationCount} 条对话`} ) : null} - {visibleMessages.map((message, index) => ( -
- -
- ))} + {visibleMessages.map((message, index) => { + const anchoredToolCalls = message.messageId + ? (toolCallsByAnchor.get(message.messageId) ?? []) + : []; + return ( + +
+ +
+ {anchoredToolCalls.map((call) => ( + + ))} +
+ ); + })} {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 new file mode 100644 index 000000000..7548fd090 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ToolCallCard.tsx @@ -0,0 +1,117 @@ +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/styles.css b/apps/ai-game-creator-shell/src/styles.css index 379902e73..9645a4c9a 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -11239,3 +11239,164 @@ button.design-workspace-tree__entry:hover, min-height: 0; flex: 1 1 auto; } + +/* ============================================================ + 工具调用卡片(2026-09):Codex 风格可折叠卡片 + ============================================================ + 契约见 `docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md`: + 折叠态一行摘要(图标 / 标题 / 摘要 / 状态点 / 状态 / 折角),展开态看命令、文件明细与输出。 + 折叠由 `