diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 28e26d67d..73a4c472c 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, - DirectTurnCancelView, GameCreatorAgentRuntimeUpdateEvent, GameCreatorChatAgentReply, GameCreatorLlmConfigStatus, @@ -147,7 +146,6 @@ import { type WorkspaceLauncherProps, writeRecentWorkspace, } from './features/app-shell/model'; -import { uploadLocalFilesAsAttachments } from './features/app-shell/useHomeProjectCreation'; import { WorkspaceLauncherShell } from './features/app-shell/WorkspaceLauncher'; import { agentConversationReadDraftsFromManifest, @@ -221,38 +219,15 @@ import { isMissingProjectFileError, parseAgentRunTrace, } from './features/project-workspace/agentRunTrace'; -import { - chatQueueFullNotice, - createQueuedChatTurn, - dequeueChatTurn, - enqueueChatTurn, - isChatTurnQueueFull, - type QueuedChatTurn, - removeQueuedChatTurn, -} from './features/project-workspace/chatComposerQueue'; import { DeveloperProjectPanels } from './features/project-workspace/DeveloperProjectPanels'; import { DeveloperRuntimePanels } from './features/project-workspace/DeveloperRuntimePanels'; -import { - type DirectHistoryAnchorGate, - directHistoryAnchorGateToWaitFor, - reuseOrOpenDirectHistoryAnchorGate, -} from './features/project-workspace/directHistoryAnchorGate'; +import { directHistoryAnchorGateToWaitFor } from './features/project-workspace/directHistoryAnchorGate'; import { readDirectHistoryPages } from './features/project-workspace/directHistoryPaging'; +import { selectDirectChatEntries } from './features/project-workspace/directThreadChat'; import { - applyDirectThreadConsumeResult, - type DirectThreadChatState, - emptyDirectThreadChatState, - mergeDirectHistoryItems, - resolveDirectThreadBootstrap, - selectDirectChatEntries, -} from './features/project-workspace/directThreadChat'; -import { - type DirectThreadConsumeResult, type DirectThreadHistorySlice, type DirectThreadItem, - type DirectThreadSubscriptionBootstrap, } from './features/project-workspace/directThreadEvents'; -import type { DirectCodexUserContentPart } from './features/project-workspace/generated'; import { appendMemoryContent, memoryScopeLabel, @@ -318,10 +293,12 @@ import { type ProjectAgentResultSummary, type ProjectAgentRuntimeSummary, } from './view/project-development'; +import { DirectProjectChatView } from './view/project-development/chat/DirectProjectChatView'; +import type { DirectCodexUserContentPart } from './view/project-development/chat/generated/DirectCodexUserContentPart'; +import { useDirectProjectChatController } from './view/project-development/chat/useDirectProjectChatController'; import type { ProjectManifestSnapshotMetadata } from './view/project-development/projectResourceLiveUpdateModel'; const initialSupervisorMessageClaimsByPage = new WeakMap>(); -const DIRECT_CODEX_PRODUCT_RUNTIME = true; const DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX = 'direct-codex:'; const DIRECT_CODEX_TURN_ALREADY_RUNNING_ERROR_PREFIX = 'direct-codex-turn-already-running:'; @@ -577,11 +554,9 @@ export function App({ const designAgentEventSubscriptionResolveRef = useRef<(() => void) | null>( null, ); - // 做方案入口独立成链:立项策划需要委派、澄清 pending 与 GDD 审批,这些只存在于 - // Supervisor Runtime;direct-codex 是单回合「生成→试玩→修」循环,没有对应机制。 - // 因此策划入口不走产品默认的 direct-codex,做游戏与做素材保持 master 的新默认。 - const directCodexProductRuntime = - DIRECT_CODEX_PRODUCT_RUNTIME && + // 项目开发工作台的普通项目固定使用 DirectProject;Supervisor 调试、Design Agent + // 和 Planning V2 是各自独立的入口,不通过 DirectProject 的兼容分支承载。 + const directProjectMode = projectSupervisorOnly && !supervisorChatOnly && !planningStartMode && @@ -713,84 +688,18 @@ export function App({ : '', ); const [chatReferences, setChatReferences] = useState([]); - /** - * 输入盒待发送附件(direct-codex 回合附件):上传成功后先生成 chip,随下次提交一起 - * 交给 `chat_with_game_creator_direct_codex` 的 `attachments`。附件只存在于前端状态, - * 提交后即清空——后端协议不变。 - */ - const [chatAttachments, setChatAttachments] = useState< - DirectCodexTurnAttachment[] - >([]); - const [chatAttachmentNotice, setChatAttachmentNotice] = useState(''); - /** 回合运行中再次发送的消息:FIFO 本地队列,当前回合结束后依次发出。 */ - const [chatTurnQueue, setChatTurnQueue] = useState([]); - const chatTurnQueueRef = useRef([]); - chatTurnQueueRef.current = chatTurnQueue; - const [chatComposerNotice, setChatComposerNotice] = useState(''); - const [directCodexTurnCancelling, setDirectCodexTurnCancelling] = - useState(false); - const queuedChatTurnSequenceRef = useRef(0); const [chatContent, setChatContent] = useState( [], ); const chatComposerRef = useRef(null); - /** - * 切项目即清空只属于上一个项目的输入盒状态:待发附件的 `localPath` 是**项目相对**的, - * 队列也属于刚结束的那条对话;留着会把 A 项目的附件路径带进 B 项目的下一个回合。 - */ useEffect(() => { - setChatAttachments([]); setChatContent([]); - setChatAttachmentNotice(''); - setChatComposerNotice(''); - setChatTurnQueue([]); - chatTurnQueueRef.current = []; }, [localProject?.projectPath]); const [chatAgentBusy, setChatAgentBusy] = useState(false); - const directCodexConversationTurnSequenceRef = useRef(0); - /** - * DirectProject 聊天真相源:历史切片与运行态事件归并成同一份条目。 - * - * 运行态只来自 Thread Manager(`subscribe` 的 bootstrap + `notify` 唤醒的 `consume`); - * 前端不再订阅 DirectRuntime 的回合进度事件,也不再各存一份回合流 / 工具卡片。 - */ - const [directThreadChat, setDirectThreadChat] = - useState(() => emptyDirectThreadChatState()); - /** 最新回合是否在跑:历史里留下的半截回合一律按已结束渲染。 */ - const directTurnRunning = directThreadChat.turnRunning; - /** 界面忙碌判定:本地正在跑这次 invoke,或订阅告诉还有一条回合没结束。 */ - const supervisorChatBusy = - chatAgentBusy || (directCodexProductRuntime && directTurnRunning); - const chatAgentBusyRef = useRef(chatAgentBusy); - const directTurnRunningRef = useRef(directTurnRunning); - chatAgentBusyRef.current = chatAgentBusy; - directTurnRunningRef.current = directTurnRunning; - const previousDirectTurnRunningRef = useRef(directTurnRunning); - const directTurnCompletionPendingRef = useRef(false); const [projectSupervisorSessionId, setProjectSupervisorSessionId] = useState< string | null >(null); - function createDirectCodexConversationTurnId() { - directCodexConversationTurnSequenceRef.current += 1; - let randomId = ''; - try { - randomId = globalThis.crypto?.randomUUID?.().trim() ?? ''; - } catch { - // The timestamp and in-page sequence remain unique enough for a local - // conversation append when WebView crypto is unavailable. - } - if (/^[a-z0-9][a-z0-9-]{5,159}$/iu.test(randomId)) { - return randomId; - } - return `${Date.now().toString(36)}-${directCodexConversationTurnSequenceRef.current.toString(36)}`; - } - - /** 换项目 / 清空对话:聊天条目回到初始态(运行态与已加载历史一起清掉)。 */ - function resetDirectThreadChat() { - setDirectThreadChat(emptyDirectThreadChatState()); - } - const [projectSupervisorRuntime, setProjectSupervisorRuntime] = useState(null); const [projectSupervisorResponseStream, setProjectSupervisorResponseStream] = @@ -1487,12 +1396,6 @@ export function App({ const [conversationVisibleCount, setConversationVisibleCount] = useState( CONVERSATION_INITIAL_VISIBLE_COUNT, ); - const [directHistoryHasMore, setDirectHistoryHasMore] = useState(false); - const directHistoryOldestItemIdRef = useRef(null); - const directHistoryLoadingRef = useRef(false); - const directHistoryAnchorGateRef = useRef( - null, - ); const [pendingCommand, setPendingCommand] = useState( null, ); @@ -1597,6 +1500,60 @@ export function App({ [], ); + const directChatController = useDirectProjectChatController({ + enabled: directProjectMode, + projectPath: localProject?.projectPath ?? null, + localProjectPathRef, + chatInput, + chatContent, + setChatInput, + setChatReferences, + setChatContent, + chatAgentBusy, + setChatAgentBusy, + setRuntimeError: setProjectSupervisorRuntimeError, + setWorkspaceStatus, + refreshManifest, + onStartTurn: startDirectCodexConversationTurn, + onReloadHistory: () => { + const nextProjectPath = requireChatProjectForUserAction(); + if (nextProjectPath) { + void loadProjectConversation(nextProjectPath, false, 'replace'); + } + }, + directCodexConversationMessageId, + directCodexUserItemFromContent, + hasMeaningfulContent: hasMeaningfulDirectCodexContent, + withDirectCodexSessionRefresh, + isTurnBusy: chatAgentBusy, + }); + const { + attachments: chatAttachments, + attachmentNotice: chatAttachmentNotice, + queuedTurns: chatTurnQueue, + composerNotice: chatComposerNotice, + setComposerNotice: setChatComposerNotice, + turnCancelling: directCodexTurnCancelling, + setTurnCancelling: setDirectCodexTurnCancelling, + directThreadChat, + directTurnRunning, + historyHasMore: directHistoryHasMore, + historyAnchorGateRef: directHistoryAnchorGateRef, + loadEarlierHistory: loadEarlierDirectHistory, + hydrateHistory: hydrateDirectHistory, + createTurnId: createDirectCodexConversationTurnId, + attachmentContentParts, + cancelQueuedTurn, + dispatchNextQueuedTurn, + uploadFiles: handleChatComposerUploadFiles, + removeAttachment: removeChatComposerAttachment, + cancelTurn: handleCancelDirectCodexTurn, + reset: resetDirectThreadChat, + submit: submitDirectProjectChat, + } = directChatController; + const supervisorChatBusy = + chatAgentBusy || (directProjectMode && directTurnRunning); + useEffect(() => { const refreshStates = manifestRefreshStatesRef.current; manifestRefreshMountedRef.current = true; @@ -1771,7 +1728,7 @@ export function App({ } initialProjectOpenedRef.current = true; if (projectSupervisorOnly) { - if (directCodexProductRuntime) { + if (directProjectMode) { if (!isAbsoluteProjectPath(initialProjectPath)) { setWorkspaceStatus('请提供工作区绝对路径'); return; @@ -1823,7 +1780,7 @@ export function App({ }, [initialProjectPath, projectSupervisorOnly]); useEffect(() => { - if (!directCodexProductRuntime || !supervisorChatBusy) { + if (!directProjectMode || !supervisorChatBusy) { return; } const timer = window.setInterval(() => { @@ -1833,130 +1790,10 @@ export function App({ }); }, DIRECT_CODEX_SESSION_KEEPALIVE_MS); return () => window.clearInterval(timer); - }, [supervisorChatBusy, directCodexProductRuntime]); - - /** - * DirectProject 聊天真相源。 - * - * `subscribe` 的 bootstrap 就是此刻要处理的事件(游标已在队尾),此后只由 - * `game-creator-direct-thread-notify` 唤醒 `consume`;前端不再订阅 DirectRuntime 的回合 - * 进度事件,也不轮询"有没有回合在跑"。 - */ - useEffect(() => { - if (!directCodexProductRuntime) { - return; - } - const projectPath = localProject?.projectPath ?? null; - const directInvoke = resolveTauriInvoke(); - // 闸门先建(或复用首屏读取已经开好的那道):同一轮渲染里首屏会等订阅回执里的锚点。 - const anchorGate = reuseOrOpenDirectHistoryAnchorGate( - directHistoryAnchorGateRef.current, - projectPath ?? '', - ); - directHistoryAnchorGateRef.current = anchorGate; - if (!projectPath || !directInvoke || !canSubscribeTauriEvents()) { - // 订阅不可用:首屏退化成"取文件尾",不阻塞加载。 - anchorGate.settle(null); - return; - } - let disposed = false; - let cleanup: (() => void) | null = null; - let subscriptionId: string | null = null; - let consuming = false; - let consumeAgain = false; - // 通知先于 subscribe 回执到达时,前端还不知道自己的 subscriptionId,没法立刻 consume。 - // 记一笔欠账,拿到回执后立刻补一次,事件就不会卡在队伍里等下一次通知。 - let notifyBeforeSubscription = false; - - const bootstrap = async () => { - const result = await directInvoke( - 'subscribe_direct_project_thread', - { projectPath }, - ); - if (disposed) { - anchorGate.settle(null); - return; - } - subscriptionId = result.subscriptionId; - // 首屏边界:回执里这一刻的最后一条已完成条目(含该条)。 - anchorGate.settle(result.lastCompletedItemId ?? null); - setDirectThreadChat((state) => - resolveDirectThreadBootstrap(state, result), - ); - if (notifyBeforeSubscription) { - notifyBeforeSubscription = false; - void consume(); - } - }; - const consume = async () => { - if (!subscriptionId || disposed) return; - if (consuming) { - consumeAgain = true; - return; - } - consuming = true; - try { - do { - consumeAgain = false; - const result = await directInvoke( - 'consume_direct_project_thread', - { subscriptionId }, - ); - if (disposed) return; - setDirectThreadChat((state) => - applyDirectThreadConsumeResult(state, result), - ); - } while (consumeAgain && !disposed); - } catch (error) { - // 订阅被别人顶掉时重订一次;通知允许丢,下一次通知会再唤醒。 - if (!disposed && String(error).includes('SUBSCRIPTION_EXPIRED')) { - subscriptionId = null; - try { - await bootstrap(); - } catch { - /* 让下一次通知再试。 */ - } - } - } finally { - consuming = false; - } - }; - const setup = async () => { - try { - const unlisten = await subscribeTauriEvent<{ subscriptionId: string }>( - 'game-creator-direct-thread-notify', - (event) => { - if (event.payload.subscriptionId === subscriptionId) { - void consume(); - return; - } - if (!subscriptionId) notifyBeforeSubscription = true; - }, - ); - if (disposed) { - unlisten(); - return; - } - cleanup = unlisten; - await bootstrap(); - } catch { - // 历史仍可使用;订阅失败不伪造忙碌态,锚点缺失时首屏按文件尾取尾屏。 - anchorGate.settle(null); - } - }; - // 换项目就是换一份聊天:先回到初始态,再订阅新线程。 - setDirectThreadChat(emptyDirectThreadChatState()); - void setup(); - return () => { - disposed = true; - cleanup?.(); - // 首屏可能还在等这道闸门(例如切项目打断了订阅),不能让它永远等下去。 - anchorGate.settle(null); - }; - }, [directCodexProductRuntime, localProject?.projectPath]); + }, [supervisorChatBusy, directProjectMode]); useEffect(() => { - if (projectSupervisorOnly && !directCodexProductRuntime) { + if (projectSupervisorOnly && !directProjectMode) { return; } if (!canSubscribeTauriEvents()) { @@ -1970,7 +1807,7 @@ export function App({ if (event.payload.projectPath !== localProjectPathRef.current) { return; } - if (directCodexProductRuntime) { + if (directProjectMode) { // DirectProject 的进度已经从线程事件流进聊天条目,这条通用进度事件不再参与。 return; } @@ -2001,15 +1838,11 @@ export function App({ disposed = true; cleanup?.(); }; - }, [directCodexProductRuntime, projectSupervisorOnly]); + }, [directProjectMode, projectSupervisorOnly]); useEffect(() => { const invoke = resolveTauriInvoke(); - if ( - !canSubscribeTauriEvents() || - directCodexProductRuntime || - planningV2Active - ) { + if (!canSubscribeTauriEvents() || directProjectMode || planningV2Active) { return; } let cleanup: (() => void) | null = null; @@ -2110,7 +1943,7 @@ export function App({ // neither necessary nor safe. // eslint-disable-next-line react-hooks/exhaustive-deps }, [ - directCodexProductRuntime, + directProjectMode, planningV2Active, refreshManifest, updateProjectSupervisorResponseStream, @@ -2291,7 +2124,7 @@ export function App({ projectSupervisorExpectedRunId ?? projectSupervisorRuntime?.runId ?? null; if ( !invoke || - directCodexProductRuntime || + directProjectMode || planningV2Active || !nextProjectPath || !sessionId || @@ -2387,7 +2220,7 @@ export function App({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [ localProject?.projectPath, - directCodexProductRuntime, + directProjectMode, planningV2Active, projectSupervisorSessionId, projectSupervisorRuntime?.phase, @@ -2402,7 +2235,7 @@ export function App({ const supervisorRunId = projectSupervisorRuntime?.runId ?? null; if ( !projectSupervisorOnly || - directCodexProductRuntime || + directProjectMode || planningV2Active || !invoke || !nextProjectPath || @@ -2483,7 +2316,7 @@ export function App({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [ localProject?.projectPath, - directCodexProductRuntime, + directProjectMode, planningV2Active, projectSupervisorOnly, projectSupervisorRuntime?.phase, @@ -2494,10 +2327,7 @@ export function App({ useLayoutEffect(() => { if ( (!supervisorChatOnly && - !( - projectSupervisorOnly && - (directCodexProductRuntime || planningV2Active) - )) || + !(projectSupervisorOnly && (directProjectMode || planningV2Active))) || !supervisorChatShouldFollowLatestRef.current ) { return; @@ -2512,7 +2342,7 @@ export function App({ projectSupervisorRuntime?.updatedAt, projectSupervisorRuntimeError, directThreadChat, - directCodexProductRuntime, + directProjectMode, planningV2Active, projectSupervisorOnly, supervisorChatOnly, @@ -2540,7 +2370,7 @@ export function App({ } // DirectProject history is written by Rust from raw app-server items. // The browser only renders that projection and must not append chat rows. - if (projectSupervisorOnly && directCodexProductRuntime) { + if (projectSupervisorOnly && directProjectMode) { // `messages` is only an optimistic UI projection in Direct mode; it is // intentionally not proof of durability. Rust owns the raw response // history, so this effect must not route these rows through the generic @@ -2695,7 +2525,7 @@ export function App({ conversationWriteVersion, pendingUiConfirmation, projectSupervisorOnly, - directCodexProductRuntime, + directProjectMode, ]); function appendLocalPermissionLog( @@ -3515,20 +3345,20 @@ export function App({ ); return; } - const resumeError = directCodexProductRuntime + const resumeError = directProjectMode ? '' : await resumeProjectSupervisorRuntimeTasksIfNeeded( invoke, nextProjectPath, ); - if (directCodexProductRuntime) { + if (directProjectMode) { projectSupervisorRuntimeResumeProjectPathRef.current = nextProjectPath; agentRuntimeResumeProjectPathRef.current = nextProjectPath; } // Direct Codex owns its app-server conversation identity. The project // workbench only hydrates the project conversation and must not inspect // or create a legacy Supervisor Session while opening the project. - const sessionId = directCodexProductRuntime + const sessionId = directProjectMode ? null : await readProjectSupervisorActiveSession(invoke, nextProjectPath); let runtimeError = ''; @@ -3542,7 +3372,7 @@ export function App({ // 同一道闸门并 settle 它。同一道闸门只锚定一次:同一订阅下再读一次(例如重开同一个项目) // 没有新回执可等,退回"按当前文件尾取尾屏",与 `/history` 手动重读一致。 let directHistoryThroughItemId: string | null = null; - if (directCodexProductRuntime && mode !== 'replace') { + if (directProjectMode && mode !== 'replace') { const anchorGate = directHistoryAnchorGateToWaitFor( directHistoryAnchorGateRef.current, nextProjectPath, @@ -3553,7 +3383,7 @@ export function App({ anchorGate.consumed = true; } } - const projectConversation = directCodexProductRuntime + const projectConversation = directProjectMode ? (() => { // 首屏铺的就是这份视图的基线,"新回合"比较没有意义:判据退化为"这一页得有能渲染 // 的条目",所以基线取空,免得拿上一份对话的残留状态去比。首屏失败仍按原有语义 @@ -3655,14 +3485,12 @@ export function App({ setProjectSupervisorResponseStream(null); } setProjectSupervisorRuntimeError(runtimeError || resumeError); - if (directCodexProductRuntime) { - setDirectHistoryHasMore(loadedDirectHistoryHasMore); - directHistoryOldestItemIdRef.current = loadedDirectHistoryFirstItemId; - if (loadedDirectHistoryItems.length > 0) { - const items = loadedDirectHistoryItems; - // 首屏历史进聊天 reducer;`messages` 在 DirectProject 下只剩运行期本地消息。 - setDirectThreadChat((state) => mergeDirectHistoryItems(state, items)); - } + if (directProjectMode) { + hydrateDirectHistory({ + items: loadedDirectHistoryItems, + hasMore: loadedDirectHistoryHasMore, + firstItemId: loadedDirectHistoryFirstItemId, + }); } setMessages((current) => { // replace 分支同样不能丢掉尚未落盘的运行时消息(初始需求)。 @@ -3862,7 +3690,7 @@ export function App({ // own project conversation is hydrated above; the old run trace remains // available only in the explicit legacy diagnostic surface. void loadProjectConversation(openedProject.projectPath); - if (!directCodexProductRuntime) { + if (!directProjectMode) { void refreshAgentRunTrace(openedProject.projectPath); } } catch (error) { @@ -6495,7 +6323,7 @@ export function App({ } // Product default: send the conversation directly to Codex app-server. // The legacy Supervisor/harness path remains below for rollback and tests. - if (directCodexProductRuntime) { + if (directProjectMode) { const directInvoke = resolveTauriInvoke(); // Capture the project snapshot before any asynchronous policy/session work. // `resolveChatProjectPath` only returns a path and TypeScript cannot infer @@ -6733,10 +6561,9 @@ export function App({ if (localProjectPathRef.current === directProjectPath) { setChatAgentBusy(false); setDirectCodexTurnCancelling(false); - chatAgentBusyRef.current = false; // 本地 invoke 正常收尾仍可直接推进队列;恢复场景则由 turn.completed effect 推进。 // 只有当前回合的收尾才能释放发送队列,不能覆盖后来启动的回合。 - dispatchNextQueuedChatTurn(); + dispatchNextQueuedTurn(); } } } @@ -6918,7 +6745,7 @@ export function App({ useEffect(() => { const latch = initialSupervisorMessageLatchRef.current; if ( - (!directCodexProductRuntime && !planningStartMode) || + (!directProjectMode && !planningStartMode) || !latch.prompt || !localProject ) { @@ -6937,7 +6764,7 @@ export function App({ return; } supervisorChatShouldFollowLatestRef.current = true; - const directConversationTurnId = directCodexProductRuntime + const directConversationTurnId = directProjectMode ? createDirectCodexConversationTurnId() : undefined; setMessages((current) => [ @@ -6973,9 +6800,11 @@ export function App({ attachments: latch.attachments, ...(initialUserItem ? { userItem: initialUserItem } : {}), }); + // 初始消息 latch 只由入口状态驱动;controller 内部函数保持稳定语义。 + // eslint-disable-next-line react-hooks/exhaustive-deps }, [ chatAgentBusy, - directCodexProductRuntime, + directProjectMode, initialCreationType, initialSupervisorMessage, localProject, @@ -10107,7 +9936,7 @@ export function App({ setLimitedCommandStatus('正在运行'); try { - const smoke = directCodexProductRuntime + const smoke = directProjectMode ? null : await invoke('run_limited_local_command', { projectPath: nextProjectPath, @@ -10127,7 +9956,7 @@ export function App({ 'preview.start', ]); void refreshManifest(nextProjectPath); - if (!directCodexProductRuntime) { + if (!directProjectMode) { void refreshAgentRunTrace(nextProjectPath); } if (announceToChat) { @@ -11359,7 +11188,7 @@ export function App({ agentRuntimeResumeProjectPathRef.current = null; return; } - if (directCodexProductRuntime) { + if (directProjectMode) { agentRuntimeResumeProjectPathRef.current = nextProjectPath; setAgentRuntimeById({}); return; @@ -11666,7 +11495,7 @@ export function App({ const nextProjectPath = localProject?.projectPath ?? null; if ( !projectSupervisorOnly || - directCodexProductRuntime || + directProjectMode || !invoke || !nextProjectPath || professionalResultCandidates.length === 0 @@ -11748,7 +11577,7 @@ export function App({ // The semantic candidate key replaces the freshly allocated candidates array. // eslint-disable-next-line react-hooks/exhaustive-deps }, [ - directCodexProductRuntime, + directProjectMode, localProject?.projectPath, professionalResultCandidateKey, projectSupervisorOnly, @@ -11879,8 +11708,7 @@ export function App({ messages.length - visibleMessages.length, ); const hasEarlierConversationMessages = - hiddenConversationCount > 0 || - (directCodexProductRuntime && directHistoryHasMore); + hiddenConversationCount > 0 || (directProjectMode && directHistoryHasMore); const projectSupervisorTransientReply = projectSupervisorResponseStream?.accumulatedText.trim() ?? ''; const projectSupervisorNeedsUserInput = agentRuntimeNeedsUserInput( @@ -11917,55 +11745,8 @@ export function App({ : null; async function showEarlierConversationMessages() { - if (directCodexProductRuntime && directHistoryHasMore) { - const invoke = resolveTauriInvoke(); - const projectPath = localProject?.projectPath; - if (invoke && projectPath && !directHistoryLoadingRef.current) { - directHistoryLoadingRef.current = true; - try { - // 一次点击连拉,直到出现新回合或取不动为止:一屏全是工具卡片 / 思考文本时, - // 单发一页会让用户点了"显示更早"却看不到任何变化。 - const pages = await readDirectHistoryPages({ - existingEntries: selectDirectChatEntries(directThreadChat), - beforeItemId: directHistoryOldestItemIdRef.current, - readSlice: (beforeItemId) => - invoke( - 'read_direct_project_history_slice', - beforeItemId - ? { - projectPath, - beforeItemId, - limit: CONVERSATION_VISIBLE_STEP, - } - : { projectPath, limit: CONVERSATION_VISIBLE_STEP }, - ), - }); - if (localProjectPathRef.current !== projectPath) { - return; - } - // 连拉到的页一次性进聊天 reducer:与运行态同形、同身份,重复读取只补不重。 - setDirectThreadChat((state) => - mergeDirectHistoryItems(state, pages.items), - ); - // 首页或中途读取失败时保留旧值:失败页没有可靠的 hasMore,不能因为 - // 一次瞬时 IO 抖动把「显示更早」按钮永久收掉。 - if (!pages.error) { - setDirectHistoryHasMore(pages.hasMore); - } - directHistoryOldestItemIdRef.current = - pages.firstItemId ?? directHistoryOldestItemIdRef.current; - if (pages.error) { - // 已经取到的页照常进视图,失败只走下面同一个报错出口。 - throw pages.error; - } - } catch (error) { - setWorkspaceStatus( - `读取更早的对话历史失败:${error instanceof Error ? error.message : String(error)}`, - ); - } finally { - directHistoryLoadingRef.current = false; - } - } + if (directProjectMode && directHistoryHasMore) { + await loadEarlierDirectHistory(); return; } setConversationVisibleCount((current) => @@ -12057,227 +11838,22 @@ export function App({ }); } - /** - * 输入盒上传本地文件:复用首页建项目那条 `upload_local_asset` 链路把文件写进项目, - * 再以**项目相对路径**生成回合附件(绝对路径会被 Rust 侧附件规则判为失败)。 - */ - async function handleChatComposerUploadFiles(files: readonly File[]) { - const invoke = resolveTauriInvoke(); - const nextProjectPath = resolveChatProjectPath(localProject); - if (!invoke || !nextProjectPath) { - setChatAttachmentNotice('需要先打开本地项目,才能上传文件'); - return; - } - const remaining = MAX_CHAT_COMPOSER_ATTACHMENTS - chatAttachments.length; - const accepted = files.slice(0, Math.max(remaining, 0)); - if (accepted.length === 0) { - setChatAttachmentNotice( - `最多同时携带 ${MAX_CHAT_COMPOSER_ATTACHMENTS} 个附件,请先移除已有附件`, - ); - return; - } - setChatAttachmentNotice('正在上传文件'); - try { - const imported = await uploadLocalFilesAsAttachments( - invoke, - nextProjectPath, - accepted, - ); - const attachments = toDirectCodexTurnAttachments(imported); - if (localProjectPathRef.current !== nextProjectPath) { - return; - } - setChatAttachments((current) => - [...current, ...attachments].slice(0, MAX_CHAT_COMPOSER_ATTACHMENTS), - ); - const failed = attachments.filter( - (attachment) => attachment.status === 'failed', - ); - setChatAttachmentNotice( - failed.length > 0 - ? `${failed.length} 个文件未能上传:${failed[0]?.name ?? ''}` - : `已上传 ${attachments.length} 个文件,将在下次发送时作为本轮附件`, - ); - void refreshManifest(nextProjectPath); - } catch (error) { - if (localProjectPathRef.current === nextProjectPath) { - setChatAttachmentNotice( - error instanceof Error ? error.message : String(error), - ); - } - } - } - - function removeChatComposerAttachment(index: number) { - setChatAttachments((current) => - current.filter((_, currentIndex) => currentIndex !== index), - ); - } - - function attachmentContentParts( - attachments: readonly DirectCodexTurnAttachment[], - ): DirectCodexUserContentPart[] { - return attachments.map((attachment) => ({ - type: 'agc_attachment_reference' as const, - name: attachment.name, - mediaType: attachment.mediaType, - size: attachment.size ?? 0, - localPath: attachment.localPath ?? '', - status: - attachment.status ?? (attachment.localPath ? 'imported' : 'failed'), - })); - } - - /** 回合运行中再次发送:进本地 FIFO 队列;队列满时拒绝并保留草稿,不静默丢消息。 */ - function enqueueChatTurnForRunningTurn(input: { - clientTurnId: string; - userItem: ReturnType; - }): boolean { - if (isChatTurnQueueFull(chatTurnQueueRef.current)) { - setChatComposerNotice(chatQueueFullNotice()); - return false; - } - queuedChatTurnSequenceRef.current += 1; - const turn = createQueuedChatTurn({ - id: `queued-chat-turn-${Date.now()}-${queuedChatTurnSequenceRef.current}`, - clientTurnId: input.clientTurnId, - userItem: input.userItem, - createdAt: Date.now(), - }); - const nextQueue = enqueueChatTurn(chatTurnQueueRef.current, turn); - chatTurnQueueRef.current = nextQueue; - setChatTurnQueue(nextQueue); - setChatComposerNotice('已加入发送队列,当前回合结束后自动发送'); - return true; - } - - function cancelQueuedChatTurn(id: string) { - const nextQueue = removeQueuedChatTurn(chatTurnQueueRef.current, id); - chatTurnQueueRef.current = nextQueue; - setChatTurnQueue(nextQueue); - if (nextQueue.length === 0) { - setChatComposerNotice(''); - } - } - - /** 队首出队并立即发出:只在 DirectProject 收到 `turn.completed` 后调用。 */ - function dispatchNextQueuedChatTurn() { - if (chatAgentBusyRef.current || directTurnRunningRef.current) { - return; - } - directTurnCompletionPendingRef.current = false; - const { next, rest } = dequeueChatTurn(chatTurnQueueRef.current); - if (!next) { - return; - } - chatTurnQueueRef.current = rest; - setChatTurnQueue(rest); - if (rest.length === 0) { - setChatComposerNotice(''); - } - startDirectCodexConversationTurn({ - clientTurnId: next.clientTurnId, - userItem: next.userItem, - }); - } - - /** - * 回合事件可能先于本地 invoke 的 finally 到达,因此先记 pending,等本地忙碌态复位后再出队。 - */ - useEffect(() => { - if (!directCodexProductRuntime) { - directTurnCompletionPendingRef.current = false; - previousDirectTurnRunningRef.current = directTurnRunning; - return; - } - const wasRunning = previousDirectTurnRunningRef.current; - previousDirectTurnRunningRef.current = directTurnRunning; - if (wasRunning && !directTurnRunning) { - directTurnCompletionPendingRef.current = true; - } - if ( - directTurnCompletionPendingRef.current && - !chatAgentBusy && - !directTurnRunning - ) { - directTurnCompletionPendingRef.current = false; - dispatchNextQueuedChatTurn(); - } - // 队列出队函数读取本轮 render 的项目输入;这里只由三个生命周期信号触发。 - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [chatAgentBusy, directCodexProductRuntime, directTurnRunning]); - - /** 终止当前 direct-codex 回合:只取消这一轮,UI 由回合的 finally 复位。 */ - async function handleCancelDirectCodexTurn() { - if (directCodexTurnCancelling) { - return; - } - const invoke = resolveTauriInvoke(); - const directProjectPath = resolveChatProjectPath(localProject); - // 繁忙判据与「终止」按钮的可见条件一致:本地 invoke 正在跑,或订阅说还有一条回合没结束。 - // 只认 `turn.started` 推导出来的 `directTurnRunning` 会漏掉"刚提交、事件还没到"的窗口。 - if (!invoke || !directProjectPath || !supervisorChatBusy) { - setProjectSupervisorRuntimeError('当前没有正在运行的回合,无法终止。'); - return; - } - setDirectCodexTurnCancelling(true); - setChatComposerNotice('正在终止当前回合'); - try { - const result = await invoke( - 'cancel_direct_codex_turn', - { projectPath: directProjectPath }, - ); - const message = result?.message?.trim(); - if (result?.outcome === 'released') { - // 这一轮已经没有人替它收尾(执行进程已退出 / 从没进执行器),Rust 侧强制释放了 - // 守卫并补了终态事件;这里同步把界面复位,不等 IPC 通知。 - setDirectThreadChat((state) => ({ ...state, turnRunning: false })); - setChatAgentBusy(false); - setProjectSupervisorRuntimeError(''); - setChatComposerNotice( - message ?? '已结束这一轮占用,可以直接重新发送消息', - ); - return; - } - if (message) { - setChatComposerNotice(message); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - setProjectSupervisorRuntimeError(`终止失败:${message}`); - setChatComposerNotice(''); - } finally { - setDirectCodexTurnCancelling(false); - } - } - function handleProjectSupervisorOnlySubmit( event: FormEvent, ) { event.preventDefault(); const prompt = chatInput.trim(); const references = chatReferences; - const pendingAttachments = chatAttachments; - const content = [ - ...chatContent, - ...attachmentContentParts(pendingAttachments), - ]; + const content = chatContent; const canonicalPrompt = directCodexContentToPromptText( content, manifest.assets, ); - if ( - !directCodexProductRuntime && - supervisorChatOnly && - prompt.startsWith('/') - ) { + if (supervisorChatOnly && prompt.startsWith('/')) { void handleChatSubmit(event); return; } - if ( - !directCodexProductRuntime && - agentRuntimeNeedsUserInput(projectSupervisorRuntimeRef.current) - ) { + if (agentRuntimeNeedsUserInput(projectSupervisorRuntimeRef.current)) { setProjectSupervisorRuntimeError('请先回答项目总控 Agent 当前的澄清问题'); return; } @@ -12285,36 +11861,6 @@ export function App({ return; } if (supervisorChatBusy) { - // 回合运行中再次发送:direct-codex 面板把消息放进本地 FIFO 队列,当前回合结束后 - // 依次发出;其它面板保持原有"运行中不接受新输入"的行为。 - if (directCodexProductRuntime) { - const clientTurnId = createDirectCodexConversationTurnId(); - const enqueued = enqueueChatTurnForRunningTurn({ - clientTurnId, - userItem: directCodexUserItemFromContent( - content, - directCodexConversationMessageId(clientTurnId, 'user'), - ), - }); - if (enqueued) { - setChatInput(''); - setChatContent([]); - setChatReferences([]); - setChatAttachments([]); - setChatAttachmentNotice(''); - } - } - return; - } - if (directCodexProductRuntime && prompt === '/history') { - const nextProjectPath = requireChatProjectForUserAction(); - if (!nextProjectPath) { - return; - } - setChatInput(''); - setChatReferences([]); - setChatContent([]); - void loadProjectConversation(nextProjectPath, false, 'replace'); return; } if (planningV2ActiveRef.current || planningStartMode) { @@ -12337,27 +11883,6 @@ export function App({ void executeChatAgentReply({ prompt: canonicalPrompt, clientTurnId }); return; } - if (supervisorChatOnly || directCodexProductRuntime) { - supervisorChatShouldFollowLatestRef.current = true; - } - if (directCodexProductRuntime) { - // 待发附件随本轮提交一次性交给回合;提交后清空,避免同一批附件重复挂到下一轮。 - setChatInput(''); - setChatReferences([]); - setChatAttachments([]); - setChatContent([]); - setChatAttachmentNotice(''); - setChatComposerNotice(''); - const clientTurnId = createDirectCodexConversationTurnId(); - startDirectCodexConversationTurn({ - clientTurnId, - userItem: directCodexUserItemFromContent( - content, - directCodexConversationMessageId(clientTurnId, 'user'), - ), - }); - return; - } setChatInput(''); setChatReferences([]); setChatContent([]); @@ -12428,37 +11953,54 @@ export function App({ ); } - if (projectSupervisorOnly) { + if (directProjectMode) { return ( - void handleCancelDirectCodexTurn()} - onRemoveAttachment={removeChatComposerAttachment} - onUploadFiles={(files) => void handleChatComposerUploadFiles(files)} - queuedTurns={chatTurnQueue} - turnCancelling={directCodexTurnCancelling} - composerRef={chatComposerRef} chatProjectAssets={chatProjectAssets} - directCodex={directCodexProductRuntime} - directTurnRunning={directCodexProductRuntime && directTurnRunning} - directEntries={ - directCodexProductRuntime - ? selectDirectChatEntries(directThreadChat) - : [] - } + composerRef={chatComposerRef} + composerNotice={chatComposerNotice} + conversationMessages={messages} + directEntries={selectDirectChatEntries(directThreadChat)} + directTurnRunning={directTurnRunning} hiddenConversationCount={hiddenConversationCount} hasEarlierConversationMessages={hasEarlierConversationMessages} messagesRef={supervisorChatMessagesRef} - needsUserInput={ - directCodexProductRuntime ? false : projectSupervisorNeedsUserInput - } + onCancelQueuedTurn={cancelQueuedTurn} + onCancelTurn={() => void handleCancelDirectCodexTurn()} + onChatInputChange={handleChatComposerChange} + onRemoveAttachment={removeChatComposerAttachment} + onScroll={handleSupervisorChatScroll} + onShowEarlierMessages={showEarlierConversationMessages} + onSubmit={submitDirectProjectChat} + onUploadFiles={(files) => void handleChatComposerUploadFiles(files)} + projectPath={localProject?.projectPath ?? projectPath} + queuedTurns={chatTurnQueue} + turnCancelling={directCodexTurnCancelling} + versions={chatProjectVersions} + workspaceStatus={workspaceStatus} + controlBusy={supervisorChatBusy} + /> + ); + } + + if (projectSupervisorOnly) { + return ( + void; - walletEntry?: ReactNode; onClose: () => void; }) { const [runtimeConfigOpen, setRuntimeConfigOpen] = useState(false); @@ -90,18 +86,6 @@ export function ProjectSupervisorSettingsDialog({ - {walletEntry ? ( -
- - -
{walletEntry}
-
- ) : null} 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 04212c3ca..b6229e007 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 @@ -1,36 +1,24 @@ -import { ArrowUp, AtSign, Loader2, Settings } from 'lucide-react'; +import { ArrowUp, Loader2 } from 'lucide-react'; import type { ComponentProps, FormEventHandler, - ReactNode, RefObject, UIEventHandler, } from 'react'; -import { Fragment, useEffect, useRef, useState } from 'react'; -import { - AgentMessageContent, - type AgentMessageTone, -} from '../../../../../packages/shared/src/components/AgentMessageContent'; +import { AgentMessageContent } from '../../../../../packages/shared/src/components/AgentMessageContent'; import type { AgentStatusCard, ChatMessage, + DesignClarificationRequest, + DesignReasoningEntry, + DesignView, PendingCommand, PendingUiConfirmation, PlanGddDecisionAction, PlanGddStateViewV1, } from '../../app/types'; -import type { - DesignClarificationRequest, - DesignReasoningEntry, - DesignView, -} from '../../app/types'; import { ChatMarkdownMessage } from '../../components/ChatMarkdownMessage'; -import { - type ApprovalMode, - approvalModeLabel, -} from '../../view/project-development/approvalMode'; -import { ApprovalModeDialog } from '../../view/project-development/ApprovalModeDialog'; import { projectProfessionalAgentLabel, projectRuntimeVisibleError, @@ -38,32 +26,12 @@ import { ProjectSupervisorRuntimePanel, projectWorkspaceStatusForDisplay, } from '../agent-runtime'; -import type { DirectCodexTurnAttachment } from '../app-shell/directCodexTurnAttachments'; import { formatAgentCardRuntimeStatus } from '../project-summary/agentPresentation'; import { taskStatusLabels } from '../project-summary/projectSummary'; -import type { QueuedChatTurn } from './chatComposerQueue'; -import { - ComposerPendingAttachments, - ComposerReasoningEffortSelect, - ComposerStopButton, - ComposerTurnQueue, - ComposerVoiceButton, -} from './ComposerControls'; -import { - ConversationModelSelect, - type ConversationModelSelectHandle, -} from './ConversationModelSelect'; import { DesignAgentPendingActions, DesignAgentPhaseStatus, } from './DesignAgentSurface'; -import type { DirectChatEntry } from './directThreadChat'; -import { - buildDirectChatTurns, - type DirectChatBlock, - type DirectChatTurn, - directMessageTimestamp, -} from './directTurnPresentation'; import { PlanGddSurface } from './GddApprovalCard'; import { pendingCommandDetail, @@ -72,19 +40,13 @@ import { import { isPlanningLaneRuntime } from './planningLane'; import { PlanningLaneRuntimeStrip } from './PlanningLaneRuntimeStrip'; import { resolvePendingCommandProjectPath } from './projectCommandPolicy'; -import { ProjectSupervisorSettingsDialog } from './ProjectSupervisorSettingsDialog'; import { ResourceReferenceInput, type ResourceReferenceInputHandle, } from './ResourceReferenceInput'; import type { ChatComposerDraft, ChatReference } from './resourceReferences'; -import { ToolCallGroup } from './ToolCallGroup'; -import { - formatClockTime, - formatTurnDuration, -} from './toolCallGroupPresentation'; +import { formatClockTime } from './toolCallGroupPresentation'; -/** 当前 Agent 和策划 Agent 的实时/历史思考使用同一个折叠入口。 */ function AgentReasoning({ text, label = '思考过程', @@ -112,19 +74,10 @@ type RuntimePanelProps = ComponentProps; type ProjectSupervisorViewProps = RuntimePanelProps & { activeVersionId?: string | null; - /** 输入盒待发送附件:随下次提交进入回合(direct-codex 才渲染)。 */ - attachments?: DirectCodexTurnAttachment[]; - /** 上传/校验附件的提示文案(失败与成功都用它,空串不渲染)。 */ - attachmentNotice?: string; chatInput: string; chatReferences: ChatReference[]; chatProjectAssets: import('../../../../../packages/shared/src/contracts/gameCreationApp').GameCreationAppAssetManifestEntry[]; composerRef?: RefObject; - directCodex?: boolean; - /** 聊天条目:历史切片与运行态事件归并后的唯一顺序(工具卡片也在里面)。 */ - directEntries?: DirectChatEntry[]; - /** 最新回合是否还在跑;只由生命周期事件决定。 */ - directTurnRunning?: boolean; hiddenConversationCount: number; hasEarlierConversationMessages?: boolean; messagesRef: RefObject; @@ -134,17 +87,6 @@ type ProjectSupervisorViewProps = RuntimePanelProps & { onChatInputChange: (draft: ChatComposerDraft) => void; onConfirmConfirmation: () => void; onConfirmPendingCommand: () => void; - /** 回合运行中点"终止":只取消当前回合,不改后端协议。 */ - onCancelTurn?: () => void; - onCancelQueuedTurn?: (id: string) => void; - onRemoveAttachment?: (index: number) => void; - onUploadFiles?: (files: readonly File[]) => void; - /** 队列里待发的消息(回合运行中再次发送时入队)。 */ - queuedTurns?: QueuedChatTurn[]; - /** 输入盒下方的通用提示(队列满、上传失败等)。 */ - composerNotice?: string; - /** 终止请求在途:终止钮进入禁用的"正在终止"态。 */ - turnCancelling?: boolean; onScroll: UIEventHandler; onShowEarlierMessages: () => void; onSubmit: FormEventHandler; @@ -160,7 +102,6 @@ type ProjectSupervisorViewProps = RuntimePanelProps & { visibleMessages: ChatMessage[]; conversationMessages?: ChatMessage[]; visibleProfessionalAgentCards: AgentStatusCard[]; - walletEntry?: ReactNode; workspaceStatus: string; planGddState: PlanGddStateViewV1 | null; planGddHydrateBusy: boolean; @@ -186,15 +127,10 @@ type ProjectSupervisorViewProps = RuntimePanelProps & { export function ProjectSupervisorView({ activeVersionId = null, - attachments = [], - attachmentNotice = '', chatInput, chatReferences, chatProjectAssets, composerRef, - directCodex = false, - directEntries = [], - directTurnRunning = false, hiddenConversationCount, hasEarlierConversationMessages = false, messagesRef, @@ -204,13 +140,6 @@ export function ProjectSupervisorView({ onChatInputChange, onConfirmConfirmation, onConfirmPendingCommand, - onCancelTurn, - onCancelQueuedTurn, - onRemoveAttachment, - onUploadFiles: _onUploadFiles, - queuedTurns = [], - composerNotice = '', - turnCancelling = false, onScroll, onShowEarlierMessages, onSubmit, @@ -226,7 +155,6 @@ export function ProjectSupervisorView({ visibleMessages, conversationMessages = visibleMessages, visibleProfessionalAgentCards, - walletEntry, workspaceStatus, planGddState, planGddHydrateBusy, @@ -245,131 +173,44 @@ export function ProjectSupervisorView({ }: ProjectSupervisorViewProps) { const planningSurfaceActive = planningLane || isPlanningLaneRuntime(runtimePanelProps.runtime); - const [settingsOpen, setSettingsOpen] = useState(false); - // 整轮会话的耗时在回合进行中要每秒刷新:用 tick 驱动的 `now` 计算"现在 - 开始"。 - const [turnUsageNow, setTurnUsageNow] = useState(() => Date.now()); - // 语音输入的降级/失败提示:不支持时按钮本身就带提示,这里只承载启动失败与权限类错误。 - const [voiceNotice, setVoiceNotice] = useState(''); - const [approvalOpen, setApprovalOpen] = useState(false); - const [approvalMode, setApprovalMode] = useState('strict'); - const [approvalNotice, setApprovalNotice] = useState(''); - useEffect(() => { - setApprovalOpen(false); - setApprovalNotice(''); - }, [settingsOpen]); const submitLabel = needsUserInput ? '等待回答' : runtimePanelProps.controlBusy ? '思考中' : '发送'; const submitting = runtimePanelProps.controlBusy && !needsUserInput; - const [modelReady, setModelReady] = useState(false); - const [modelValidating, setModelValidating] = useState(false); - const modelSelectRef = useRef(null); - const modelValidateInFlightRef = useRef(false); - useEffect(() => { - if (!directTurnRunning) { - return; - } - setTurnUsageNow(Date.now()); - const timer = setInterval(() => setTurnUsageNow(Date.now()), 1000); - return () => clearInterval(timer); - }, [directTurnRunning]); - const runBusy = runtimePanelProps.controlBusy || submitting; - const directTurns = directCodex - ? buildDirectChatTurns({ - entries: directEntries, - localMessages: conversationMessages, - turnRunning: directTurnRunning, - }) - : []; - const activeTurnStartedAt = - directTurns.find((turn) => turn.active)?.startedAt ?? 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())}`; - }; - - const renderTurnUsage = (turn: DirectChatTurn) => { - if (turn.active || !turn.startedAt) return null; - const endedAt = Math.max(turn.endedAt, turn.startedAt); - return ( -

- {`本轮结束于 ${clockTimeWithSeconds(endedAt)} · 耗时 ${formatTurnDuration(endedAt - turn.startedAt) ?? '0秒'}`} -

- ); - }; - const renderMessageContent = ( - message: ChatMessage, - index: number, - tone: AgentMessageTone, - ) => { - const sentAt = - message.role === 'user' ? directMessageTimestamp(message.updatedAt) : 0; - return ( -
- - - - {!planningSurfaceActive && sentAt > 0 ? ( - - ) : null} - {showDesignReasoning && message.reasoningText ? ( - - ) : null} -
- ); - }; - const renderMessage = (message: ChatMessage, index: number) => - renderMessageContent(message, index, 'body'); - - const submitButton = ( - + + + + {showDesignReasoning && message.reasoningText ? ( + + ) : null} + {message.role === 'user' && message.updatedAt ? ( + + ) : null} + ); + return ( -
+
{designView || onDesignApprove ? ( )} - {directCodex ? ( -
+
+ - - -
- ) : null} + className={`project-supervisor-topbar-dot${runtimePanelProps.controlBusy ? ' is-busy' : ''}`} + aria-hidden="true" + /> + {runtimePanelProps.controlBusy + ? '项目总控 Agent 正在处理' + : projectWorkspaceStatusForDisplay(workspaceStatus)} + +
{hiddenConversationCount > 0 || hasEarlierConversationMessages ? ( @@ -448,119 +275,28 @@ export function ProjectSupervisorView({ />
) : null} - {directCodex - ? directTurns.map((turn) => { - // 只有一个消息列表:用户气泡 → 执行过程(可折叠)→ 最终回复。 - const streamingKey = turn.active - ? ([...turn.process] - .reverse() - .find((block) => block.kind === 'assistant')?.key ?? null) - : null; - const renderBlock = ( - block: DirectChatBlock, - tone: AgentMessageTone = 'body', - ) => { - if (block.kind === 'tools') { - return ( - - ); - } - if (block.kind === 'reasoning') { - return ( - - ); - } - const role = block.kind === 'user' ? 'user' : 'assistant'; - return ( -
- - - - {!planningSurfaceActive && - block.kind === 'user' && - block.at > 0 ? ( - - ) : null} -
- ); - }; - return ( - - {turn.users.map((block) => renderBlock(block))} - {turn.process.length > 0 ? ( - turn.active ? ( - turn.process.map((block) => - renderBlock(block, 'process'), - ) - ) : ( -
- 执行过程 -
- {turn.process.map((block) => - renderBlock(block, 'process'), - )} -
-
- ) - ) : null} - {turn.finals.map((block) => renderBlock(block))} - {renderTurnUsage(turn)} -
- ); - }) - : visibleMessages.map(renderMessage)} - {showDesignReasoning && - designReasoningEntries - .filter((entry) => !entry.messageId) - .map((entry) => ( - - ))} + {visibleMessages.map(renderMessage)} + {showDesignReasoning + ? designReasoningEntries + .filter((entry) => !entry.messageId) + .map((entry) => ( + + )) + : null} {showDesignReasoning && designReasoning ? ( ) : null} - {!directCodex && transientReply ? ( + {transientReply ? (
@@ -574,7 +310,7 @@ export function ProjectSupervisorView({
) : null}
- {directCodex ? null : planningSurfaceActive ? ( + {planningSurfaceActive ? ( @@ -637,160 +373,59 @@ export function ProjectSupervisorView({ onRetry={onDesignRetry ?? (() => undefined)} /> ) : null} - {directCodex && directTurnRunning ? ( - -
-
-
- ) : null} -
{ - if (!directCodex) { - onSubmit(event); - return; - } - event.preventDefault(); - if (modelValidateInFlightRef.current) return; - modelValidateInFlightRef.current = true; - setModelValidating(true); - const validateModel = async () => { - try { - const ready = modelSelectRef.current - ? await modelSelectRef.current.ensureUsable() - : modelReady; - if (ready) onSubmit(event); - } finally { - modelValidateInFlightRef.current = false; - setModelValidating(false); - } - }; - void validateModel(); - }} - > - onCancelQueuedTurn?.(id)} - /> - onRemoveAttachment?.(index)} - /> + - {directCodex ? ( -
-
- -
-
- {/* 推理档放在模型选择器旁边(Codex 的「高」那个位置):写回的是客户端 - 配置,只影响后续回合;当前回合的行为不受影响。 */} - - - - composerRef?.current?.insertText(text) - } - onNotice={setVoiceNotice} - /> - {submitting && onCancelTurn ? ( - +
+
+
- ) : ( - submitButton - )} - {directCodex && - (composerNotice || attachmentNotice || voiceNotice) ? ( -

- {composerNotice || attachmentNotice || voiceNotice} -

- ) : null} +
- {directCodex ? null : ( - - {projectWorkspaceStatusForDisplay(workspaceStatus)} - - )} {showProfessionalCollaboration ? (
); } diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx index 123cafc08..b2f1ea468 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx @@ -61,6 +61,7 @@ import { createProjectResourcePreviewRequestId, createProjectResourcePreviewScopeId, } from '../../services/projectResourcePreviewTransport'; +import type { DirectCodexUserContentPart } from '../../view/project-development/chat/generated/DirectCodexUserContentPart'; import { $createAttachmentReferenceNode, $isAttachmentReferenceNode, @@ -73,7 +74,6 @@ import { writeChatPromptPolishReminderDisabled, } from './chatPromptPolish'; import { ChatPromptPolishReminder } from './ChatPromptPolishReminder'; -import type { DirectCodexUserContentPart } from './generated'; import { $createResourceReferenceNode, $isResourceReferenceNode, diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/chatComposerQueue.ts b/apps/ai-game-creator-shell/src/features/project-workspace/chatComposerQueue.ts index 74601fa2e..2dab0b64c 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/chatComposerQueue.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/chatComposerQueue.ts @@ -5,7 +5,7 @@ * 依次发出。队列项能在输入盒上方单独取消。这里只放与 React 无关的纯逻辑,便于单测。 */ import type { GameCreationAppAssetManifestEntry } from '../../../../../packages/shared/src/contracts/gameCreationApp'; -import type { DirectCodexUserItem } from './generated'; +import type { DirectCodexUserItem } from '../../view/project-development/chat/generated/DirectCodexUserItem'; import { directCodexContentToPromptText } from './resourceReferences'; /** 队列上限:满了以后拒绝入队并给出可读提示,而不是静默丢消息。 */ diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/chatPromptPolish.ts b/apps/ai-game-creator-shell/src/features/project-workspace/chatPromptPolish.ts index cc509e140..d30127da5 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/chatPromptPolish.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/chatPromptPolish.ts @@ -1,5 +1,5 @@ import { resolveTauriInvoke } from '../../app/tauri'; -import type { DirectCodexUserContentPart } from './generated'; +import type { DirectCodexUserContentPart } from '../../view/project-development/chat/generated/DirectCodexUserContentPart'; /** * 「不再提醒」偏好存本机 localStorage,不进 manifest、不进后端。 diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/directThreadEvents.ts b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadEvents.ts index 741e694f3..8834882cc 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/directThreadEvents.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadEvents.ts @@ -5,13 +5,11 @@ * 入口转发:前端不再自己抄一份形状,字段增删必须改 Rust。 */ -export type { - DirectThreadConsumeResult, - DirectThreadDeltaKind, - DirectThreadEvent, - DirectThreadFileChange, - DirectThreadHistorySlice, - DirectThreadItem, - DirectThreadRequestKind, - DirectThreadSubscriptionBootstrap, -} from './generated'; +export type { DirectThreadConsumeResult } from '../../view/project-development/chat/generated/DirectThreadConsumeResult'; +export type { DirectThreadDeltaKind } from '../../view/project-development/chat/generated/DirectThreadDeltaKind'; +export type { DirectThreadEvent } from '../../view/project-development/chat/generated/DirectThreadEvent'; +export type { DirectThreadFileChange } from '../../view/project-development/chat/generated/DirectThreadFileChange'; +export type { DirectThreadHistorySlice } from '../../view/project-development/chat/generated/DirectThreadHistorySlice'; +export type { DirectThreadItem } from '../../view/project-development/chat/generated/DirectThreadItem'; +export type { DirectThreadRequestKind } from '../../view/project-development/chat/generated/DirectThreadRequestKind'; +export type { DirectThreadSubscriptionBootstrap } from '../../view/project-development/chat/generated/DirectThreadSubscriptionBootstrap'; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/index.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/index.ts deleted file mode 100644 index 5bd9b7cb7..000000000 --- a/apps/ai-game-creator-shell/src/features/project-workspace/generated/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -export type { DirectCodexUserContentPart } from './DirectCodexUserContentPart'; -export type { DirectCodexUserAttachmentReferencePart } from './DirectCodexUserAttachmentReferencePart'; -export type { DirectCodexUserItem } from './DirectCodexUserItem'; -export type { DirectCodexUserMessageItem } from './DirectCodexUserMessageItem'; -export type { DirectCodexUserRole } from './DirectCodexUserRole'; -export type { DirectCodexUserRuntimeRegionPart } from './DirectCodexUserRuntimeRegionPart'; -export type { DirectCodexUserMessageEnvelope } from './DirectCodexUserMessageEnvelope'; -export type { DirectThreadConsumeResult } from './DirectThreadConsumeResult'; -export type { DirectThreadDeltaKind } from './DirectThreadDeltaKind'; -export type { DirectThreadEvent } from './DirectThreadEvent'; -export type { DirectThreadFileChange } from './DirectThreadFileChange'; -export type { DirectThreadHistorySlice } from './DirectThreadHistorySlice'; -export type { DirectThreadItem } from './DirectThreadItem'; -export type { DirectThreadRequestKind } from './DirectThreadRequestKind'; -export type { DirectThreadSubscriptionBootstrap } from './DirectThreadSubscriptionBootstrap'; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts b/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts index 1192a5c73..fbd2c19be 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts @@ -10,10 +10,8 @@ import { buildGameCreationAppAssetTagLibrary, type GameCreationAppAssetTagLibraryEntry, } from '../../../../../packages/shared/src/contracts/gameCreationAppAssetTagLibrary'; -import type { - DirectCodexUserContentPart, - DirectCodexUserItem, -} from './generated'; +import type { DirectCodexUserContentPart } from '../../view/project-development/chat/generated/DirectCodexUserContentPart'; +import type { DirectCodexUserItem } from '../../view/project-development/chat/generated/DirectCodexUserItem'; export type ResourceReferenceSource = | 'asset-picker' diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 03c2a4897..b122df6d4 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -5850,6 +5850,17 @@ iframe.preview-frame { min-height: 0; } +.game-workbench-layout-account { + position: absolute; + top: 8px; + right: 12px; + z-index: 4; +} + +.game-workbench-layout-account .launcher-account-bar { + position: static; +} + .game-workbench-layout.is-ui-editor { grid-template-columns: minmax(0, 1fr); } diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx new file mode 100644 index 000000000..d2bb8580f --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx @@ -0,0 +1,453 @@ +import { ArrowUp, AtSign, Loader2, Settings } from 'lucide-react'; +import type { FormEventHandler, RefObject, UIEventHandler } from 'react'; +import { Fragment, useEffect, useRef, useState } from 'react'; + +import { + AgentMessageContent, + type AgentMessageTone, +} from '../../../../../../packages/shared/src/components/AgentMessageContent'; +import type { ChatMessage } from '../../../app/types'; +import { ChatMarkdownMessage } from '../../../components/ChatMarkdownMessage'; +import { projectWorkspaceStatusForDisplay } from '../../../features/agent-runtime'; +import type { DirectCodexTurnAttachment } from '../../../features/app-shell/directCodexTurnAttachments'; +import type { QueuedChatTurn } from '../../../features/project-workspace/chatComposerQueue'; +import { + ComposerAttachmentMenu, + ComposerPendingAttachments, + ComposerReasoningEffortSelect, + ComposerStopButton, + ComposerTurnQueue, + ComposerVoiceButton, +} from '../../../features/project-workspace/ComposerControls'; +import { + ConversationModelSelect, + type ConversationModelSelectHandle, +} from '../../../features/project-workspace/ConversationModelSelect'; +import type { DirectChatEntry } from '../../../features/project-workspace/directThreadChat'; +import { + buildDirectChatTurns, + type DirectChatBlock, + type DirectChatTurn, + directMessageTimestamp, +} from '../../../features/project-workspace/directTurnPresentation'; +import { ProjectSupervisorSettingsDialog } from '../../../features/project-workspace/ProjectSupervisorSettingsDialog'; +import { + ResourceReferenceInput, + type ResourceReferenceInputHandle, +} from '../../../features/project-workspace/ResourceReferenceInput'; +import type { + ChatComposerDraft, + ChatReference, +} from '../../../features/project-workspace/resourceReferences'; +import { ToolCallGroup } from '../../../features/project-workspace/ToolCallGroup'; +import { + formatClockTime, + formatTurnDuration, +} from '../../../features/project-workspace/toolCallGroupPresentation'; +import { type ApprovalMode, approvalModeLabel } from '../approvalMode'; +import { ApprovalModeDialog } from '../ApprovalModeDialog'; + +type AssetManifestEntry = + import('../../../../../../packages/shared/src/contracts/gameCreationApp').GameCreationAppAssetManifestEntry; +type GameIterationVersion = + import('../../../../../../packages/shared/src/contracts/gameCreationApp').GameIterationVersion; + +export type DirectProjectChatViewProps = { + activeVersionId?: string | null; + attachments: DirectCodexTurnAttachment[]; + attachmentNotice: string; + chatInput: string; + chatReferences: ChatReference[]; + chatProjectAssets: AssetManifestEntry[]; + composerRef: RefObject; + composerNotice: string; + conversationMessages: ChatMessage[]; + directEntries: DirectChatEntry[]; + directTurnRunning: boolean; + hiddenConversationCount: number; + hasEarlierConversationMessages: boolean; + messagesRef: RefObject; + onCancelQueuedTurn: (id: string) => void; + onCancelTurn: () => void; + onChatInputChange: (draft: ChatComposerDraft) => void; + onRemoveAttachment: (index: number) => void; + onScroll: UIEventHandler; + onShowEarlierMessages: () => void; + onSubmit: FormEventHandler; + onUploadFiles: (files: readonly File[]) => void; + projectPath: string; + queuedTurns: QueuedChatTurn[]; + turnCancelling: boolean; + versions?: GameIterationVersion[]; + workspaceStatus: string; + controlBusy: boolean; +}; + +/** + * DirectProject 的独立表现容器。 + * + * 这个组件只处理 DirectProject 的输入框、回合分区、运行中状态和设置表现; + * Supervisor / Design Agent / Planning V2 的运行态不在这里出现。 + */ +export function DirectProjectChatView({ + activeVersionId = null, + attachments, + attachmentNotice, + chatInput, + chatReferences, + chatProjectAssets, + composerRef, + composerNotice, + conversationMessages, + directEntries, + directTurnRunning, + hiddenConversationCount, + hasEarlierConversationMessages, + messagesRef, + onCancelQueuedTurn, + onCancelTurn, + onChatInputChange, + onRemoveAttachment, + onScroll, + onShowEarlierMessages, + onSubmit, + onUploadFiles, + projectPath, + queuedTurns, + turnCancelling, + versions, + workspaceStatus, + controlBusy, +}: DirectProjectChatViewProps) { + const [settingsOpen, setSettingsOpen] = useState(false); + const [turnUsageNow, setTurnUsageNow] = useState(() => Date.now()); + const [voiceNotice, setVoiceNotice] = useState(''); + const [approvalOpen, setApprovalOpen] = useState(false); + const [approvalMode, setApprovalMode] = useState('strict'); + const [approvalNotice, setApprovalNotice] = useState(''); + const [modelReady, setModelReady] = useState(false); + const [modelValidating, setModelValidating] = useState(false); + const modelSelectRef = useRef(null); + const modelValidateInFlightRef = useRef(false); + + useEffect(() => { + if (!directTurnRunning) return; + setTurnUsageNow(Date.now()); + const timer = setInterval(() => setTurnUsageNow(Date.now()), 1000); + return () => clearInterval(timer); + }, [directTurnRunning]); + + const directTurns = buildDirectChatTurns({ + entries: directEntries, + localMessages: conversationMessages, + turnRunning: directTurnRunning, + }); + const activeTurnStartedAt = + directTurns.find((turn) => turn.active)?.startedAt ?? 0; + + const renderTurnUsage = (turn: DirectChatTurn) => { + if (turn.active || !turn.startedAt) return null; + const endedAt = Math.max(turn.endedAt, turn.startedAt); + return ( +

+ {`本轮结束于 ${new Date(endedAt).toLocaleTimeString('zh-CN', { + hour12: false, + })} · 耗时 ${formatTurnDuration(endedAt - turn.startedAt) ?? '0秒'}`} +

+ ); + }; + + const submitLabel = controlBusy ? '思考中' : '发送'; + const submitButton = ( + + ); + + const renderBlock = ( + turn: DirectChatTurn, + block: DirectChatBlock, + tone: AgentMessageTone = 'body', + streamingKey: string | null, + ) => { + if (block.kind === 'tools') { + return ( + + ); + } + if (block.kind === 'reasoning') { + return ( + + 思考过程 +
{block.text}
+
+ ); + } + const role = block.kind === 'user' ? 'user' : 'assistant'; + return ( +
+ + + + {block.kind === 'user' && block.at > 0 ? ( + + ) : null} +
+ ); + }; + + return ( +
+
+
+ + + +
+
+ {hiddenConversationCount > 0 || hasEarlierConversationMessages ? ( + + ) : null} + {directTurns.map((turn) => { + const streamingKey = turn.active + ? ([...turn.process] + .reverse() + .find((block) => block.kind === 'assistant')?.key ?? null) + : null; + return ( + + {turn.users.map((block) => + renderBlock(turn, block, 'body', streamingKey), + )} + {turn.process.length > 0 ? ( + turn.active ? ( + turn.process.map((block) => + renderBlock(turn, block, 'process', streamingKey), + ) + ) : ( +
+ 执行过程 +
+ {turn.process.map((block) => + renderBlock(turn, block, 'process', streamingKey), + )} +
+
+ ) + ) : null} + {turn.finals.map((block) => + renderBlock(turn, block, 'body', streamingKey), + )} + {renderTurnUsage(turn)} +
+ ); + })} +
+ {directTurnRunning ? ( + +
+
+
+ ) : null} +
{ + event.preventDefault(); + if (modelValidateInFlightRef.current) return; + modelValidateInFlightRef.current = true; + setModelValidating(true); + void (async () => { + try { + const ready = modelSelectRef.current + ? await modelSelectRef.current.ensureUsable() + : modelReady; + if (ready) onSubmit(event); + } finally { + modelValidateInFlightRef.current = false; + setModelValidating(false); + } + })(); + }} + > + + + +
+
+ + composerRef.current?.openPicker()} + /> +
+
+ + + composerRef.current?.insertText(text)} + onNotice={setVoiceNotice} + /> + {controlBusy ? ( + + ) : ( + submitButton + )} +
+
+ {composerNotice || attachmentNotice || voiceNotice ? ( +

+ {composerNotice || attachmentNotice || voiceNotice} +

+ ) : null} + +
+ {settingsOpen ? ( + setApprovalOpen(true)} + onClose={() => setSettingsOpen(false)} + /> + ) : null} + {settingsOpen && approvalOpen ? ( + setApprovalOpen(false)} + closeOnEscape={false} + /> + ) : null} +
+ ); +} diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserAttachmentReferencePart.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexUserAttachmentReferencePart.ts similarity index 100% rename from apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserAttachmentReferencePart.ts rename to apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexUserAttachmentReferencePart.ts diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserContentPart.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexUserContentPart.ts similarity index 100% rename from apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserContentPart.ts rename to apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexUserContentPart.ts diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserItem.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexUserItem.ts similarity index 100% rename from apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserItem.ts rename to apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexUserItem.ts diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageEnvelope.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexUserMessageEnvelope.ts similarity index 100% rename from apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageEnvelope.ts rename to apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexUserMessageEnvelope.ts diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageItem.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexUserMessageItem.ts similarity index 100% rename from apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageItem.ts rename to apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexUserMessageItem.ts diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRole.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexUserRole.ts similarity index 100% rename from apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRole.ts rename to apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexUserRole.ts diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRuntimeRegionPart.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexUserRuntimeRegionPart.ts similarity index 100% rename from apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRuntimeRegionPart.ts rename to apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexUserRuntimeRegionPart.ts diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadConsumeResult.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectThreadConsumeResult.ts similarity index 100% rename from apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadConsumeResult.ts rename to apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectThreadConsumeResult.ts diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadDeltaKind.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectThreadDeltaKind.ts similarity index 100% rename from apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadDeltaKind.ts rename to apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectThreadDeltaKind.ts diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadEvent.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectThreadEvent.ts similarity index 100% rename from apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadEvent.ts rename to apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectThreadEvent.ts diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadFileChange.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectThreadFileChange.ts similarity index 100% rename from apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadFileChange.ts rename to apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectThreadFileChange.ts diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadHistorySlice.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectThreadHistorySlice.ts similarity index 100% rename from apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadHistorySlice.ts rename to apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectThreadHistorySlice.ts diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadItem.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectThreadItem.ts similarity index 100% rename from apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadItem.ts rename to apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectThreadItem.ts diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadRequestKind.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectThreadRequestKind.ts similarity index 100% rename from apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadRequestKind.ts rename to apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectThreadRequestKind.ts diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadSubscriptionBootstrap.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectThreadSubscriptionBootstrap.ts similarity index 100% rename from apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadSubscriptionBootstrap.ts rename to apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectThreadSubscriptionBootstrap.ts diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/useDirectProjectChatController.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/useDirectProjectChatController.ts new file mode 100644 index 000000000..aaecdb951 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/useDirectProjectChatController.ts @@ -0,0 +1,559 @@ +import { + type Dispatch, + type FormEvent, + type RefObject, + type SetStateAction, + useEffect, + useRef, + useState, +} from 'react'; + +import { CONVERSATION_VISIBLE_STEP } from '../../../app/constants'; +import { resolveTauriInvoke } from '../../../app/tauri'; +import type { DirectTurnCancelView } from '../../../app/types'; +import { + type DirectCodexTurnAttachment, + toDirectCodexTurnAttachments, +} from '../../../features/app-shell/directCodexTurnAttachments'; +import { uploadLocalFilesAsAttachments } from '../../../features/app-shell/useHomeProjectCreation'; +import { + chatQueueFullNotice, + createQueuedChatTurn, + dequeueChatTurn, + enqueueChatTurn, + isChatTurnQueueFull, + type QueuedChatTurn, + removeQueuedChatTurn, +} from '../../../features/project-workspace/chatComposerQueue'; +import { + type DirectHistoryAnchorGate, + reuseOrOpenDirectHistoryAnchorGate, +} from '../../../features/project-workspace/directHistoryAnchorGate'; +import { readDirectHistoryPages } from '../../../features/project-workspace/directHistoryPaging'; +import { + applyDirectThreadConsumeResult, + type DirectThreadChatState, + emptyDirectThreadChatState, + mergeDirectHistoryItems, + resolveDirectThreadBootstrap, + selectDirectChatEntries, +} from '../../../features/project-workspace/directThreadChat'; +import type { + DirectThreadConsumeResult, + DirectThreadHistorySlice, + DirectThreadSubscriptionBootstrap, +} from '../../../features/project-workspace/directThreadEvents'; +import type { ChatReference } from '../../../features/project-workspace/resourceReferences'; +import { + canSubscribeTauriEvents, + subscribeTauriEvent, +} from '../../../services/tauriEventSubscription'; +import type { DirectCodexUserContentPart } from './generated/DirectCodexUserContentPart'; +import type { DirectCodexUserItem } from './generated/DirectCodexUserItem'; + +export const MAX_CHAT_COMPOSER_ATTACHMENTS = 8; +export const DIRECT_HISTORY_PAGE_SIZE = CONVERSATION_VISIBLE_STEP; + +export type DirectProjectChatControllerProps = { + enabled: boolean; + projectPath: string | null; + localProjectPathRef: RefObject; + chatInput: string; + chatContent: DirectCodexUserContentPart[]; + setChatInput: Dispatch>; + setChatReferences: Dispatch>; + setChatContent: Dispatch>; + chatAgentBusy: boolean; + setChatAgentBusy: Dispatch>; + setRuntimeError: (message: string) => void; + setWorkspaceStatus: Dispatch>; + refreshManifest: (projectPath: string) => Promise | void; + onStartTurn: (input: { + clientTurnId: string; + userItem: DirectCodexUserItem; + }) => void; + onReloadHistory: () => void; + directCodexConversationMessageId: ( + turnId: string, + role: 'user' | 'assistant', + ) => string; + directCodexUserItemFromContent: ( + content: DirectCodexUserContentPart[], + messageId: string, + ) => DirectCodexUserItem; + hasMeaningfulContent: (content: DirectCodexUserContentPart[]) => boolean; + withDirectCodexSessionRefresh: (operation: () => Promise) => Promise; + isTurnBusy: boolean; +}; + +export function useDirectProjectChatController({ + enabled, + projectPath, + localProjectPathRef, + chatInput, + chatContent, + setChatInput, + setChatReferences, + setChatContent, + chatAgentBusy, + setChatAgentBusy, + setRuntimeError, + setWorkspaceStatus, + refreshManifest, + onStartTurn, + onReloadHistory, + directCodexConversationMessageId, + directCodexUserItemFromContent, + hasMeaningfulContent, + withDirectCodexSessionRefresh, + isTurnBusy, +}: DirectProjectChatControllerProps) { + const [attachments, setAttachments] = useState( + [], + ); + const [attachmentNotice, setAttachmentNotice] = useState(''); + const [queuedTurns, setQueuedTurns] = useState([]); + const queuedTurnsRef = useRef([]); + queuedTurnsRef.current = queuedTurns; + const [composerNotice, setComposerNotice] = useState(''); + const [turnCancelling, setTurnCancelling] = useState(false); + const [directThreadChat, setDirectThreadChat] = + useState(() => emptyDirectThreadChatState()); + const currentTurnRunning = directThreadChat.turnRunning; + const [historyHasMore, setHistoryHasMore] = useState(false); + const historyOldestItemIdRef = useRef(null); + const historyLoadingRef = useRef(false); + const historyAnchorGateRef = useRef(null); + const queueSequenceRef = useRef(0); + const turnSequenceRef = useRef(0); + const directTurnRunningRef = useRef(currentTurnRunning); + directTurnRunningRef.current = currentTurnRunning; + const chatAgentBusyRef = useRef(chatAgentBusy); + chatAgentBusyRef.current = chatAgentBusy; + const completionPendingRef = useRef(false); + const previousTurnRunningRef = useRef(currentTurnRunning); + + useEffect(() => { + setAttachments([]); + setAttachmentNotice(''); + setComposerNotice(''); + setQueuedTurns([]); + queuedTurnsRef.current = []; + setDirectThreadChat(emptyDirectThreadChatState()); + setHistoryHasMore(false); + historyOldestItemIdRef.current = null; + }, [projectPath]); + + useEffect(() => { + if (!enabled) return; + const directInvoke = resolveTauriInvoke(); + const anchorGate = reuseOrOpenDirectHistoryAnchorGate( + historyAnchorGateRef.current, + projectPath ?? '', + ); + historyAnchorGateRef.current = anchorGate; + if (!projectPath || !directInvoke || !canSubscribeTauriEvents()) { + anchorGate.settle(null); + return; + } + const invoke = directInvoke; + let disposed = false; + let cleanup: (() => void) | null = null; + let subscriptionId: string | null = null; + let consuming = false; + let consumeAgain = false; + let notifyBeforeSubscription = false; + + async function consume() { + if (!subscriptionId || disposed) return; + if (consuming) { + consumeAgain = true; + return; + } + consuming = true; + try { + do { + consumeAgain = false; + const result = await invoke( + 'consume_direct_project_thread', + { subscriptionId }, + ); + if (disposed) return; + setDirectThreadChat((state) => + applyDirectThreadConsumeResult(state, result), + ); + } while (consumeAgain && !disposed); + } catch (error) { + if (!disposed && String(error).includes('SUBSCRIPTION_EXPIRED')) { + subscriptionId = null; + try { + await bootstrap(); + } catch { + // 下一次通知再重试。 + } + } + } finally { + consuming = false; + } + } + async function bootstrap() { + const result = await invoke( + 'subscribe_direct_project_thread', + { projectPath }, + ); + if (disposed) { + anchorGate.settle(null); + return; + } + subscriptionId = result.subscriptionId; + anchorGate.settle(result.lastCompletedItemId ?? null); + setDirectThreadChat((state) => + resolveDirectThreadBootstrap(state, result), + ); + if (notifyBeforeSubscription) { + notifyBeforeSubscription = false; + void consume(); + } + } + const setup = async () => { + try { + const unlisten = await subscribeTauriEvent<{ subscriptionId: string }>( + 'game-creator-direct-thread-notify', + (event) => { + if (event.payload.subscriptionId === subscriptionId) { + void consume(); + } else if (!subscriptionId) { + notifyBeforeSubscription = true; + } + }, + ); + if (disposed) { + unlisten(); + return; + } + cleanup = unlisten; + await bootstrap(); + } catch { + anchorGate.settle(null); + } + }; + void setup(); + return () => { + disposed = true; + cleanup?.(); + anchorGate.settle(null); + }; + }, [enabled, projectPath]); + + useEffect(() => { + if (!enabled) { + completionPendingRef.current = false; + previousTurnRunningRef.current = currentTurnRunning; + return; + } + const wasRunning = previousTurnRunningRef.current; + previousTurnRunningRef.current = currentTurnRunning; + if (wasRunning && !currentTurnRunning) completionPendingRef.current = true; + if (completionPendingRef.current && !chatAgentBusy && !currentTurnRunning) { + completionPendingRef.current = false; + dispatchNextQueuedTurn(); + } + // 队列出队只由忙碌态和线程完成态驱动。 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [chatAgentBusy, currentTurnRunning, enabled]); + + function createTurnId() { + turnSequenceRef.current += 1; + let randomId = ''; + try { + randomId = globalThis.crypto?.randomUUID?.().trim() ?? ''; + } catch { + // WebView 没有 crypto 时使用时间戳和页内序号。 + } + if (/^[a-z0-9][a-z0-9-]{5,159}$/iu.test(randomId)) return randomId; + return `${Date.now().toString(36)}-${turnSequenceRef.current.toString(36)}`; + } + + function attachmentContentParts(items: readonly DirectCodexTurnAttachment[]) { + return items.map((attachment) => ({ + type: 'agc_attachment_reference' as const, + name: attachment.name, + mediaType: attachment.mediaType, + size: attachment.size ?? 0, + localPath: attachment.localPath ?? '', + status: + attachment.status ?? (attachment.localPath ? 'imported' : 'failed'), + })); + } + + function startTurn(input: { + clientTurnId: string; + userItem: DirectCodexUserItem; + }) { + onStartTurn(input); + } + + function dispatchNextQueuedTurn() { + if (chatAgentBusyRef.current || directTurnRunningRef.current) return; + const { next, rest } = dequeueChatTurn(queuedTurnsRef.current); + if (!next) return; + completionPendingRef.current = false; + queuedTurnsRef.current = rest; + setQueuedTurns(rest); + if (rest.length === 0) setComposerNotice(''); + startTurn({ clientTurnId: next.clientTurnId, userItem: next.userItem }); + } + + function enqueueTurn(input: { + clientTurnId: string; + userItem: DirectCodexUserItem; + }) { + if (isChatTurnQueueFull(queuedTurnsRef.current)) { + setComposerNotice(chatQueueFullNotice()); + return false; + } + queueSequenceRef.current += 1; + const turn = createQueuedChatTurn({ + id: `queued-chat-turn-${Date.now()}-${queueSequenceRef.current}`, + clientTurnId: input.clientTurnId, + userItem: input.userItem, + createdAt: Date.now(), + }); + const next = enqueueChatTurn(queuedTurnsRef.current, turn); + queuedTurnsRef.current = next; + setQueuedTurns(next); + setComposerNotice('已加入发送队列,当前回合结束后自动发送'); + return true; + } + + function cancelQueuedTurn(id: string) { + const next = removeQueuedChatTurn(queuedTurnsRef.current, id); + queuedTurnsRef.current = next; + setQueuedTurns(next); + if (next.length === 0) setComposerNotice(''); + } + + async function uploadFiles(files: readonly File[]) { + const invoke = resolveTauriInvoke(); + if (!invoke || !projectPath) { + setAttachmentNotice('需要先打开本地项目,才能上传文件'); + return; + } + const accepted = files.slice( + 0, + Math.max(MAX_CHAT_COMPOSER_ATTACHMENTS - attachments.length, 0), + ); + if (accepted.length === 0) { + setAttachmentNotice( + `最多同时携带 ${MAX_CHAT_COMPOSER_ATTACHMENTS} 个附件,请先移除已有附件`, + ); + return; + } + setAttachmentNotice('正在上传文件'); + try { + const imported = await uploadLocalFilesAsAttachments( + invoke, + projectPath, + accepted, + ); + const nextAttachments = toDirectCodexTurnAttachments(imported); + if (localProjectPathRef.current !== projectPath) return; + setAttachments((current) => + [...current, ...nextAttachments].slice( + 0, + MAX_CHAT_COMPOSER_ATTACHMENTS, + ), + ); + const failed = nextAttachments.filter( + (attachment) => attachment.status === 'failed', + ); + setAttachmentNotice( + failed.length > 0 + ? `${failed.length} 个文件未能上传:${failed[0]?.name ?? ''}` + : `已上传 ${nextAttachments.length} 个文件,将在下次发送时作为本轮附件`, + ); + void refreshManifest(projectPath); + } catch (error) { + if (localProjectPathRef.current === projectPath) { + setAttachmentNotice( + error instanceof Error ? error.message : String(error), + ); + } + } + } + + function removeAttachment(index: number) { + setAttachments((current) => + current.filter((_, currentIndex) => currentIndex !== index), + ); + } + + function submit(event: FormEvent) { + event.preventDefault(); + const content = [...chatContent, ...attachmentContentParts(attachments)]; + if (!hasMeaningfulContent(content)) return; + if (isTurnBusy || currentTurnRunning) { + const clientTurnId = createTurnId(); + if ( + enqueueTurn({ + clientTurnId, + userItem: directCodexUserItemFromContent( + content, + directCodexConversationMessageId(clientTurnId, 'user'), + ), + }) + ) { + clearPendingInput(); + } + return; + } + const prompt = chatInput.trim(); + if (prompt === '/history') { + clearPendingInput(); + onReloadHistory(); + return; + } + clearPendingInput(); + const clientTurnId = createTurnId(); + startTurn({ + clientTurnId, + userItem: directCodexUserItemFromContent( + content, + directCodexConversationMessageId(clientTurnId, 'user'), + ), + }); + } + + async function cancelTurn() { + if (turnCancelling) return; + const invoke = resolveTauriInvoke(); + if (!invoke || !projectPath || (!isTurnBusy && !currentTurnRunning)) { + setRuntimeError('当前没有正在运行的回合,无法终止。'); + return; + } + setTurnCancelling(true); + setComposerNotice('正在终止当前回合'); + try { + const result = await withDirectCodexSessionRefresh(() => + invoke('cancel_direct_codex_turn', { + projectPath, + }), + ); + const message = result?.message?.trim(); + if (result?.outcome === 'released') { + setDirectThreadChat((state) => ({ ...state, turnRunning: false })); + setChatAgentBusy(false); + setRuntimeError(''); + setComposerNotice(message ?? '已结束这一轮占用,可以直接重新发送消息'); + } else if (message) { + setComposerNotice(message); + } + } catch (error) { + setRuntimeError( + `终止失败:${error instanceof Error ? error.message : String(error)}`, + ); + setComposerNotice(''); + } finally { + setTurnCancelling(false); + } + } + + async function loadEarlierHistory() { + if ( + !enabled || + !historyHasMore || + !projectPath || + historyLoadingRef.current + ) + return; + const invoke = resolveTauriInvoke(); + if (!invoke) return; + historyLoadingRef.current = true; + try { + const pages = await readDirectHistoryPages({ + existingEntries: selectDirectChatEntries(directThreadChat), + beforeItemId: historyOldestItemIdRef.current, + readSlice: (beforeItemId) => + invoke( + 'read_direct_project_history_slice', + beforeItemId + ? { projectPath, beforeItemId, limit: DIRECT_HISTORY_PAGE_SIZE } + : { projectPath, limit: DIRECT_HISTORY_PAGE_SIZE }, + ), + }); + if (localProjectPathRef.current !== projectPath) return; + setDirectThreadChat((state) => + mergeDirectHistoryItems(state, pages.items), + ); + if (!pages.error) setHistoryHasMore(pages.hasMore); + historyOldestItemIdRef.current = + pages.firstItemId ?? historyOldestItemIdRef.current; + if (pages.error) throw pages.error; + } catch (error) { + setWorkspaceStatus( + `读取更早的对话历史失败:${error instanceof Error ? error.message : String(error)}`, + ); + } finally { + historyLoadingRef.current = false; + } + } + + function reset() { + setDirectThreadChat(emptyDirectThreadChatState()); + setHistoryHasMore(false); + historyOldestItemIdRef.current = null; + historyAnchorGateRef.current?.settle(null); + } + + function hydrateHistory(input: { + items: Parameters[1]; + hasMore: boolean; + firstItemId: string | null; + }) { + setHistoryHasMore(input.hasMore); + historyOldestItemIdRef.current = input.firstItemId; + if (input.items.length > 0) { + setDirectThreadChat((state) => + mergeDirectHistoryItems(state, input.items), + ); + } + } + + function clearPendingInput() { + setChatInput(''); + setChatReferences([]); + setChatContent([]); + setAttachments([]); + setAttachmentNotice(''); + setComposerNotice(''); + } + + return { + attachments, + attachmentNotice, + queuedTurns, + composerNotice, + setComposerNotice, + turnCancelling, + setTurnCancelling, + directThreadChat, + setDirectThreadChat, + directTurnRunning: currentTurnRunning, + historyHasMore, + historyOldestItemIdRef, + historyAnchorGateRef, + createTurnId, + attachmentContentParts, + enqueueTurn, + cancelQueuedTurn, + dispatchNextQueuedTurn, + uploadFiles, + removeAttachment, + cancelTurn, + loadEarlierHistory, + hydrateHistory, + submit, + reset, + clearPendingInput, + }; +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index d81dd7083..b08b4152a 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -54,13 +54,10 @@ import { ZoomOut, } from 'lucide-react'; import { - cloneElement, type CSSProperties, Fragment, - isValidElement, memo, type PointerEvent as ReactPointerEvent, - type ReactElement, type ReactNode, startTransition, useCallback, @@ -3425,18 +3422,6 @@ export default function ProjectDevelopmentView({ const agentSummaries = showAllAgentGroups ? allAgentSummaries : allAgentSummaries.slice(0, 3); - /** - * 会话面板 Codex 风格改造后,面板顶部不再直接挂泥点钱包,钱包收进面板自己的设置浮层。 - * `supervisor` 是外部传进来的 React 元素(`WorkspaceLauncher` 里的 `ProjectSupervisor`), - * 这里用 `cloneElement` 把 `walletEntry` 补进去;元素形态不变时原样返回,不改变既有行为。 - */ - const supervisorSurface = - walletEntry && isValidElement(supervisor) - ? cloneElement(supervisor as ReactElement<{ walletEntry?: ReactNode }>, { - walletEntry, - }) - : supervisor; - const resourceSectionScrollKey = useCallback( (category: ResourceCategory, layoutMode = sortMode) => `${manifest.projectId}\n${layoutMode}\n${category}`, @@ -7824,6 +7809,9 @@ export default function ProjectDevelopmentView({ aria-label="策划工作台" >
+ {walletEntry ? ( +
{walletEntry}
+ ) : null}
与策划 Agent 对话 持续协作推进设计
- {walletEntry ? ( -
{walletEntry}
- ) : null} {supervisor} @@ -7857,6 +7842,9 @@ export default function ProjectDevelopmentView({ conversationOnlyWorkbench ? ' is-conversation-only' : '' }`} > + {walletEntry ? ( +
{walletEntry}
+ ) : null}
- {supervisorSurface} + {supervisor} ) : null} diff --git a/apps/ai-game-creator-shell/tests/chatPromptPolish.test.tsx b/apps/ai-game-creator-shell/tests/chatPromptPolish.test.tsx index 4439cd42f..70f6a7c34 100644 --- a/apps/ai-game-creator-shell/tests/chatPromptPolish.test.tsx +++ b/apps/ai-game-creator-shell/tests/chatPromptPolish.test.tsx @@ -21,7 +21,6 @@ import { shouldRemindChatPromptPolish, writeChatPromptPolishReminderDisabled, } from '../src/features/project-workspace/chatPromptPolish'; -import type { DirectCodexUserContentPart } from '../src/features/project-workspace/generated'; import type { ResourceReferenceInputHandle } from '../src/features/project-workspace/ResourceReferenceInput'; import { ResourceReferenceInput } from '../src/features/project-workspace/ResourceReferenceInput'; import { @@ -31,6 +30,7 @@ import { hasMeaningfulDirectCodexContent, resourceReferenceFromAsset, } from '../src/features/project-workspace/resourceReferences'; +import type { DirectCodexUserContentPart } from '../src/view/project-development/chat/generated/DirectCodexUserContentPart'; const storage = new Map(); Object.defineProperty(window, 'localStorage', {