diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs index 24b3aed74..130c01549 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs @@ -5,6 +5,7 @@ use crate::project::{ }; use crate::{LocalConversationMessageRecord, LocalConversationResult}; use serde_json::Value; +use std::collections::BTreeMap; use std::fs::{self, File}; use std::io::{BufRead, BufReader, Read, Seek, SeekFrom}; use std::path::{Path, PathBuf}; @@ -91,6 +92,10 @@ fn record(item: &Value) -> Result { serde_json::to_string(&serde_json::json!({ "type": DIRECT_PROJECT_HISTORY_RECORD_TYPE, "payload": item, + "recordedAt": std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_millis() as u64) + .unwrap_or(0), })) .map_err(|error| format!("序列化 DirectProject 历史失败:{error}")) } @@ -470,6 +475,13 @@ fn direct_project_message_item(role: &str, content: &str, message_id: Option<&st } pub(crate) fn read_direct_project_history_items_at(root: &Path) -> Result, String> { + Ok(read_direct_project_history_entries_at(root)? + .into_iter() + .map(|(item, _)| item) + .collect()) +} + +fn read_direct_project_history_entries_at(root: &Path) -> Result, String> { let path = history_path(root); if !prepare_game_creator_private_path_for_read(&path, false, "DirectProject 历史")? { return Ok(Vec::new()); @@ -507,7 +519,13 @@ pub(crate) fn read_direct_project_history_items_at(root: &Path) -> Result, limit: usize, -) -> Result<(Vec, bool), String> { - let items = read_direct_project_history_items_at(root)?; +) -> Result<(Vec, bool, BTreeMap), String> { + let items = read_direct_project_history_entries_at(root)?; let end = match before_item_id { Some(item_id) => items .iter() - .position(|item| item.get("id").and_then(Value::as_str) == Some(item_id)) + .position(|(item, _)| item.get("id").and_then(Value::as_str) == Some(item_id)) .ok_or_else(|| format!("DirectProject 历史中不存在 item:{item_id}"))?, None => items.len(), }; let bounded_limit = limit.clamp(1, 200); let start = end.saturating_sub(bounded_limit); - Ok((items[start..end].to_vec(), start > 0)) + let slice = &items[start..end]; + let timestamps = slice + .iter() + .filter_map(|(item, at)| { + let id = item.get("id").and_then(Value::as_str)?; + (*at > 0).then(|| (id.to_string(), *at)) + }) + .collect(); + Ok(( + slice.iter().map(|(item, _)| item.clone()).collect(), + start > 0, + timestamps, + )) } pub(crate) fn read_direct_project_last_item_id_at(root: &Path) -> Result, String> { @@ -546,10 +576,10 @@ pub(crate) fn read_direct_project_chat_history_at( root: &Path, ) -> Result { let path = history_path(root); - let items = read_direct_project_history_items_at(root)?; + let items = read_direct_project_history_entries_at(root)?; let messages = items .into_iter() - .filter_map(|item| { + .filter_map(|(item, recorded_at)| { let role = item.get("role").and_then(Value::as_str)?; if !matches!(role, "user" | "assistant") { return None; @@ -571,7 +601,7 @@ pub(crate) fn read_direct_project_chat_history_at( content, agent_id: None, message_id: item.get("id").and_then(Value::as_str).map(str::to_string), - updated_at: 0, + updated_at: recorded_at, }) }) .collect(); @@ -610,6 +640,40 @@ mod tests { const RESPONSE_ITEM_ROW: &str = r#"{"type":"response_item","payload":{"type":"message","role":"user","id":"codex-item-2","content":[{"type":"input_text","text":"再加一个按钮"}]}}"#; const RESPONSE_ASSISTANT_ROW: &str = r#"{"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"已完成"}]}}"#; + #[test] + fn history_timestamps_survive_reload_and_idempotent_append_without_changing_raw_items() { + let root = init_history_project("history-time"); + let item = json!({ + "type": "message", "role": "user", "id": "sent-message", + "content": [{"type": "input_text", "text": "修改游戏"}], + }); + append_direct_project_user_message_at(root.path(), &item).unwrap(); + let (items, _, timestamps) = + super::read_direct_project_history_items_slice_at(root.path(), None, 20).unwrap(); + assert_eq!(items, vec![item.clone()]); + assert!(timestamps["sent-message"] > 0); + append_direct_project_user_message_at(root.path(), &item).unwrap(); + let (_, _, reloaded) = + super::read_direct_project_history_items_slice_at(root.path(), None, 20).unwrap(); + assert_eq!(timestamps, reloaded); + } + + #[test] + fn old_history_without_envelope_time_stays_unknown() { + let root = init_history_project("history-unknown-time"); + write_history_lines(root.path(), &[RESPONSE_ITEM_ROW]); + let (_, _, timestamps) = + super::read_direct_project_history_items_slice_at(root.path(), None, 20).unwrap(); + assert!(timestamps.is_empty()); + assert_eq!( + read_direct_project_chat_history_at(root.path()) + .unwrap() + .messages[0] + .updated_at, + 0 + ); + } + /// 判据:争用类失败会被"有界退避重试"真的吃掉,最终把条目落一行。 /// /// 注入标记是"让接下来 N 次单次尝试返回争用失败";退避表只补一次重试,所以注入 1 次 diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs index 9f5adf8f9..e3bc497ea 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs @@ -58,6 +58,7 @@ pub(crate) struct DirectThreadConsumeResult { pub(crate) struct DirectThreadHistorySlice { pub(crate) items: Vec, pub(crate) has_more: bool, + pub(crate) item_timestamps: std::collections::BTreeMap, } #[derive(Clone, Debug)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index e95d51aa3..51b826f08 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -5373,12 +5373,16 @@ pub(crate) async fn read_direct_project_history_slice( tauri::async_runtime::spawn_blocking(move || { let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "conversation.read")?; - let (items, has_more) = read_direct_project_history_items_slice_at( + let (items, has_more, item_timestamps) = read_direct_project_history_items_slice_at( root, before_item_id.as_deref(), limit.unwrap_or(20), )?; - Ok(DirectThreadHistorySlice { items, has_more }) + Ok(DirectThreadHistorySlice { + items, + has_more, + item_timestamps, + }) }) .await .map_err(|error| format!("读取 DirectProject 历史切片后台任务失败:{error}"))? diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index dcbfc49a3..f9803ec0c 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -55,7 +55,6 @@ import type { DesignClarificationRequest, DesignEvent, DesignView, - DirectActiveTurnView, DirectTurnCancelView, GameCreatorAgentRuntimeUpdateEvent, GameCreatorChatAgentReply, @@ -137,6 +136,7 @@ import { submitProjectSupervisorRuntimeTask, taskRowsFromManifest, } from './features/agent-runtime'; +import { DIRECT_ACTIVE_TURNS_POLL_INTERVAL_MS } from './features/agent-runtime/directActiveTurns'; import { type DirectCodexTurnAttachment, toDirectCodexTurnAttachments, @@ -242,8 +242,7 @@ import { directThreadHistoryItemsToMessages, type DirectThreadHistorySlice, type DirectThreadSubscriptionBootstrap, - emptyDirectThreadReducerState, - reduceDirectThreadEvents, + isDirectTurnInProgress, } from './features/project-workspace/directThreadEvents'; import type { DirectCodexUserContentPart } from './features/project-workspace/generated'; import { @@ -321,8 +320,6 @@ const DIRECT_CODEX_ANOTHER_TURN_RUNNING_ERROR_MARKER = const DIRECT_CODEX_RECOVERED_TURN_STALLED_MS = 15_000; const DIRECT_CODEX_RECOVERED_TURN_STALLED_NOTICE = '该回合已无响应,可在输入盒点「终止」结束它以继续'; -const DIRECT_CODEX_RECOVERED_TURN_STARTED_DETAIL = - '已恢复正在运行的回合,正在等待陶泥儿的最新进度'; // Platform access tokens are short lived. DirectProject can spend several // minutes in image generation, build and browser validation, so keep the // client-owned native session current while a turn is running. The singleflight @@ -414,9 +411,8 @@ function directCodexActivityDetail( case 'finalizing': return '正在整理结果'; case 'completed': - return '正在提交回复'; case 'failed': - return '正在记录失败原因'; + return ''; default: return '正在处理任务'; } @@ -449,11 +445,8 @@ function directCodexProcessDetail({ activity?: string | null; status: string; }) { - if (status === 'completed') { - return '正在提交回复'; - } - if (status === 'failed') { - return '正在记录失败原因'; + if (status === 'completed' || status === 'failed') { + return ''; } if (status === 'streaming') { return '正在生成回复'; @@ -960,9 +953,14 @@ export function App({ turnId: string; lastSequence: number; receivedDirectUpdate: boolean; + restored?: boolean; + } | null>(null); + const directActiveSnapshotVersionRef = useRef(0); + const directTurnLifecycleRef = useRef<{ + reset: () => void; + loadHistory: (projectPath: string) => Promise; + restore: (projectPath: string) => Promise; } | null>(null); - const directThreadSubscriptionIdRef = useRef(null); - const directThreadReducerStateRef = useRef(emptyDirectThreadReducerState()); const lastDirectCodexActivityRef = useRef(null); /** * 重进会话后从 Rust 恢复出来的回合:只有在恢复后的第一个窗口内一直收不到事件, @@ -1069,6 +1067,7 @@ export function App({ } function resetDirectCodexTurn() { + directActiveSnapshotVersionRef.current += 1; clearRecoveredDirectCodexTurnWatch(); activeDirectCodexTurnRef.current = null; lastDirectCodexActivityRef.current = null; @@ -1128,10 +1127,12 @@ export function App({ * 项目时前端 `activeDirectCodexTurnRef` 是空的——界面既不订阅这一轮的事件,也不显示 * 过程卡,用户再发消息只会被守卫拒绝。这里把后端登记的回合读回来重新接管。 * - * 只读探测,不改后端回合本身;探测失败(老二进制没有这个命令、路径读不到)等于没有 - * 回合,不影响打开项目。 + * 只读探测,不改后端回合本身;探测失败保留当前已知状态,不视为没有活动回合。 */ - async function restoreRunningDirectCodexTurn(projectPath: string) { + async function restoreRunningDirectCodexTurn( + projectPath: string, + reconcile = false, + ) { if (!directCodexProductRuntime || !projectPath) { return; } @@ -1139,50 +1140,87 @@ export function App({ if (!invoke) { return; } - // 本组件已经接管这个项目:`/history` 之类的重复读取不能把 lastSequence 归零。 - if (activeDirectCodexTurnRef.current?.projectPath === projectPath) { + const owner = activeDirectCodexTurnRef.current; + const sequence = owner?.lastSequence; + const scopeVersion = projectScopeVersionRef.current; + if (!reconcile && owner?.projectPath === projectPath) { return; } - let activeView: DirectActiveTurnView | null = null; + const readVersion = ++directActiveSnapshotVersionRef.current; + let turns: GameCreatorDirectActiveTurn[]; try { - activeView = await invoke( - 'read_direct_codex_active_turn', - { projectPath }, + turns = await invoke( + 'list_game_creator_direct_active_turns', ); } catch { + // 读取失败不等于没有活动回合。 return; } - const clientTurnId = activeView?.clientTurnId?.trim(); - if (!clientTurnId) { - return; - } + if (!Array.isArray(turns)) return; if ( localProjectPathRef.current !== projectPath || - activeDirectCodexTurnRef.current?.projectPath === projectPath + planningV2ActiveRef.current || + designAgentLaneRef.current || + projectScopeVersionRef.current !== scopeVersion || + directActiveSnapshotVersionRef.current !== readVersion || + activeDirectCodexTurnRef.current !== owner || + owner?.lastSequence !== sequence ) { return; } - activeDirectCodexTurnRef.current = { - projectPath, - turnId: clientTurnId, - // 与下面发起回合的两处赋值同形(`lastSequence: -1`):Rust 侧 emitter 的 sequence - // 从 1 开始,所以恢复后到达的第一批事件不会被 sequence 过滤丢掉。 - lastSequence: -1, - receivedDirectUpdate: false, - }; + const activeView = turns.find( + (turn) => + projectPathsMatchForInvalidation(turn.projectPath, projectPath) && + isDirectTurnInProgress(turn.status), + ); + if (!activeView || !isDirectTurnInProgress(activeView.status)) { + // 本地刚发送但尚未进入 Rust 的请求不能被空快照取消。 + if (owner && !owner.restored && owner.lastSequence < 0) return; + resetDirectCodexTurn(); + setChatAgentBusy(false); + if (owner && reconcile) { + void loadProjectConversation(projectPath, false, 'replace'); + } + return; + } + const matchingOwner = owner?.turnId === activeView.turnId ? owner : null; + if (owner && !matchingOwner) { + if (!owner.restored && owner.lastSequence < 0) return; + resetDirectCodexTurn(); + } + if (!matchingOwner) { + activeDirectCodexTurnRef.current = { + projectPath, + turnId: activeView.turnId, + lastSequence: -1, + receivedDirectUpdate: false, + restored: true, + }; + setDirectCodexProcessKey(`${projectPath}\u0000${activeView.turnId}`); + setProjectSupervisorRuntimeError(''); + watchRecoveredDirectCodexTurn(projectPath, activeView.turnId); + } setChatAgentBusy(true); - setDirectCodexStatus('running'); - setDirectCodexProcessKey(`${projectPath}\u0000${clientTurnId}`); - setDirectCodexProgress(DIRECT_CODEX_RECOVERED_TURN_STARTED_DETAIL); - setDirectCodexProgressUpdatedAt(Date.now()); - setDirectCodexTransientReply(''); - setDirectCodexTransientReasoning(''); - directCodexTransientReplyRef.current = ''; - setDirectCodexTransientReplyUpdatedAt(null); - setProjectSupervisorRuntimeError(''); - watchRecoveredDirectCodexTurn(projectPath, clientTurnId); + // 活动快照不携带正文;已有实时进度不能被同序号的通用描述覆盖。 + if ( + !matchingOwner?.receivedDirectUpdate || + activeView.sequence > matchingOwner.lastSequence + ) { + setDirectCodexStatus(activeView.status); + setDirectCodexProgress( + directCodexActivityDetail(activeView.activity, activeView.status), + ); + setDirectCodexProgressUpdatedAt(activeView.updatedAt); + } } + directTurnLifecycleRef.current = { + reset: resetDirectCodexTurn, + loadHistory: (projectPath) => + loadProjectConversation(projectPath, false, 'replace'), + restore: (projectPath) => restoreRunningDirectCodexTurn(projectPath, true), + }; + // 回合流(文本段 + 工具按**出现顺序**交替):实时增量与回读历史共用一份状态, // 渲染顺序只由条目的 `seq` 决定,界面不再按文本长度 / 标点 / 时间窗猜切点。 const [directTurnStream, setDirectTurnStream] = useState( @@ -2306,15 +2344,10 @@ export function App({ ? payload.updatedAt : Date.now(); const processDetail = directCodexProcessDetail(payload); - if (payload.status === 'failed') { - activeDirectCodexTurnRef.current = null; - lastDirectCodexActivityRef.current = null; - setDirectCodexStatus(payload.status); - setDirectCodexProgress(processDetail); - setDirectCodexProgressUpdatedAt(updatedAt); - setDirectCodexTransientReply(''); - setDirectCodexTransientReasoning(''); - setDirectCodexTransientReplyUpdatedAt(null); + if (payload.status === 'failed' || payload.status === 'completed') { + directTurnLifecycleRef.current?.reset(); + setChatAgentBusy(false); + void directTurnLifecycleRef.current?.loadHistory(payload.projectPath); return; } setDirectCodexStatus(payload.status); @@ -2374,123 +2407,77 @@ export function App({ useEffect(() => { const projectPath = localProject?.projectPath ?? null; const directInvoke = resolveTauriInvoke(); - if (!directCodexProductRuntime || !projectPath || !directInvoke) { - directThreadSubscriptionIdRef.current = null; - directThreadReducerStateRef.current = emptyDirectThreadReducerState(); - return; - } + if (!directCodexProductRuntime || !projectPath || !directInvoke) return; let disposed = false; let cleanup: (() => void) | null = null; + let subscriptionId: string | null = null; + let consuming = false; + let consumeAgain = false; - const applyReducerState = ( - state: ReturnType, - ) => { - if (disposed) return; - directThreadReducerStateRef.current = state; - const running = - state.status === 'accepted' || - state.status === 'running' || - state.status === 'streaming' || - state.status === 'finalizing'; - setChatAgentBusy(running); - setDirectCodexStatus(state.status); - setDirectCodexProgress(state.progress); - setDirectCodexProgressUpdatedAt(Date.now()); - if (state.accumulatedText) { - setDirectCodexTransientReply(state.accumulatedText); - directCodexTransientReplyRef.current = state.accumulatedText; - setDirectCodexTransientReplyUpdatedAt(Date.now()); - } else { - setDirectCodexTransientReply(''); - directCodexTransientReplyRef.current = ''; - setDirectCodexTransientReplyUpdatedAt(null); - } + // Provider 原始事件只用于通知;运行状态始终取 client 回合快照和 Direct 事件。 + const refreshActive = () => { + if (!disposed) void directTurnLifecycleRef.current?.restore(projectPath); }; - const bootstrap = async () => { - try { - const activeTurns = await directInvoke( - 'list_game_creator_direct_active_turns', - ); - const activeTurn = activeTurns.find((turn) => - projectPathsMatchForInvalidation(turn.projectPath, projectPath), - ); - if (activeTurn && !activeDirectCodexTurnRef.current) { - activeDirectCodexTurnRef.current = { - projectPath, - turnId: activeTurn.turnId, - lastSequence: -1, - receivedDirectUpdate: true, - }; - setDirectCodexProcessKey(`${projectPath}\\u0000${activeTurn.turnId}`); - setChatAgentBusy(true); - setDirectCodexStatus('running'); - setDirectCodexProgress('正在处理'); - setDirectCodexProgressUpdatedAt(Date.now()); - } - } catch { - // 订阅 bootstrap 仍是恢复事实源;快照失败不能被改写成“没有在跑”。 - } const result = await directInvoke( 'subscribe_direct_project_thread', { projectPath }, ); if (disposed) return; - directThreadSubscriptionIdRef.current = result.subscriptionId; - const state = reduceDirectThreadEvents( - result.events, - emptyDirectThreadReducerState(), - ); - applyReducerState(state); - if (state.turnId && !activeDirectCodexTurnRef.current) { - activeDirectCodexTurnRef.current = { - projectPath, - turnId: state.turnId, - // Thread Manager 的 seq 与回合展示事件的 sequence 是两个独立序列。 - lastSequence: -1, - receivedDirectUpdate: true, - }; - setDirectCodexProcessKey(`${projectPath}\u0000${state.turnId}`); - } + subscriptionId = result.subscriptionId; + refreshActive(); }; - const consume = async () => { - const subscriptionId = directThreadSubscriptionIdRef.current; if (!subscriptionId || disposed) return; + if (consuming) { + consumeAgain = true; + return; + } + consuming = true; try { - const result = await directInvoke( - 'consume_direct_project_thread', - { subscriptionId }, - ); - if (disposed) return; - const state = reduceDirectThreadEvents( - result.events, - directThreadReducerStateRef.current, - ); - applyReducerState(state); + do { + consumeAgain = false; + const result = await directInvoke( + 'consume_direct_project_thread', + { subscriptionId }, + ); + if (disposed) return; + if ( + result.events.some( + (event) => + event.type === 'turn.started' || + event.type === 'turn.completed', + ) + ) { + refreshActive(); + } + if ( + result.events.some((event) => event.type === 'turn.completed') && + !activeDirectCodexTurnRef.current?.receivedDirectUpdate + ) { + // 重进时若未接到 Direct 结束事件,原始 item 的落盘通知仍可补齐最终回复。 + void directTurnLifecycleRef.current?.loadHistory(projectPath); + } + } while (consumeAgain && !disposed); } catch (error) { - if (String(error).includes('SUBSCRIPTION_EXPIRED')) { - directThreadSubscriptionIdRef.current = null; + if (!disposed && String(error).includes('SUBSCRIPTION_EXPIRED')) { + subscriptionId = null; try { await bootstrap(); } catch { - // A later project activation or notification will retry bootstrap. + /* 活动快照轮询仍然有效。 */ } } + } finally { + consuming = false; } }; - const setup = async () => { try { const unlisten = await subscribeTauriEvent<{ subscriptionId: string }>( 'game-creator-direct-thread-notify', (event) => { - if ( - event.payload.subscriptionId === - directThreadSubscriptionIdRef.current - ) { - void consume(); - } + if (event.payload.subscriptionId === subscriptionId) void consume(); }, ); if (disposed) { @@ -2499,15 +2486,21 @@ export function App({ } cleanup = unlisten; await bootstrap(); + await consume(); } catch { - // The history view remains usable when the runtime subscription is unavailable. + // 历史仍可使用;订阅失败不伪造忙碌态。 } }; + refreshActive(); + const timer = window.setInterval( + refreshActive, + DIRECT_ACTIVE_TURNS_POLL_INTERVAL_MS, + ); void setup(); return () => { disposed = true; cleanup?.(); - directThreadSubscriptionIdRef.current = null; + window.clearInterval(timer); }; }, [directCodexProductRuntime, localProject?.projectPath]); @@ -4118,7 +4111,10 @@ export function App({ return { path: nextProjectPath, agentId: null, - messages: directThreadHistoryItemsToMessages(slice.items), + messages: directThreadHistoryItemsToMessages( + slice.items, + slice.itemTimestamps, + ), } satisfies LocalConversationResult; }); })() @@ -7211,9 +7207,6 @@ export function App({ setMessages((current) => appendDirectAssistantMessage(current, reply), ); - setDirectCodexStatus('finalizing'); - setDirectCodexProgress('正在同步项目文件'); - setDirectCodexProgressUpdatedAt(Date.now()); await refreshDirectProjectManifest(directProjectPath); } } catch (error) { @@ -7291,21 +7284,19 @@ export function App({ await refreshManifest(directProjectPath); } } finally { - setChatAgentBusy(false); - setDirectCodexTurnCancelling(false); - setDirectCodexProgress(''); - setDirectCodexStatus(null); - setDirectCodexProgressUpdatedAt(null); const activeTurn = activeDirectCodexTurnRef.current; if ( - !activeTurn || - (activeTurn.projectPath === directProjectPath && - activeTurn.turnId === clientTurnId) + localProjectPathRef.current === directProjectPath && + (!activeTurn || + (activeTurn.projectPath === directProjectPath && + activeTurn.turnId === clientTurnId)) ) { + setChatAgentBusy(false); + setDirectCodexTurnCancelling(false); resetDirectCodexTurn(); + // 只有当前回合的收尾才能释放发送队列,不能覆盖后来启动的回合。 + dispatchNextQueuedChatTurn(); } - // 队列:本回合确实结束后,按 FIFO 自动发出下一条(不丢、不乱序)。 - dispatchNextQueuedChatTurn(); } } return; @@ -12492,18 +12483,19 @@ export function App({ if (localProjectPathRef.current !== projectPath) { return; } - const older = directThreadHistoryItemsToMessages(slice.items).map( - (message) => ({ - role: - message.role === 'user' - ? ('user' as const) - : ('assistant' as const), - text: message.content, - runtimeOwned: true, - messageId: message.messageId, - updatedAt: message.updatedAt, - }), - ); + const older = directThreadHistoryItemsToMessages( + slice.items, + slice.itemTimestamps, + ).map((message) => ({ + role: + message.role === 'user' + ? ('user' as const) + : ('assistant' as const), + text: message.content, + runtimeOwned: true, + messageId: message.messageId, + updatedAt: message.updatedAt, + })); setMessages((current) => [...older, ...current]); setConversationVisibleCount((current) => current + older.length); setDirectHistoryHasMore(slice.hasMore); 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 ed6a0f30f..df18ec4e2 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 @@ -58,7 +58,9 @@ import { } from './DesignAgentSurface'; import { buildDirectTurnPresentations, + directMessageTimestamp, type DirectTurnPresentation, + splitDirectTurnContent, } from './directTurnPresentation'; import { PlanGddSurface } from './GddApprovalCard'; import { @@ -75,7 +77,10 @@ import { } from './ResourceReferenceInput'; import type { ChatComposerDraft, ChatReference } from './resourceReferences'; import { ToolCallGroup } from './ToolCallGroup'; -import { formatTurnDuration } from './toolCallGroupPresentation'; +import { + formatClockTime, + formatTurnDuration, +} from './toolCallGroupPresentation'; /** 相邻的工具流项合并成一个块;中间夹了文本段就另起一块。 */ type TurnStreamToolRun = { @@ -397,8 +402,7 @@ export function ProjectSupervisorView({ setApprovalOpen(false); setApprovalNotice(''); }, [settingsOpen]); - const runBusy = - runtimePanelProps.controlBusy || Boolean(directProcessDetail) || submitting; + const runBusy = runtimePanelProps.controlBusy || submitting; const directTurns = directCodex ? buildDirectTurnPresentations({ messages: conversationMessages, @@ -433,26 +437,39 @@ export function ProjectSupervisorView({

); }; - const renderMessage = (message: ChatMessage, index: number) => ( -
- - {showDesignReasoning && message.reasoningText ? ( -
- 思考过程 -
{message.reasoningText}
-
- ) : null} -
- ); + const renderMessage = (message: ChatMessage, index: number) => { + const sentAt = + message.role === 'user' ? directMessageTimestamp(message.updatedAt) : 0; + return ( +
+ + {sentAt > 0 ? ( + + ) : null} + {showDesignReasoning && message.reasoningText ? ( +
+ 思考过程 +
{message.reasoningText}
+
+ ) : null} +
+ ); + }; const submitButton = (