From 8f7017f6c05df3379f697c5bc889990916be046b Mon Sep 17 00:00:00 2001 From: suzmii Date: Wed, 2 Sep 2026 16:49:42 +0800 Subject: [PATCH] =?UTF-8?q?Direct=20=E8=BF=87=E7=A8=8B=E5=8D=A1=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E6=8C=89=E5=9B=9E=E5=90=88=E9=98=B6=E6=AE=B5=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E9=A9=B1=E5=8A=A8=E5=B9=B6=E7=A7=BB=E9=99=A4=E5=90=88?= =?UTF-8?q?=E6=88=90=E6=89=93=E5=AD=97=E6=9C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DirectProject observer 只把真实回复增量标记 streaming,工具中间文本与活动一律 running - 删除最终回复的合成打字机回放,改由真实事件驱动流式展示 - App 事件投影新增回合状态与 processKey,小字统一补充正在前缀 - 过程卡标题只由回合状态决定,展开状态在同一回合内保持 - 失败与接受态文案改为当前阶段描述,旧直接活动词映射移除 - AppSurface 回归覆盖接受态、展开保持、流式标题与失败态断言 - 决策记录与 Direct 审计账本同步新的状态口径 --- .../src-tauri/src/agent/direct_runtime.rs | 114 ++++++------ apps/ai-game-creator-shell/src/App.tsx | 167 +++++++++++++----- .../ProjectSupervisorView.tsx | 62 +++++-- .../appSurface/project-development.suite.ts | 82 ++++++--- .../shared-memory/decision-log.md | 10 ++ ...术方案】Direct回合行为审计账本-2026-08-31.md | 2 +- 6 files changed, 298 insertions(+), 139 deletions(-) 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 a87d40991..a1df967a3 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 @@ -3696,6 +3696,20 @@ fn project_direct_codex_accumulated_text( project_direct_codex_visible_text(accumulated_text) } +/// Resolve the UI lifecycle status for one DirectProject observation. Only a +/// real agent-message delta is `streaming`; plan, reasoning, tool output, and +/// item activity remain `running` because they describe work rather than the +/// user-visible reply body. +fn direct_codex_observation_status( + observation: &DirectCodexTurnObservation, + stream_enabled: bool, +) -> &'static str { + match observation { + DirectCodexTurnObservation::AccumulatedText(_) if stream_enabled => "streaming", + _ => "running", + } +} + pub(crate) fn build_direct_codex_system_prompt(root: &Path) -> Result { let controlled_web_search = load_game_creator_app_config().map(|config| config.llm.web_search_enabled)?; @@ -3897,42 +3911,32 @@ async fn run_direct_game_creator_turn_inner( .map_err(|error| { DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error) })?; - let live_streamed_text = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); let reply = if let Some(emitter) = turn_emitter { let emitter = emitter.clone(); - let live_streamed_text_for_observer = std::sync::Arc::clone(&live_streamed_text); - let mut has_streamed = false; - let mut latest_accumulated_text = None; - let mut observer = move |observation: DirectCodexTurnObservation| match observation { - DirectCodexTurnObservation::AccumulatedText(accumulated_text) => { - let visible_text = - project_direct_codex_accumulated_text(stream_enabled, &accumulated_text); - if visible_text.is_none() { - return; + let mut observer = move |observation: DirectCodexTurnObservation| { + let status = direct_codex_observation_status(&observation, stream_enabled); + match observation { + DirectCodexTurnObservation::AccumulatedText(accumulated_text) => { + let visible_text = + project_direct_codex_accumulated_text(stream_enabled, &accumulated_text); + if visible_text.is_none() { + return; + } + emitter.emit(status, None, visible_text); } - has_streamed = true; - live_streamed_text_for_observer.store(true, std::sync::atomic::Ordering::Relaxed); - latest_accumulated_text = visible_text.clone(); - emitter.emit("streaming", None, visible_text); - } - DirectCodexTurnObservation::IntermediateText(intermediate_text) => { - let visible_text = if stream_enabled { - project_direct_codex_visible_text(&intermediate_text) - } else { - None - }; - if let Some(visible_text) = visible_text { - has_streamed = true; - latest_accumulated_text = Some(visible_text.clone()); - emitter.emit("streaming", None, Some(visible_text)); + DirectCodexTurnObservation::IntermediateText(intermediate_text) => { + let visible_text = if stream_enabled { + project_direct_codex_visible_text(&intermediate_text) + } else { + None + }; + if let Some(visible_text) = visible_text { + emitter.emit(status, None, Some(visible_text)); + } + } + DirectCodexTurnObservation::Activity(activity) => { + emitter.emit(status, Some(activity), None); } - } - DirectCodexTurnObservation::Activity(activity) => { - emitter.emit( - if has_streamed { "streaming" } else { "running" }, - Some(activity), - latest_accumulated_text.clone(), - ); } }; direct_game_creator_codex_chat_at_with_optional_observer( @@ -3961,31 +3965,6 @@ async fn run_direct_game_creator_turn_inner( ) })?; if let Some(emitter) = turn_emitter { - // The Router/Responses upstream frequently buffers the whole agent - // reply and only delivers it with the terminal item, so real - // agentMessage deltas never arrive while tools run. When no live - // delta reached the UI, replay the final reply as a bounded typewriter - // stream so the chat shows progressive text instead of one jump from - // activity status to the completed message. - if stream_enabled - && !live_streamed_text.load(std::sync::atomic::Ordering::Relaxed) - && !visible_reply.is_empty() - { - const TYPEWRITER_CHUNK_CHARS: usize = 24; - const TYPEWRITER_CHUNK_DELAY_MS: u64 = 40; - let text = visible_reply.as_str(); - let mut offset = 0usize; - while offset < text.len() { - let mut end = (offset + TYPEWRITER_CHUNK_CHARS).min(text.len()); - while end < text.len() && !text.is_char_boundary(end) { - end += 1; - } - emitter.emit("streaming", None, Some(text[..end].trim_end().to_string())); - offset = end; - tokio::time::sleep(std::time::Duration::from_millis(TYPEWRITER_CHUNK_DELAY_MS)) - .await; - } - } emitter.emit( "finalizing", Some("response-finalization"), @@ -4701,6 +4680,27 @@ mod tests { ); } + #[test] + fn direct_observation_status_separates_reply_stream_from_work_activity() { + let accumulated = DirectCodexTurnObservation::AccumulatedText("阶段性回复".to_string()); + let intermediate = DirectCodexTurnObservation::IntermediateText("正在调用工具".to_string()); + let activity = DirectCodexTurnObservation::Activity("command-exec"); + + assert_eq!( + direct_codex_observation_status(&accumulated, true), + "streaming" + ); + assert_eq!( + direct_codex_observation_status(&accumulated, false), + "running" + ); + assert_eq!( + direct_codex_observation_status(&intermediate, true), + "running" + ); + assert_eq!(direct_codex_observation_status(&activity, true), "running"); + } + #[test] fn direct_creation_type_is_a_bounded_structured_hint_not_user_prompt_text() { for (creation_type, label) in [("game", "做游戏"), ("art", "做素材"), ("doc", "做方案")] diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 584a85994..2c63e38cd 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -254,34 +254,6 @@ const DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX = 'direct-codex:'; const DIRECT_CODEX_TURN_ALREADY_RUNNING_ERROR_PREFIX = 'direct-codex-turn-already-running:'; -function directCodexActivityText(activity: string | null | undefined) { - switch (activity) { - case 'request-accepted': - return '已接收需求'; - case 'preparing': - return '正在准备'; - case 'file-read': - return '读取文件'; - case 'file-write': - return '写入文件'; - case 'game-verify': - return '验证游戏'; - case 'command-exec': - return '执行命令'; - case 'controlled-tool': - return '执行工具'; - case 'web-search': - return '搜索资料'; - case 'context-compaction': - return '整理上下文'; - case 'response-finalization': - return '整理回复'; - case 'none': - default: - return '陶泥儿正在处理'; - } -} - const DIRECT_CODEX_TURN_UPDATE_STATUSES = new Set([ 'accepted', 'running', @@ -291,6 +263,94 @@ const DIRECT_CODEX_TURN_UPDATE_STATUSES = new Set([ 'failed', ]); +function ensureDirectProcessPrefix(text: string) { + const trimmed = text.trim(); + if (!trimmed) { + return ''; + } + if (trimmed.startsWith('正在')) { + return trimmed; + } + if (/^(?:执行|调用|读取|写入|验证|搜索|整理|修改|生成)/u.test(trimmed)) { + return `正在${trimmed}`; + } + return `正在处理:${trimmed}`; +} + +function directCodexActivityDetail( + activity: string | null | undefined, + status: string | null | undefined, +) { + switch (activity) { + case 'request-accepted': + return '正在等待陶泥儿开始'; + case 'preparing': + return '正在理解需求'; + case 'file-read': + return '正在读取文件'; + case 'file-write': + return status === 'finalizing' ? '正在同步项目文件' : '正在写入文件'; + case 'game-verify': + return '正在验证游戏'; + case 'command-exec': + return '正在执行命令'; + case 'controlled-tool': + return '正在调用工具'; + case 'web-search': + return '正在搜索资料'; + case 'context-compaction': + return '正在整理上下文'; + case 'response-finalization': + return '正在整理回复'; + case 'none': + default: + switch (status) { + case 'accepted': + return '正在等待陶泥儿开始'; + case 'finalizing': + return '正在整理结果'; + case 'completed': + return '正在提交回复'; + case 'failed': + return '正在记录失败原因'; + default: + return '正在处理任务'; + } + } +} + +function directCodexProcessDetail({ + accumulatedText, + activity, + status, +}: { + accumulatedText?: string | null; + activity?: string | null; + status: string; +}) { + if (status === 'completed') { + return '正在提交回复'; + } + if (status === 'failed') { + return '正在记录失败原因'; + } + if (status === 'streaming') { + const text = accumulatedText?.trim(); + if (text) { + return `正在生成回复:${text}`; + } + return directCodexActivityDetail(activity, status); + } + if (status === 'finalizing') { + return directCodexActivityDetail(activity, status); + } + const text = accumulatedText?.trim(); + if (text) { + return ensureDirectProcessPrefix(text); + } + return directCodexActivityDetail(activity, status); +} + function directCodexConversationMessageId( turnId: string, role: ChatMessage['role'], @@ -541,6 +601,10 @@ export function App({ ); const [chatAgentBusy, setChatAgentBusy] = useState(false); const [directCodexProgress, setDirectCodexProgress] = useState(''); + const [directCodexStatus, setDirectCodexStatus] = useState< + GameCreatorDirectTurnUpdateEvent['status'] | null + >(null); + const [directCodexProcessKey, setDirectCodexProcessKey] = useState(''); const [directCodexProgressUpdatedAt, setDirectCodexProgressUpdatedAt] = useState(null); const [directCodexTransientReply, setDirectCodexTransientReply] = @@ -582,6 +646,8 @@ export function App({ function resetDirectCodexTurn() { activeDirectCodexTurnRef.current = null; setDirectCodexProgress(''); + setDirectCodexStatus(null); + setDirectCodexProcessKey(''); setDirectCodexProgressUpdatedAt(null); setDirectCodexTransientReply(''); setDirectCodexTransientReplyUpdatedAt(null); @@ -1233,25 +1299,21 @@ export function App({ Number.isFinite(payload.updatedAt) && payload.updatedAt > 0 ? payload.updatedAt : Date.now(); + const processDetail = directCodexProcessDetail(payload); if (payload.status === 'failed') { activeDirectCodexTurnRef.current = null; - setDirectCodexProgress('处理失败,正在同步错误'); + setDirectCodexStatus(payload.status); + setDirectCodexProgress(processDetail); setDirectCodexProgressUpdatedAt(updatedAt); - setDirectCodexTransientReply(''); + setDirectCodexTransientReply(processDetail); setDirectCodexTransientReplyUpdatedAt(null); return; } - if (payload.status === 'completed') { - setDirectCodexProgress('回复已生成,正在提交'); - setDirectCodexProgressUpdatedAt(updatedAt); - } else if (payload.activity != null) { - setDirectCodexProgress(directCodexActivityText(payload.activity)); - setDirectCodexProgressUpdatedAt(updatedAt); - } - if (typeof payload.accumulatedText === 'string') { - setDirectCodexTransientReply(payload.accumulatedText); - setDirectCodexTransientReplyUpdatedAt(updatedAt); - } + setDirectCodexStatus(payload.status); + setDirectCodexProgress(processDetail); + setDirectCodexProgressUpdatedAt(updatedAt); + setDirectCodexTransientReply(processDetail); + setDirectCodexTransientReplyUpdatedAt(updatedAt); }, ) .then((unlisten) => { @@ -1293,7 +1355,10 @@ export function App({ ) { return; } - setDirectCodexProgress(event.payload.message); + const progressDetail = ensureDirectProcessPrefix(event.payload.message); + setDirectCodexStatus('running'); + setDirectCodexProgress(progressDetail); + setDirectCodexTransientReply(progressDetail); setDirectCodexProgressUpdatedAt(Date.now()); return; } @@ -5469,9 +5534,11 @@ export function App({ receivedDirectUpdate: false, }; setChatAgentBusy(true); - setDirectCodexProgress('已发送消息,正在等待陶泥儿回复'); + setDirectCodexStatus('accepted'); + setDirectCodexProcessKey(`${directProjectPath}\u0000${clientTurnId}`); + setDirectCodexProgress('正在等待陶泥儿开始'); + setDirectCodexTransientReply('正在等待陶泥儿开始'); setDirectCodexProgressUpdatedAt(Date.now()); - setDirectCodexTransientReply(''); setDirectCodexTransientReplyUpdatedAt(null); setProjectSupervisorRuntimeError(''); try { @@ -5540,7 +5607,9 @@ export function App({ setMessages((current) => appendDirectAssistantMessage(current, reply), ); - setDirectCodexProgress('正在刷新项目状态'); + setDirectCodexStatus('finalizing'); + setDirectCodexProgress('正在同步项目文件'); + setDirectCodexTransientReply('正在同步项目文件'); setDirectCodexProgressUpdatedAt(Date.now()); await refreshDirectProjectManifest(directProjectPath); } @@ -5589,6 +5658,8 @@ export function App({ } if (localProjectPathRef.current === directProjectPath) { clearDirectCodexTransientReply(directProjectPath, clientTurnId); + setDirectCodexStatus('failed'); + setDirectCodexProgress('正在记录失败原因'); setProjectSupervisorRuntimeError(visibleMessage); setMessages((current) => appendDirectAssistantMessage(current, visibleMessage), @@ -10900,7 +10971,11 @@ export function App({ ; +function directStatusTitle(status: string | null | undefined) { + switch (status) { + case 'accepted': + return '需求已接收'; + case 'running': + return '任务执行中'; + case 'streaming': + return '回复生成中'; + case 'finalizing': + return '结果整理中'; + case 'completed': + return '回复已生成'; + case 'failed': + return '处理失败'; + default: + return '任务执行中'; + } +} + type ProjectSupervisorViewProps = RuntimePanelProps & { chatInput: string; directCodex?: boolean; - directActivity?: string; + directStatus?: GameCreatorDirectTurnUpdateStatus | null; + directProcessDetail?: string; + directProcessKey?: string; hiddenConversationCount: number; messagesRef: RefObject; needsUserInput: boolean; @@ -75,7 +97,9 @@ type ProjectSupervisorViewProps = RuntimePanelProps & { export function ProjectSupervisorView({ chatInput, directCodex = false, - directActivity = '', + directStatus = null, + directProcessDetail = '', + directProcessKey = '', hiddenConversationCount, messagesRef, needsUserInput, @@ -104,10 +128,14 @@ export function ProjectSupervisorView({ onMakeGameFromApprovedGdd, ...runtimePanelProps }: ProjectSupervisorViewProps) { - const [processDetailExpanded, setProcessDetailExpanded] = useState(false); + const [expandedProcessKey, setExpandedProcessKey] = useState( + null, + ); useEffect(() => { - setProcessDetailExpanded(false); - }, [directActivity, transientReply]); + setExpandedProcessKey(null); + }, [directProcessKey]); + const processDetailExpanded = + Boolean(directProcessKey) && expandedProcessKey === directProcessKey; const submitLabel = needsUserInput ? '等待回答' @@ -169,9 +197,13 @@ export function ProjectSupervisorView({ >
- {transientReply ? ( + {(directCodex ? directProcessDetail : transientReply) ? (

- {transientReply} + {directCodex ? directProcessDetail : transientReply}

- {transientReply.includes('\n') || - transientReply.length > 96 ? ( + {(directCodex + ? directProcessDetail + : transientReply + ).includes('\n') || + (directCodex ? directProcessDetail : transientReply).length > + 96 ? (