diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index f923269b7..3f524a8f1 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -293,56 +293,6 @@ function directCodexConversationMessageId( return `${DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX}${turnId}:${role}`; } -function directCodexConversationTurnId( - message: ChatMessage, - role: ChatMessage['role'], -) { - if (message.role !== role) { - return null; - } - const messageId = message.messageId?.trim() ?? ''; - const roleSuffix = `:${role}`; - if ( - !messageId.startsWith(DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX) || - !messageId.endsWith(roleSuffix) - ) { - return null; - } - const turnId = messageId.slice( - DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX.length, - -roleSuffix.length, - ); - return /^[a-z0-9][a-z0-9-]{5,159}$/iu.test(turnId) ? turnId : null; -} - -export function unansweredDirectCodexConversationTurn(messages: ChatMessage[]) { - for (let index = messages.length - 1; index >= 0; index -= 1) { - const message = messages[index]; - if (!message) { - continue; - } - const turnId = directCodexConversationTurnId(message, 'user'); - if (!turnId) { - continue; - } - const assistantMessageId = directCodexConversationMessageId( - turnId, - 'assistant', - ); - if ( - messages.some( - (candidate) => - candidate.role === 'assistant' && - candidate.messageId?.trim() === assistantMessageId, - ) - ) { - continue; - } - return { prompt: message.text, turnId }; - } - return null; -} - export function isDirectCodexTurnAlreadyRunningError(error: unknown) { const message = error instanceof Error ? error.message : String(error); return message @@ -540,6 +490,12 @@ export function App({ useState(null); const [directCodexTransientReply, setDirectCodexTransientReply] = useState(''); + const directCodexTransientReplyRef = useRef(''); + const directCodexInterruptedPartialRef = useRef<{ + projectPath: string; + text: string; + messageId: string; + } | null>(null); const [ directCodexTransientReplyUpdatedAt, setDirectCodexTransientReplyUpdatedAt, @@ -579,6 +535,7 @@ export function App({ setDirectCodexProgress(''); setDirectCodexProgressUpdatedAt(null); setDirectCodexTransientReply(''); + directCodexTransientReplyRef.current = ''; setDirectCodexTransientReplyUpdatedAt(null); } @@ -592,6 +549,7 @@ export function App({ } activeDirectCodexTurnRef.current = null; setDirectCodexTransientReply(''); + directCodexTransientReplyRef.current = ''; setDirectCodexTransientReplyUpdatedAt(null); return true; } @@ -1232,8 +1190,6 @@ export function App({ activeDirectCodexTurnRef.current = null; setDirectCodexProgress('处理失败,正在同步错误'); setDirectCodexProgressUpdatedAt(updatedAt); - setDirectCodexTransientReply(''); - setDirectCodexTransientReplyUpdatedAt(null); return; } if (payload.status === 'completed') { @@ -1245,6 +1201,7 @@ export function App({ } if (typeof payload.accumulatedText === 'string') { setDirectCodexTransientReply(payload.accumulatedText); + directCodexTransientReplyRef.current = payload.accumulatedText; setDirectCodexTransientReplyUpdatedAt(updatedAt); } }, @@ -1840,9 +1797,8 @@ export function App({ ); if (localProjectPathRef.current === nextProjectPath) { // Any history read started before this terminal append may hold - // a user-only snapshot. Invalidate it before releasing the - // in-memory claim so that stale hydration cannot replay the - // same billable Direct turn. + // A stale history snapshot may still be missing this terminal + // append. Invalidate it before releasing the in-memory claim. projectSupervisorHistoryLoadVersionRef.current += 1; } recoveredDirectCodexTurnClaimsRef.current.delete(claimKey); @@ -2698,9 +2654,6 @@ export function App({ projectConversation.messages, supervisorConversation?.messages ?? [], ); - const unansweredDirectTurn = directCodexProductRuntime - ? unansweredDirectCodexConversationTurn(conversationMessages) - : null; if ( conversationContainsProjectSupervisorResponseStream( supervisorConversation?.messages ?? [], @@ -2752,24 +2705,6 @@ export function App({ }); return nextConversationMessages; }); - if (unansweredDirectTurn) { - const claimKey = `${nextProjectPath}\u0000${unansweredDirectTurn.turnId}`; - void Promise.resolve().then(() => { - if ( - projectSupervisorHistoryLoadVersionRef.current !== loadVersion || - localProjectPathRef.current !== nextProjectPath || - activeDirectCodexTurnRef.current || - recoveredDirectCodexTurnClaimsRef.current.has(claimKey) - ) { - return; - } - recoveredDirectCodexTurnClaimsRef.current.add(claimKey); - void executeChatAgentReply({ - prompt: unansweredDirectTurn.prompt, - clientTurnId: unansweredDirectTurn.turnId, - }); - }); - } } catch (error) { if ( projectSupervisorHistoryLoadVersionRef.current !== loadVersion || @@ -5459,6 +5394,20 @@ export function App({ }, }, ); + const persistDirectPartialMessage = (messageId: string, text: string) => + directInvoke( + 'append_local_conversation_message', + { + projectPath: directProjectPath, + agentId: null, + messageId, + message: { + role: 'assistant', + content: `${text.trim()}\nunexpected interrupt happened here`, + agentId: null, + }, + }, + ); const recoveredDirectCodexTurnClaimKey = `${directProjectPath}\u0000${clientTurnId}`; recoveredDirectCodexTurnClaimsRef.current.add( recoveredDirectCodexTurnClaimKey, @@ -5473,9 +5422,21 @@ export function App({ setDirectCodexProgress('已发送消息,正在等待陶泥儿回复'); setDirectCodexProgressUpdatedAt(Date.now()); setDirectCodexTransientReply(''); + directCodexTransientReplyRef.current = ''; setDirectCodexTransientReplyUpdatedAt(null); setProjectSupervisorRuntimeError(''); try { + const interruptedPartial = directCodexInterruptedPartialRef.current; + if ( + interruptedPartial?.projectPath === directProjectPath && + interruptedPartial.text.trim() + ) { + await persistDirectPartialMessage( + interruptedPartial.messageId, + interruptedPartial.text, + ); + directCodexInterruptedPartialRef.current = null; + } // Persist the original user intent and stable turn identity before // Codex can start any billable or externally visible work. The // regular conversation writer may race this call, but messageId @@ -5526,10 +5487,10 @@ export function App({ } } // Rust persists a successful Direct reply before returning Ok. The - // browser append is redundant, so the hydrated-turn claim can be + // browser append is redundant, so the in-memory turn claim can be // released without reopening the Provider side effect. Invalidate - // any user-only history snapshot captured before Rust committed the - // terminal reply first. + // any history snapshot captured before Rust committed the terminal + // reply first. if (localProjectPathRef.current === directProjectPath) { projectSupervisorHistoryLoadVersionRef.current += 1; } @@ -5565,6 +5526,23 @@ export function App({ '陶泥儿智能创作', true, ); + const partial = directCodexTransientReplyRef.current.trim(); + if (partial) { + const partialMessageId = + globalThis.crypto?.randomUUID?.() || + `direct-partial-${Date.now().toString(36)}`; + directCodexInterruptedPartialRef.current = { + projectPath: directProjectPath, + text: partial, + messageId: partialMessageId, + }; + try { + await persistDirectPartialMessage(partialMessageId, partial); + } catch { + // The next user send retries this idempotent append before + // constructing the replay prompt. + } + } try { await persistDirectAssistantMessage(visibleMessage); if (localProjectPathRef.current === directProjectPath) {