From 05b4f9bc871d2238ce30f07ca6d5578a1e4beccd Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Tue, 15 Sep 2026 23:00:45 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=99=BA=E8=83=BD=E5=88=9B?= =?UTF-8?q?=E4=BD=9C=E5=AF=B9=E8=AF=9D=E5=B1=95=E7=A4=BA=E4=B8=8E=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E8=B0=83=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复流式回复、运行状态卡片和回合耗时展示 保留创建项目时的初始提示词并调整输入区按钮 展示工具调用输入输出并清理命令输出中的 ANSI 编码 --- .../src-tauri/src/agent/codex_app_server.rs | 37 ++++ .../src-tauri/src/agent/direct_runtime.rs | 43 +++-- .../src-tauri/src/agent/direct_tool_calls.rs | 83 ++++++++- .../src/agent/runtime_driver/entrypoints.rs | 13 ++ .../src-tauri/src/main.rs | 3 + apps/ai-game-creator-shell/src/App.tsx | 54 +++++- apps/ai-game-creator-shell/src/app/types.ts | 4 + .../ProjectSupervisorView.tsx | 176 ++++++++++++++++-- .../project-workspace/ToolCallGroup.tsx | 35 ++-- apps/ai-game-creator-shell/src/styles.css | 62 ++++++ 10 files changed, 463 insertions(+), 47 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs index dc892fc29..03980d27f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs @@ -573,6 +573,8 @@ enum CodexTurnEvent { pub(crate) enum DirectCodexTurnObservation { AccumulatedText(String), IntermediateText(String), + /// 模型的思考过程(reasoning item 的明文摘要):流式阶段整段替换下发。 + Reasoning(String), Activity(&'static str), /// 一条结构化工具调用(`item/started` 与 `item/completed` 各采一次,按 id 幂等)。 ToolCall(crate::DirectToolCall), @@ -841,6 +843,37 @@ fn direct_codex_mcp_tool_intermediate_text(item: &serde_json::Value) -> String { /// (with the concrete command/tool/path) while tools run; it does not push /// plan/reasoning text deltas. Showing what the agent is actually doing is /// the only reliable way to make the execution phase feel alive. +/// 从 reasoning item 里抽明文思考文本:优先 `summary[].text`,其次 `content[].text`。 +/// +/// Codex 的 reasoning item 形如 +/// `{ "type": "reasoning", "summary": [...], "content": [{ "text": "..." }], "encrypted_content": ... }`, +/// 没有 `role` 字段;明文(至少 content/summary 之一)存在时我们才展示,拿不到就返回 None。 +fn direct_codex_item_reasoning_text(item: &serde_json::Value) -> Option { + if item.get("type").and_then(serde_json::Value::as_str) != Some("reasoning") { + return None; + } + let collect = |key: &str| -> Option { + let parts = item + .get(key)? + .as_array()? + .iter() + .filter_map(|entry| { + entry + .get("text") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|text| !text.is_empty()) + }) + .collect::>(); + if parts.is_empty() { + None + } else { + Some(parts.join("\n\n")) + } + }; + collect("summary").or_else(|| collect("content")) +} + fn direct_codex_item_intermediate_text(item: &serde_json::Value) -> Option { const MAX_ITEM_TEXT_CHARS: usize = 240; let item_type = item @@ -2952,6 +2985,10 @@ impl CodexAppServerConnection { // 让执行期间聊天窗口显示“正在做什么”,而不是只 // 有活动状态来回跳动。completed 事件不再重复。 if !completed { + if let Some(reasoning) = direct_codex_item_reasoning_text(item) + { + observer(DirectCodexTurnObservation::Reasoning(reasoning)); + } if let Some(text) = direct_codex_item_intermediate_text(item) { observer(DirectCodexTurnObservation::IntermediateText( text, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs index c46eb5f04..1e2fd4a4f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs @@ -4366,7 +4366,6 @@ async fn run_direct_game_creator_turn_inner( let emitter = emitter.clone(); let turn_root = root.to_path_buf(); let turn_tool_calls = Arc::clone(&tool_calls); - let mut emitted_tool_call_ids: BTreeSet = BTreeSet::new(); let mut observer = move |observation: DirectCodexTurnObservation| { let status = direct_codex_observation_status(&observation, stream_enabled); match observation { @@ -4393,27 +4392,37 @@ async fn run_direct_game_creator_turn_inner( DirectCodexTurnObservation::Activity(activity) => { emitter.emit(status, Some(activity), None, None); } + DirectCodexTurnObservation::Reasoning(reasoning) => { + // 思考过程按"当前累计全文"下发(前端整段替换),状态保持 running: + // streaming 已被"用户可见正文"占用。 + emitter.emit_with_reasoning("running", None, None, None, Some(reasoning)); + } DirectCodexTurnObservation::ToolCall(tool_call) => { - // 每条工具调用只在采集到的那一个事件里下发一次(id 与 Codex item 一一对应), - // 这样既满足"集合变化才带",也避免每个 heartbeat 重发全量。 - if !emitted_tool_call_ids.insert(tool_call.id.clone()) { - return; - } - let previous = { - let mut collected = lock_direct_tool_call_collector(&turn_tool_calls); - let previous = collected + // 同一个工具调用会被观察两次:`item/started`(running)与 `item/completed` + // (终态)。这里**只在状态真的变化时**才再收集与下发一次,既能带上终态、 + // 又不会在每个 heartbeat 重发同一份快照(前端按 id 幂等合并,不会多出卡片)。 + // + // 曾经这里用"每个 id 只发一次"去重,结果终态观察被直接丢掉:工具调用永远 + // 停在 running(实机表现为"命令都结束了还显示执行中")。 + { + let collected = lock_direct_tool_call_collector(&turn_tool_calls); + let existing = collected .iter() - .find(|existing| existing.id == tool_call.id) - .cloned(); + .find(|existing| existing.id == tool_call.id); + if !super::direct_tool_calls::direct_tool_call_status_changed( + existing, &tool_call, + ) { + return; + } + } + { + let mut collected = lock_direct_tool_call_collector(&turn_tool_calls); collected.retain(|existing| existing.id != tool_call.id); collected.push(tool_call.clone()); - previous - }; - emitter.emit(status, None, None, Some(vec![tool_call])); - // `started` 一落盘卡片就能在刷新后立刻出现;`completed` 覆盖同一行。 - if let Some(previous) = previous { - spawn_persist_direct_tool_call(&turn_root, &previous); } + emitter.emit(status, None, None, Some(vec![tool_call.clone()])); + // 落盘"最新的那一份":started 让卡片刷新后立刻出现,终态覆盖同一行。 + spawn_persist_direct_tool_call(&turn_root, &tool_call); } } }; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_calls.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_calls.rs index 005e9e333..bf9e35b05 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_calls.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_calls.rs @@ -296,6 +296,15 @@ fn direct_tool_call_title(kind: &str, changes: &[DirectToolCallChange]) -> Strin } fn direct_tool_call_status(item: &Value, completed: bool) -> &'static str { + // item 自带的显式终态优先:被策略拒绝(declined)、失败、取消的调用不能因为 + // `completed == true` 就被当成成功,否则卡片会把"没执行成功"显示成"已执行"。 + if let Some(status) = item.get("status").and_then(Value::as_str) { + match status { + "completed" => return "completed", + "failed" | "declined" | "cancelled" | "canceled" | "aborted" => return "failed", + _ => {} + } + } // Codex 的退出码约定:非 0 即失败;缺席时按"已完成"处理。 if let Some(exit_code) = item.get("exitCode").and_then(Value::as_i64) { return if exit_code == 0 { @@ -314,6 +323,17 @@ fn direct_tool_call_status(item: &Value, completed: bool) -> &'static str { } } +/// 同一 id 的两次观察(`item/started` / `item/completed`)是否带来了状态变化。 +/// +/// 只有状态变化时才需要再收集、再下发一次:既避免同一份快照在每个心跳重复下发, +/// 又不会像"每个 id 只发一次"那样把终态丢掉(历史 bug:命令都结束了卡片仍显示"执行中")。 +pub(crate) fn direct_tool_call_status_changed( + existing: Option<&DirectToolCall>, + incoming: &DirectToolCall, +) -> bool { + !existing.is_some_and(|current| current.status == incoming.status) +} + /// 把一条 Codex item 投影成工具调用条目。非工具类 item 返回 `None`。 /// /// `started_at` / `updated_at`:item 自己带的 `startedAtMs` / `completedAtMs` 优先, @@ -632,9 +652,10 @@ pub(crate) fn direct_tool_call_now_ms() -> u64 { #[cfg(test)] mod tests { use super::{ - direct_tool_call_from_item, direct_tool_call_now_ms, persist_direct_tool_call_at, - persist_direct_tool_calls_at, read_direct_tool_calls_at, sanitize_detail_text, - tool_calls_path, DIRECT_TOOL_CALL_LIMIT, + direct_tool_call_from_item, direct_tool_call_now_ms, direct_tool_call_status, + direct_tool_call_status_changed, persist_direct_tool_call_at, persist_direct_tool_calls_at, + read_direct_tool_calls_at, sanitize_detail_text, tool_calls_path, DirectToolCall, + DirectToolCallDetail, DIRECT_TOOL_CALL_LIMIT, DIRECT_TOOL_CALL_SCHEMA_VERSION, }; use serde_json::json; @@ -676,6 +697,62 @@ mod tests { } /// 判据:同一 item 的 started 与 completed 只落一行,completed 覆盖 status。 + fn sample_tool_call(id: &str, status: &str, updated_at: u64) -> DirectToolCall { + DirectToolCall { + schema_version: DIRECT_TOOL_CALL_SCHEMA_VERSION.to_string(), + id: id.to_string(), + turn_id: "turn-1".to_string(), + kind: "command".to_string(), + title: "执行命令".to_string(), + summary: "npm run build".to_string(), + status: status.to_string(), + detail: DirectToolCallDetail::default(), + started_at: 1, + updated_at, + } + } + + #[test] + fn tool_call_status_change_is_detected_only_on_real_changes() { + let running = sample_tool_call("call-1", "running", 1); + let completed = sample_tool_call("call-1", "completed", 2); + + assert!( + direct_tool_call_status_changed(None, &running), + "首次观察必须被收集" + ); + assert!( + !direct_tool_call_status_changed(Some(&running), &running), + "状态没变时不该重复下发同一份快照" + ); + assert!( + direct_tool_call_status_changed(Some(&running), &completed), + "running -> completed 的终态观察必须被收集与下发(历史 bug:这里被丢弃,卡片永远显示执行中)" + ); + } + + #[test] + fn explicit_declined_or_failed_status_is_not_reported_as_completed() { + for status in ["declined", "failed", "cancelled", "aborted"] { + let item = json!({ + "id": "call-1", + "type": "commandExecution", + "status": status, + }); + assert_eq!( + direct_tool_call_status(&item, true), + "failed", + "item 自带 {status} 时不能因为 completed=true 就被当成 completed" + ); + } + let completed = json!({ + "id": "call-1", + "type": "commandExecution", + "status": "completed", + }); + assert_eq!(direct_tool_call_status(&completed, true), "completed"); + } + #[test] fn tool_call_upsert_is_idempotent_per_item_id() { let root = init_tool_call_project("tool-call-upsert"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs index 2c6867fb3..9089d7748 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs @@ -50,6 +50,18 @@ impl DirectGameCreatorTurnUpdateEmitter { activity: Option<&'static str>, accumulated_text: Option, tool_calls: Option>, + ) { + self.emit_with_reasoning(status, activity, accumulated_text, tool_calls, None); + } + + /// 带思考过程的回合更新:`reasoning_text` 为"当前累计的思考全文"(前端整段替换)。 + pub(crate) fn emit_with_reasoning( + &self, + status: &'static str, + activity: Option<&'static str>, + accumulated_text: Option, + tool_calls: Option>, + reasoning_text: Option, ) { let status_is_allowed = matches!( status, @@ -94,6 +106,7 @@ impl DirectGameCreatorTurnUpdateEmitter { activity: activity.map(str::to_string), accumulated_text, tool_calls, + reasoning_text, updated_at, }, ); diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 3f73bcdc5..426a713c5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -1014,6 +1014,9 @@ struct GameCreatorDirectTurnUpdateEvent { /// `skip_serializing_if`:字段缺席时前端拿到 `undefined`,行为与改造前一致。 #[serde(skip_serializing_if = "Option::is_none")] tool_calls: Option>, + /// 本回合当前累计的思考过程(流式整段替换);拿不到时字段缺席。 + #[serde(skip_serializing_if = "Option::is_none")] + reasoning_text: Option, updated_at: u64, } diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index d508a077b..633a39331 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -875,6 +875,9 @@ export function App({ const [directCodexTransientReply, setDirectCodexTransientReply] = useState(''); const directCodexTransientReplyRef = useRef(''); + // 直连回合的思考过程(流式):整段替换;回合结束/开始新回合/清空对话时一并清掉。 + const [directCodexTransientReasoning, setDirectCodexTransientReasoning] = + useState(''); const [ directCodexTransientReplyUpdatedAt, setDirectCodexTransientReplyUpdatedAt, @@ -975,6 +978,7 @@ export function App({ setDirectCodexProcessKey(''); setDirectCodexProgressUpdatedAt(null); setDirectCodexTransientReply(''); + setDirectCodexTransientReasoning(''); directCodexTransientReplyRef.current = ''; setDirectCodexTransientReplyUpdatedAt(null); } @@ -1073,6 +1077,7 @@ export function App({ setDirectCodexProgress(DIRECT_CODEX_RECOVERED_TURN_STARTED_DETAIL); setDirectCodexProgressUpdatedAt(Date.now()); setDirectCodexTransientReply(''); + setDirectCodexTransientReasoning(''); directCodexTransientReplyRef.current = ''; setDirectCodexTransientReplyUpdatedAt(null); setProjectSupervisorRuntimeError(''); @@ -1106,6 +1111,7 @@ export function App({ } activeDirectCodexTurnRef.current = null; setDirectCodexTransientReply(''); + setDirectCodexTransientReasoning(''); directCodexTransientReplyRef.current = ''; setDirectCodexTransientReplyUpdatedAt(null); return true; @@ -1245,9 +1251,29 @@ export function App({ const sessionError = result.session.lastError?.summary ?? resultError; setProjectSupervisorRuntimeError(sessionError); if (result.conversation) { - const conversationMessages = planningMessagesToChatMessages( + let conversationMessages = planningMessagesToChatMessages( result.conversation, ); + // 创建项目后的首条需求可能先于规划会话快照到达;不能让后到的空快照 + // 把用户刚发出的内容覆盖掉。 + const initialPrompt = initialSupervisorMessageLatchRef.current.prompt; + if ( + initialPrompt && + !conversationMessages.some( + (message) => + message.role === 'user' && message.text.trim() === initialPrompt, + ) + ) { + conversationMessages = [ + { + role: 'user', + text: initialPrompt, + runtimeOwned: true, + updatedAt: Date.now(), + }, + ...conversationMessages, + ]; + } setConversationVisibleCount(CONVERSATION_INITIAL_VISIBLE_COUNT); setMessages(conversationMessages); savedConversationProjectPathRef.current = localProjectPathRef.current; @@ -1331,7 +1357,7 @@ export function App({ texts.push(entry.text); reasoningByMessageId.set(entry.messageId, texts); } - return view.messages + const messages: ChatMessage[] = view.messages .filter((message) => message.text.trim()) .map((message) => ({ role: message.role === 'user' ? 'user' : 'assistant', @@ -1341,6 +1367,21 @@ export function App({ reasoningText: reasoningByMessageId.get(message.id)?.join('\n\n'), updatedAt: Date.now(), })); + const initialPrompt = initialSupervisorMessageLatchRef.current.prompt; + if ( + initialPrompt && + !messages.some( + (message) => message.role === 'user' && message.text === initialPrompt, + ) + ) { + messages.unshift({ + role: 'user', + text: initialPrompt, + runtimeOwned: true, + updatedAt: Date.now(), + }); + } + return messages; } function applyDesignView(view: DesignView, projectPath: string) { @@ -2150,6 +2191,9 @@ export function App({ })), ); } + if (typeof payload.reasoningText === 'string') { + setDirectCodexTransientReasoning(payload.reasoningText); + } const updatedAt = Number.isFinite(payload.updatedAt) && payload.updatedAt > 0 ? payload.updatedAt @@ -2162,6 +2206,7 @@ export function App({ setDirectCodexProgress(processDetail); setDirectCodexProgressUpdatedAt(updatedAt); setDirectCodexTransientReply(''); + setDirectCodexTransientReasoning(''); setDirectCodexTransientReplyUpdatedAt(null); return; } @@ -6796,6 +6841,7 @@ export function App({ setDirectCodexProcessKey(`${directProjectPath}\u0000${clientTurnId}`); setDirectCodexProgress('正在等待陶泥儿开始'); setDirectCodexTransientReply(''); + setDirectCodexTransientReasoning(''); setDirectCodexProgressUpdatedAt(Date.now()); directCodexTransientReplyRef.current = ''; setDirectCodexTransientReplyUpdatedAt(null); @@ -6929,6 +6975,8 @@ export function App({ setChatAgentBusy(false); setDirectCodexTurnCancelling(false); setDirectCodexProgress(''); + setDirectCodexStatus(null); + setDirectCodexProgressUpdatedAt(null); const activeTurn = activeDirectCodexTurnRef.current; if ( !activeTurn || @@ -12528,6 +12576,8 @@ export function App({ if (projectSupervisorOnly) { return ( :`。 */ function directCodexTurnMessageId(turnId: string, role: 'user' | 'assistant') { @@ -157,8 +160,11 @@ type ProjectSupervisorViewProps = RuntimePanelProps & { toolCalls?: GameCreatorDirectToolCall[]; /** 当前正在跑的回合 id;卡片在 assistant 消息落盘前锚到它。 */ activeTurnId?: string | null; + initialSupervisorMessage?: string; showProfessionalCollaboration?: boolean; transientReply: string; + /** 流式思考过程(direct-codex):拿不到就为空,空则不渲染。 */ + transientReasoning?: string; showDesignReasoning?: boolean; designReasoning?: string; designReasoningEntries?: DesignReasoningEntry[]; @@ -211,7 +217,7 @@ export function ProjectSupervisorView({ onCancelTurn, onCancelQueuedTurn, onRemoveAttachment, - onUploadFiles, + onUploadFiles: _onUploadFiles, queuedTurns = [], composerNotice = '', turnCancelling = false, @@ -223,8 +229,10 @@ export function ProjectSupervisorView({ projectPath, toolCalls = [], activeTurnId = null, + initialSupervisorMessage = '', showProfessionalCollaboration = true, transientReply, + transientReasoning = '', showDesignReasoning = false, designReasoning = '', designReasoningEntries = [], @@ -252,9 +260,21 @@ export function ProjectSupervisorView({ const [expandedProcessKey, setExpandedProcessKey] = useState( null, ); + useEffect(() => { + if (!activeTurnId) { + return; + } + setTurnUsageNow(Date.now()); + const timer = setInterval(() => setTurnUsageNow(Date.now()), 1000); + return () => clearInterval(timer); + }, [activeTurnId]); + useEffect(() => { setExpandedProcessKey(null); }, [directProcessKey]); + useEffect(() => { + setActiveTurnStartedAt(activeTurnId ? Date.now() : 0); + }, [activeTurnId]); const processDetailExpanded = Boolean(directProcessKey) && expandedProcessKey === directProcessKey; const submitLabel = needsUserInput @@ -269,6 +289,9 @@ export function ProjectSupervisorView({ const modelValidateInFlightRef = useRef(false); // 设置浮层:Codex 顶栏只剩状态与齿轮,运行配置 / 审批模式 / 钱包都收进这里。 const [settingsOpen, setSettingsOpen] = useState(false); + // 整轮会话的耗时在回合进行中要每秒刷新:用 tick 驱动的 `now` 计算"现在 - 开始"。 + const [turnUsageNow, setTurnUsageNow] = useState(() => Date.now()); + const [activeTurnStartedAt, setActiveTurnStartedAt] = useState(0); // 语音输入的降级/失败提示:不支持时按钮本身就带提示,这里只承载启动失败与权限类错误。 const [voiceNotice, setVoiceNotice] = useState(''); const [approvalOpen, setApprovalOpen] = useState(false); @@ -316,11 +339,74 @@ export function ProjectSupervisorView({ return 0; } const userId = directCodexTurnMessageId(turnId, 'user'); - return ( + const value = Number( visibleMessages.find((message) => message.messageId === userId) - ?.updatedAt ?? 0 + ?.updatedAt, + ); + if (!Number.isFinite(value) || value <= 0) return 0; + // 旧快照使用 Unix 秒,新消息使用毫秒;统一到毫秒,避免出现数千万分钟。 + return value < 100_000_000_000 ? value * 1000 : value; + }; + /** 本轮会话的结束时刻:工具快照与消息里最晚的那个 updatedAt。 */ + const turnEndedAtFor = (turnId: string) => { + let endedAt = turnToolCallEndedAt( + toolCalls.filter((call) => call.turnId === turnId), + ); + for (const message of visibleMessages) { + const messageTurnId = message.messageId + ? directCodexTurnIdFromAssistantMessageId(message.messageId) + : null; + if (messageTurnId === turnId) { + endedAt = Math.max(endedAt, Number(message.updatedAt) || 0); + } + } + return endedAt < 100_000_000_000 ? endedAt * 1000 : endedAt; + }; + const turnStartedAtFor = (turnId: string) => { + const messageStarted = userMessageUpdatedAtForTurn(turnId); + if (messageStarted) return messageStarted; + const starts = toolCalls + .filter((call) => call.turnId === turnId && Number(call.startedAt) > 0) + .map((call) => Number(call.startedAt)); + return starts.length > 0 ? Math.min(...starts) : 0; + }; + + const clockTimeWithSeconds = (timestamp: number) => { + const date = new Date(timestamp); + const pad = (value: number) => String(value).padStart(2, '0'); + return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; + }; + + /** 整轮会话的结束时间与耗时(进行中时用 tick 驱的 now,所以秒数会实时跳动)。 */ + const renderTurnUsage = (turnId: string) => { + if (!turnId) { + return null; + } + const startedAt = + turnStartedAtFor(turnId) || + (activeTurnId === turnId ? activeTurnStartedAt : 0); + if (!startedAt) { + return null; + } + const running = Boolean(activeTurnId) && turnId === activeTurnId; + const endedAt = running + ? turnUsageNow + : Math.max(turnEndedAtFor(turnId), startedAt); + const duration = formatTurnDuration(endedAt - startedAt); + return ( +

+ {running + ? `本轮进行中 · 用时 ${duration ?? '—'}` + : `本轮结束于 ${endedAt ? clockTimeWithSeconds(endedAt) : '—'} · 耗时 ${duration ?? '0秒'}`} +

); }; + const emptyState = directCodex && visibleMessages.length === 0 && @@ -427,6 +513,19 @@ export function ProjectSupervisorView({ {`显示更早 · 还有 ${hiddenConversationCount} 条对话`} ) : null} + {initialSupervisorMessage.trim() && + !visibleMessages.some( + (message) => + message.role === 'user' && + message.text.trim() === initialSupervisorMessage.trim(), + ) ? ( +
+ +
+ ) : null} {visibleMessages.map((message, index) => { const anchoredToolCalls = message.messageId ? (toolCallsByAnchor.get(message.messageId) ?? []) @@ -434,6 +533,16 @@ export function ProjectSupervisorView({ const anchoredTurnId = message.messageId ? directCodexTurnIdFromAssistantMessageId(message.messageId) : null; + const nextTurnId = + index + 1 < visibleMessages.length && + visibleMessages[index + 1]?.messageId + ? directCodexTurnIdFromAssistantMessageId( + visibleMessages[index + 1]!.messageId!, + ) + : null; + // 这条消息是该回合的最后一条时,在它后面给出整轮会话的结束时间与耗时。 + const isTurnEnd = + Boolean(anchoredTurnId) && nextTurnId !== anchoredTurnId; return ( {anchoredToolCalls.length > 0 ? ( @@ -460,6 +569,9 @@ export function ProjectSupervisorView({ ) : null} + {isTurnEnd && anchoredTurnId + ? renderTurnUsage(anchoredTurnId) + : null} ); })} @@ -471,6 +583,40 @@ export function ProjectSupervisorView({ className="message-tool-call" /> ) : null} + {liveToolCallTurnId && + !visibleMessages.some( + (message) => + message.messageId && + directCodexTurnIdFromAssistantMessageId(message.messageId) === + liveToolCallTurnId, + ) + ? renderTurnUsage(liveToolCallTurnId) + : null} + {directCodex && transientReasoning ? ( +
+ 思考过程 +
{transientReasoning}
+
+ ) : null} + {/* 直连回合的流式正文:恢复为对话区里的普通 assistant 消息(不再放进状态卡片), + 这样"边生成边显示"和"状态卡片只放状态"两件事同时成立。 */} + {directCodex && transientReply ? ( +
+ +
+ ) : null} {showDesignReasoning && designReasoningEntries .filter((entry) => !entry.messageId) @@ -590,6 +736,21 @@ export function ProjectSupervisorView({ ? directStatusTitle(directStatus) : '陶泥儿正在处理'} + {activeTurnId ? ( + + {`已耗时 ${ + formatTurnDuration( + Math.max( + 0, + turnUsageNow - + (turnStartedAtFor(activeTurnId) || + activeTurnStartedAt || + turnUsageNow), + ), + ) ?? '0秒' + }`} + + ) : null} {directProcessDetail ? ( @@ -684,13 +845,6 @@ export function ProjectSupervisorView({ {directCodex ? (
- onUploadFiles?.(files)} - onOpenReferencePicker={() => - composerRef?.current?.openPicker() - } - />
); @@ -235,7 +223,12 @@ function ToolCallRow({ hidden={!expanded} > {detailCommand ? ( -
{detailCommand}
+
+ 输入 +
+              {stripAnsi(detailCommand)}
+            
+
) : null} {changes.length > 0 ? (
    @@ -248,13 +241,27 @@ function ToolCallRow({
) : null} {detailOutput ? ( -
{detailOutput}
+
+ 输出 +
+              {stripAnsi(detailOutput)}
+            
+
) : null}
); } +function stripAnsi(value: string) { + // ANSI CSI / OSC 控制序列:命令输出在终端里可带颜色,聊天卡片不应显示转义码。 + const ansiPattern = new RegExp( + String.raw`[\x1b\x9b]\][0-?]*[ -/]*[@-~]|\x1b\[[0-?]*[ -/]*[@-~]`, + 'g', + ); + return value.replace(ansiPattern, ''); +} + function toolCallChangeKindLabel(kind: string) { if (kind === 'add') { return '新增'; diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index af6ed5d53..a0f35682f 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -2660,6 +2660,15 @@ textarea { animation: project-supervisor-process-pulse 1.2s ease-in-out infinite; } +.project-supervisor-process-elapsed { + margin-left: auto !important; + color: #795548 !important; + font-size: 13px !important; + font-style: normal; + font-weight: 500; + white-space: nowrap; +} + @keyframes project-supervisor-process-pulse { 50% { opacity: 0.42; @@ -11840,6 +11849,17 @@ button.design-workspace-tree__entry:hover, padding: 6px 8px 8px; } +.agent-tool-call-row-section { + display: grid; + gap: 4px; +} + +.agent-tool-call-row-section > small { + color: #8d7668; + font-size: 11px; + font-weight: 600; +} + .agent-tool-call-row-command, .agent-tool-call-row-output { margin: 0; @@ -12027,3 +12047,45 @@ button.design-workspace-tree__entry:hover, > * + * { margin-top: 14px; } + +/* 整轮会话的结束时间与耗时:比消息本身更轻,属于轮次级信息。 */ +.game-workbench-chat + .project-supervisor-surface.is-direct-codex + .project-supervisor-message-list + .message-turn-usage { + margin: 0; + padding: 0 2px; + color: var(--platform-text-soft); + font-size: 12px; + line-height: 1.6; + font-variant-numeric: tabular-nums; +} + +/* 流式思考过程:默认折叠(
),长文本必须换行,不允许出现横向滚动条。 */ +.game-workbench-chat + .project-supervisor-surface.is-direct-codex + .project-supervisor-message-list + details[data-testid='live-reasoning'] { + margin: 0; + color: var(--platform-text-soft); + font-size: 12px; +} + +.game-workbench-chat + .project-supervisor-surface.is-direct-codex + .project-supervisor-message-list + details[data-testid='live-reasoning'] > summary { + cursor: pointer; +} + +.game-workbench-chat + .project-supervisor-surface.is-direct-codex + .project-supervisor-message-list + details[data-testid='live-reasoning'] pre { + margin: 6px 0 0; + white-space: pre-wrap; + overflow-wrap: anywhere; + font-family: inherit; + font-size: 12px; + line-height: 1.6; +}