/* eslint-disable react-refresh/only-export-components -- Testable pure helpers currently share this legacy app module. */ import { type FormEvent, type UIEvent, useCallback, useEffect, useLayoutEffect, useRef, useState, } from 'react'; import { type GameCreationAppCommandDescriptor, type GameCreationAppManifest, type GameCreationAppPreviewState, type GameCreationAppPreviewStatus, } from '../../../packages/shared/src/contracts/gameCreationApp'; import { AGENT_CHAT_SCROLL_BOTTOM_THRESHOLD, CONVERSATION_INITIAL_VISIBLE_COUNT, CONVERSATION_VISIBLE_STEP, createLocalProjectId, seedManifest, } from './app/constants'; import { useEscapeToClose } from './app/dialogs'; import { claimInitialTurnForPage } from './app/initialTurnClaims'; import { resolveTauriInvoke } from './app/tauri'; import type { AgentRuntimeResult, AgentRuntimeState, ChatMessage, DesignAgentInput, DesignClarificationRequest, DesignEvent, DesignView, InitLocalProjectResult, LauncherImportedAttachment, LocalConversationResult, LocalGameProjectRevisionStatus, LocalPreviewResult, LocalPreviewStatus, LocalProjectExportPackageResult, LocalProjectFileResult, LocalProjectKind, PendingUiConfirmation, ProjectPermissionPolicyView, TauriInvoke, } from './app/types'; import { GameDistributionPublishPanel } from './components/game-distribution/GameDistributionPublishPanel'; import { agentConversationId, agentRuntimeStateFromResult, createAgentChatRunId, createDefaultChatMessages, isAgentFinalizationMessageId, isMissingAgentRuntimeResumeCommandError, isRuntimeConfigMissingError, mergeAgentRuntimeStateIntoMap, projectProfessionalAgentLabel, taskRowsFromManifest, } from './features/agent-runtime'; import { isTransientProjectOpenMessage, latestVisibleItems, type ProjectChatComponentProps, readInitialProjectPath, type WorkspaceLauncherProps, writeRecentWorkspace, } from './features/app-shell/model'; import { WorkspaceLauncherShell } from './features/app-shell/WorkspaceLauncher'; import { projectAgentRuntimeSummaries, summarizeAgentRunCompletionForChat, } from './features/project-summary/agentPresentation'; import { isAbsoluteProjectPath, projectPathHasControlCharacter, } from './features/project-summary/projectSummary'; import { parseAgentRunTrace } from './features/project-workspace/agentRunTrace'; import { importDesignFiles } from './features/project-workspace/importDesignFiles'; import { parseRememberInput } from './features/project-workspace/memoryCommands'; import { isAgentTraceFilePath, needsInitializedChatProject, resolveChatProjectPath, } from './features/project-workspace/projectCommandPolicy'; import type { ResourceReferenceInputHandle } from './features/project-workspace/ResourceReferenceInput'; import { directCodexContentToLegacyContentDto, hasMeaningfulDirectCodexContent, RESOURCE_REFERENCE_INSERT_EVENT, type ResourceReferenceInsertEventDetail, } from './features/project-workspace/resourceReferences'; import { RuntimeConfigDialog } from './features/runtime-config/RuntimeConfigDialog'; import { readGamePublishAvailability } from './services/gameDistributionPublish'; import { setAgcPluginProjectPath, startAvailableAgcEditorPlugins, } from './services/pluginHost'; import { canSubscribeTauriEvents, subscribeTauriEvent, } from './services/tauriEventSubscription'; import type { HomeCreationType } from './view/home'; import { type ProjectAgentResultSummary, type ProjectAgentRuntimeSummary, } from './view/project-development'; import { DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX } from './view/project-development/chat/conversation/directCodexConversation'; import { directCodexAttachmentContentParts, toDirectCodexTurnAttachments, } from './view/project-development/chat/conversation/directCodexTurnAttachments'; import { type DirectProjectChatHandle, DirectProjectChatView, type DirectProjectInitialTurn, } from './view/project-development/chat/DirectProjectChatView'; import { PlanningChatView } from './view/project-development/planning/PlanningChatView'; import type { ProjectManifestSnapshotMetadata } from './view/project-development/projectResourceLiveUpdateModel'; function isPersistableDirectCodexConversationMessage(message: ChatMessage) { if (!message.runtimeOwned) { return false; } const messageId = message.messageId?.trim() ?? ''; const roleSuffix = `:${message.role}`; if ( !messageId.startsWith(DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX) || !messageId.endsWith(roleSuffix) ) { return false; } 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); } /** * 历史回读与"尚未落盘的运行时消息"合并。 * * 初始需求是**乐观插入**到 messages 的(latch 命中后先插一条 user 消息,再发起回合), * 而历史回读在 replace 分支里是无条件整体替换 —— 只要回读晚于乐观插入,那条用户消息 * 就会被冲掉(界面上看不到初始需求,但回合其实已经跑起来了)。 * 这里把当前 messages 里"运行时拥有、且回读结果里没有"的消息保留在末尾(它们是最新的)。 */ export { AuthenticatedClient } from './app/AuthenticatedClient'; export { deriveAgentStatusCards, summarizeAgentAudit, summarizeAgentRunTrace, } from './features/project-summary/agentPresentation'; export { isAbsoluteProjectPath } from './features/project-summary/projectSummary'; export { needsInitializedChatProject, parseRememberInput, resolveChatProjectPath, }; export function WorkspaceLauncher(props: WorkspaceLauncherProps) { return ; } type AppProps = { initialProjectPath?: string; initialProjectManifest?: GameCreationAppManifest; initialProjectKind?: LocalProjectKind; planningStartMode?: boolean; activeVersionId?: ProjectChatComponentProps['activeVersionId']; initialPlanningPrompt?: string; initialPlanningPromptClaimScope?: string; initialCreationType?: HomeCreationType | null; initialAttachments?: LauncherImportedAttachment[]; onDesignFilesImportingChange?: ProjectChatComponentProps['onDesignFilesImportingChange']; playRequest?: ProjectChatComponentProps['playRequest']; onPlayRequestHandled?: ProjectChatComponentProps['onPlayRequestHandled']; onManifestChange?: ( projectPath: string, manifest: GameCreationAppManifest, metadata?: ProjectManifestSnapshotMetadata, ) => void; onPreviewChange?: (preview: GameCreationAppPreviewState | null) => void; onAgentRuntimeSummariesChange?: ( summaries: ProjectAgentRuntimeSummary[], ) => void; onAgentResultsChange?: (results: ProjectAgentResultSummary[]) => void; }; /** * 工作台壳里由立项策划链路接管的提交入参。 * * DirectProject 的回合入参(`@` 引用、附件、权限确认重跑)归聊天容器自己的 * `DirectProjectTurnInput`,不再从这里穿过。 */ type ExecuteChatAgentReplyInput = { prompt: string; clientTurnId?: string; }; export function App({ initialProjectPath: initialProjectPathOverride = '', initialProjectManifest, initialProjectKind = 'web', planningStartMode = false, activeVersionId = null, initialPlanningPrompt = '', initialPlanningPromptClaimScope = '', initialCreationType = null, initialAttachments = [], onDesignFilesImportingChange, playRequest = null, onPlayRequestHandled, onManifestChange, onPreviewChange, onAgentRuntimeSummariesChange, onAgentResultsChange, }: AppProps = {}) { const [designAgentActive, setDesignAgentActive] = useState(planningStartMode); const designAgentActiveRef = useRef(planningStartMode); designAgentActiveRef.current = designAgentActive; const [designAgentView, setDesignAgentView] = useState( null, ); const designAgentLaneRef = useRef(planningStartMode); const designAgentTurnRef = useRef<{ projectPath: string; clientTurnId: string; } | null>(null); const designAgentEventSubscriptionReadyRef = useRef | null>( null, ); const designAgentEventSubscriptionResolveRef = useRef<(() => void) | null>( null, ); /** * 本轮策划回合的思考归属。 * * 与 `designAgentTurnRef` 分开:那个记录回合本身,回合结束就清空;这个只用来判定 * 「这条 reasoning 事件是不是本轮的」,回合结束后仍保留,最后一条思考不会在视图 * 落盘前的空隙里被丢掉。切项目时随其它聊天状态一起清空。 */ const designAgentReasoningTurnRef = useRef<{ projectPath: string; clientTurnId: string; } | null>(null); // 项目开发工作台的普通项目固定使用 DirectProject,立项策划项目走策划聊天;两条 // 链路各自独立,不通过兼容分支互相承载。 const directProjectMode = !designAgentActive; const [initialProjectPath] = useState( () => initialProjectPathOverride || readInitialProjectPath(), ); const eagerProject = Boolean(initialProjectPath); const [projectPath, setProjectPath] = useState(initialProjectPath); const [localProject, setLocalProject] = useState(() => eagerProject ? { projectPath: initialProjectPath, manifestPath: `${initialProjectPath.replace(/[\\/]+$/, '')}/.agent/manifest.json`, manifest: initialProjectManifest ?? seedManifest, } : null, ); const localProjectPathRef = useRef(null); useEffect(() => { const nextProjectPath = localProject?.projectPath ?? null; const previousProjectPath = localProjectPathRef.current; localProjectPathRef.current = nextProjectPath; // 未绑定项目时无需触发插件宿主;这也避免启动空首页时产生无意义的 Tauri 调用。 if (!nextProjectPath && !previousProjectPath) return; // 工程类型只决定「有哪几个编辑器插件可用」,不影响要不要尝试拉起:可用性由插件宿主 // 自己判(装配好的编辑器插件各自启动),未就绪时给出统一文案。 let active = true; void setAgcPluginProjectPath(nextProjectPath) .then(async () => { if (active && nextProjectPath) { await startAvailableAgcEditorPlugins(() => active); } }) .catch((error) => { if (!active || !nextProjectPath) { return; } setWorkspaceStatus( `编辑器插件未就绪:${ error instanceof Error ? error.message : String(error) }`, ); }); return () => { active = false; if (nextProjectPath || previousProjectPath) { void setAgcPluginProjectPath(null).catch(() => undefined); } localProjectPathRef.current = null; }; }, [localProject?.projectPath]); const manifestRefreshMountedRef = useRef(true); const manifestRefreshStatesRef = useRef( new Map< string, { pending: boolean; inFlight: Promise | null; } >(), ); const [manifest, setManifest] = useState( initialProjectManifest ?? seedManifest, ); const manifestRef = useRef(manifest); manifestRef.current = manifest; /** * 跟随工作台壳的清单快照。 * * 资源命令(改标签 / 改类型 / 重命名 / 删素材)由资源画布写入,写入后壳重读一次 * manifest、按 CAS 归并进 `currentProjectContext`,再把归并结果继续以这个 prop 传下来。 * 但它是 `useState` 的**初值**:壳里换了新清单不会再进来,于是聊天侧(`@` 选择器的 * 标签统计与候选、`@` 候选菜单)一直用挂载时那份旧清单 —— 用户改完标签,画布已经 * 更新,聊天侧却还是旧标签。 * * 这里把壳那份快照补进聊天侧状态,判据只有两条:同一项目、内容确实变了 * (按内容的短路是必要的:聊天侧自己的写入会被壳原样回传,只比身份会让两边 * 无意义地互相推一轮)。壳那份快照始终由磁盘重读 + CAS 归并得到,因此不会把 * 聊天侧带到更旧的版本上。 */ useEffect(() => { const snapshot = initialProjectManifest; const current = manifestRef.current; if (!snapshot || snapshot === current) return; if (snapshot.projectId !== current.projectId) return; if (JSON.stringify(snapshot) === JSON.stringify(current)) return; setManifest(snapshot); }, [initialProjectManifest]); const initialPlanningPromptLatchRef = useRef({ projectPath: initialProjectPath, prompt: initialPlanningPrompt.trim(), claimScope: initialPlanningPromptClaimScope, creationType: initialCreationType, attachments: toDirectCodexTurnAttachments(initialAttachments), }); const handledPlayRequestRef = useRef(null); function updateClientPreview( nextPreview: LocalPreviewResult | null, status: GameCreationAppPreviewStatus = nextPreview ? 'running' : 'stopped', ) { onPreviewChange?.( nextPreview ? { status, url: nextPreview.url, port: nextPreview.port, } : { status }, ); } const chatComposerRef = useRef(null); const [chatAgentBusy, setChatAgentBusy] = useState(false); // 发布到游戏广场:试玩包导出结果与面板开关由工作台壳持有,聊天容器只负责触发。 const [publishPackageResult, setPublishPackageResult] = useState(null); const [publishPanelOpen, setPublishPanelOpen] = useState(false); // 发布灰度:只有命中的账号才把「发布到游戏广场」入口交给聊天头;读取失败按不开放处理。 const [gamePublishAllowed, setGamePublishAllowed] = useState(false); const [projectChatError, setProjectChatError] = useState(''); const [designAgentTransientReply, setDesignAgentTransientReplyVisible] = useState(''); const designAgentTransientReplyTargetRef = useRef(''); const designAgentVisibleReplyRef = useRef(''); const designAgentPendingViewRef = useRef<{ clientTurnId: string; projectPath: string; view: DesignView; } | null>(null); const [designAgentReasoning, setDesignAgentReasoning] = useState(''); function setDesignAgentTransientReplyTarget(next: string) { designAgentTransientReplyTargetRef.current = next; if (!next) { designAgentVisibleReplyRef.current = ''; setDesignAgentTransientReplyVisible(''); } } function designAgentEventSubscriptionReady() { // 业务动作只读取当前订阅代次;清理与下一次 effect 建立之间不能创建 // 一个没有订阅 effect 接管的悬挂 promise。 return designAgentEventSubscriptionReadyRef.current ?? Promise.resolve(); } function createDesignAgentEventSubscriptionReady() { if (!designAgentEventSubscriptionReadyRef.current) { designAgentEventSubscriptionReadyRef.current = new Promise( (resolve) => { designAgentEventSubscriptionResolveRef.current = resolve; }, ); } return designAgentEventSubscriptionReadyRef.current; } function resolveDesignAgentEventSubscriptionReady() { designAgentEventSubscriptionResolveRef.current?.(); designAgentEventSubscriptionResolveRef.current = null; } useEffect(() => { const timer = window.setInterval(() => { const target = designAgentTransientReplyTargetRef.current; setDesignAgentTransientReplyVisible((current) => { if (!target) { designAgentVisibleReplyRef.current = ''; return ''; } const prefix = target.startsWith(current) ? current : ''; if (prefix === target) { designAgentVisibleReplyRef.current = target; return target; } const remaining = target.length - prefix.length; const step = remaining > 160 ? 4 : remaining > 48 ? 2 : 1; const next = target.slice(0, prefix.length + step); designAgentVisibleReplyRef.current = next; return next; }); }, 50); return () => window.clearInterval(timer); }, []); /** * 策划 Agent 的实时事件流:本轮流式正文、思考过程和回合中途的视图都靠它推给界面。 * * 订阅建立是异步的,而回合由一个 invoke 发起;`designAgentEventSubscriptionReady()` * 让回合等监听器挂好再开始,避免开头几个事件丢掉。事件只认当前项目;有在跑的回合时 * 还要认本轮 `clientTurnId`,迟到的上一轮事件不会画到这一轮上。 */ useEffect(() => { const ready = createDesignAgentEventSubscriptionReady(); if (!canSubscribeTauriEvents() || !designAgentActive) { resolveDesignAgentEventSubscriptionReady(); return () => { if (designAgentEventSubscriptionReadyRef.current === ready) { designAgentEventSubscriptionReadyRef.current = null; createDesignAgentEventSubscriptionReady(); } }; } let cleanup: (() => void) | null = null; let disposed = false; void subscribeTauriEvent('design-agent-update', (event) => { const payload = event.payload; const tracked = designAgentTurnRef.current; if ( payload.projectPath !== localProjectPathRef.current || (tracked && payload.clientTurnId !== tracked.clientTurnId) ) { return; } if ( (payload.kind === 'text' || payload.kind === 'tool') && payload.text ) { setDesignAgentTransientReplyTarget(payload.text); } if (payload.reasoningText != null) { const reasoningTurn = designAgentReasoningTurnRef.current; if ( reasoningTurn?.projectPath === payload.projectPath && reasoningTurn.clientTurnId === payload.clientTurnId ) { setDesignAgentReasoning(payload.reasoningText); } } if (payload.view) { applyDesignAgentViewAfterTransient( payload.view, payload.projectPath, payload.clientTurnId, ); } }) .then((unlisten) => { resolveDesignAgentEventSubscriptionReady(); if (disposed) { unlisten(); return; } cleanup = unlisten; }) .catch(() => { resolveDesignAgentEventSubscriptionReady(); }); return () => { disposed = true; cleanup?.(); resolveDesignAgentEventSubscriptionReady(); if (designAgentEventSubscriptionReadyRef.current === ready) { designAgentEventSubscriptionReadyRef.current = null; createDesignAgentEventSubscriptionReady(); } }; // 订阅只跟着「是否走策划 Agent」这条链路;回调里的项目/回合判据都走 refs, // 把它们写进依赖会在每轮回复时重订事件。 // eslint-disable-next-line react-hooks/exhaustive-deps }, [designAgentActive]); function designMessagesToChat(view: DesignView): ChatMessage[] { const reasoningByMessageId = new Map(); for (const entry of view.reasoningEntries ?? []) { if (!entry.messageId) { continue; } const texts = reasoningByMessageId.get(entry.messageId) ?? []; texts.push(entry.text); reasoningByMessageId.set(entry.messageId, texts); } // 持久策划消息没有发送时间,不能把读取时刻显示成历史发送时间。 const messages: ChatMessage[] = view.messages .filter((message) => message.text.trim()) .map((message) => ({ role: message.role === 'user' ? 'user' : 'assistant', text: message.text, runtimeOwned: true, messageId: message.id, reasoningText: reasoningByMessageId.get(message.id)?.join('\n\n'), })); const initialPrompt = initialPlanningPromptLatchRef.current.prompt; if ( initialPrompt && !messages.some( (message) => message.role === 'user' && message.text === initialPrompt, ) ) { messages.unshift({ role: 'user', text: initialPrompt, runtimeOwned: true, }); } return messages; } function applyDesignView(view: DesignView, projectPath: string) { designAgentLaneRef.current = true; setDesignAgentView(view); designAgentActiveRef.current = true; setDesignAgentActive(true); setProjectChatError(view.session.lastError ?? ''); setChatAgentBusy(view.running); const conversation = designMessagesToChat(view); setMessages(conversation); savedConversationProjectPathRef.current = projectPath; savedConversationCountRef.current = conversation.length; latestMessagesRef.current = conversation; } function commitDesignAgentView(view: DesignView, projectPath: string) { const pendingTurnId = designAgentPendingViewRef.current?.clientTurnId; designAgentPendingViewRef.current = null; applyDesignView(view, projectPath); setDesignAgentReasoning(''); setDesignAgentTransientReplyTarget(''); if (designAgentTurnRef.current?.clientTurnId === pendingTurnId) { designAgentTurnRef.current = null; } } function applyDesignAgentViewAfterTransient( view: DesignView, projectPath: string, clientTurnId: string, ) { let target = designAgentTransientReplyTargetRef.current; const tracked = designAgentTurnRef.current; if (!target.trim() && !view.running) { const latestAssistantText = [...view.messages] .reverse() .find((message) => message.role !== 'user' && message.text.trim()) ?.text.trim(); if (latestAssistantText) { setDesignAgentTransientReplyTarget(latestAssistantText); target = latestAssistantText; } } if ( !view.running && tracked?.clientTurnId === clientTurnId && target.trim() && designAgentVisibleReplyRef.current !== target ) { designAgentPendingViewRef.current = { clientTurnId, projectPath, view, }; return; } commitDesignAgentView(view, projectPath); } useEffect(() => { const timer = window.setInterval(() => { const pending = designAgentPendingViewRef.current; if (!pending) { return; } const target = designAgentTransientReplyTargetRef.current; if (target && designAgentVisibleReplyRef.current !== target) { return; } commitDesignAgentView(pending.view, pending.projectPath); }, 50); return () => window.clearInterval(timer); // 收尾定时器只需注册一次;它读取 refs,避免随每次渲染重建。 // eslint-disable-next-line react-hooks/exhaustive-deps }, []); async function hydrateDesignAgentSession(nextProjectPath: string) { const invoke = resolveTauriInvoke(); if (!invoke || !nextProjectPath.trim()) { return null; } const view = await invoke( 'hydrate_design_agent_session', { projectPath: nextProjectPath }, ); if (localProjectPathRef.current !== nextProjectPath) { return null; } if (!view) { return null; } applyDesignView(view, nextProjectPath); return view; } async function executeDesignAgentTurn( nextProjectPath: string, input: DesignAgentInput, clientTurnId = createAgentChatRunId('design-agent-turn'), ) { const invoke = resolveTauriInvoke(); if (!invoke) { setProjectChatError('需要在 Tauri App 内运行。'); return; } designAgentTurnRef.current = { projectPath: nextProjectPath, clientTurnId, }; designAgentReasoningTurnRef.current = { projectPath: nextProjectPath, clientTurnId, }; designAgentPendingViewRef.current = null; await designAgentEventSubscriptionReady(); setChatAgentBusy(true); setProjectChatError(''); setDesignAgentTransientReplyTarget(''); setDesignAgentReasoning(''); try { const view = await invoke('continue_design_agent_session', { projectPath: nextProjectPath, clientTurnId, input, }); if (localProjectPathRef.current !== nextProjectPath) { return; } applyDesignAgentViewAfterTransient(view, nextProjectPath, clientTurnId); } catch (error) { if (localProjectPathRef.current !== nextProjectPath) { return; } const message = error instanceof Error ? error.message : String(error); if (isRuntimeConfigMissingError(message)) { requestRuntimeConfigOpen(); } setProjectChatError(message); } finally { if (!designAgentPendingViewRef.current) { designAgentTurnRef.current = null; setDesignAgentTransientReplyTarget(''); } setChatAgentBusy(false); } } const [chatFilesImporting, setChatFilesImporting] = useState(false); const [chatFileImportNotice, setChatFileImportNotice] = useState(''); const planningChatMessagesRef = useRef(null); const planningChatShouldFollowLatestRef = useRef(true); const directProjectChatRef = useRef(null); const [agentRuntimeById, setAgentRuntimeById] = useState< Record >({}); const agentRuntimeByIdRef = useRef(agentRuntimeById); agentRuntimeByIdRef.current = agentRuntimeById; const [professionalAgentResultsById, setProfessionalAgentResultsById] = useState>({}); const [workspaceStatus, setWorkspaceStatus] = useState( eagerProject ? `已打开:${initialProjectPath}` : '请选择工作区', ); const [messages, setMessages] = useState( createDefaultChatMessages, ); const [conversationVisibleCount, setConversationVisibleCount] = useState( CONVERSATION_INITIAL_VISIBLE_COUNT, ); const [pendingUiConfirmation, setPendingUiConfirmation] = useState(null); const [pendingNonEmptyProjectCreate, setPendingNonEmptyProjectCreate] = useState<{ projectPath: string; announceToChat: boolean; } | null>(null); const [conversationWriteVersion, setConversationWriteVersion] = useState(0); const savedConversationCountRef = useRef( eagerProject ? createDefaultChatMessages().length : 0, ); const savedConversationProjectPathRef = useRef( eagerProject ? initialProjectPath : null, ); const projectConversationWriteConfirmedRef = useRef(null); const projectConversationWriteCancelledRef = useRef<{ projectPath: string; messageCount: number; } | null>(null); const latestMessagesRef = useRef([]); const conversationWriteInFlightRef = useRef(false); const projectScopeVersionRef = useRef(0); const refreshManifest = useCallback( (nextProjectPath = localProjectPathRef.current ?? ''): Promise => { const invoke = resolveTauriInvoke(); if (!invoke || !nextProjectPath) { return Promise.resolve(); } const refreshStates = manifestRefreshStatesRef.current; let refreshState = refreshStates.get(nextProjectPath); if (!refreshState) { refreshState = { pending: false, inFlight: null }; refreshStates.set(nextProjectPath, refreshState); } refreshState.pending = true; if (refreshState.inFlight) { return refreshState.inFlight; } const activeRefreshState = refreshState; const refreshPromise = (async () => { try { while (activeRefreshState.pending) { activeRefreshState.pending = false; const projectScopeVersion = projectScopeVersionRef.current; try { const nextManifest = await invoke( 'get_local_game_manifest', { projectPath: nextProjectPath }, ); if ( manifestRefreshMountedRef.current && localProjectPathRef.current === nextProjectPath && projectScopeVersionRef.current === projectScopeVersion ) { setManifest(nextManifest); } } catch { // Dev-only convenience; command errors are surfaced by the action that triggered them. } if ( !manifestRefreshMountedRef.current || localProjectPathRef.current !== nextProjectPath || projectScopeVersionRef.current !== projectScopeVersion ) { activeRefreshState.pending = false; } } } finally { activeRefreshState.inFlight = null; if (!activeRefreshState.pending) { refreshStates.delete(nextProjectPath); } } })(); activeRefreshState.inFlight = refreshPromise; return refreshPromise; }, [], ); useEffect(() => { const refreshStates = manifestRefreshStatesRef.current; manifestRefreshMountedRef.current = true; return () => { manifestRefreshMountedRef.current = false; refreshStates.clear(); }; }, []); const executeChatAgentReplyRef = useRef< (input: ExecuteChatAgentReplyInput) => Promise >(async () => undefined); const agentRuntimeResumeProjectPathRef = useRef(null); const initialProjectOpenedRef = useRef(false); const pendingUiConfirmationActionRef = useRef<(() => void) | null>(null); const executeRunLocalRef = useRef<(announceToChat: boolean) => void>( () => undefined, ); const [runtimeConfigOpen, setRuntimeConfigOpen] = useState(false); function requestRuntimeConfigOpen() { setRuntimeConfigOpen(true); } useEscapeToClose(cancelUiCommandConfirmation, pendingUiConfirmation !== null); useEscapeToClose( cancelProjectCreateInNonEmptyFolder, pendingNonEmptyProjectCreate !== null, ); useEffect(() => { if ( pendingUiConfirmation?.projectPath && pendingUiConfirmation.projectPath !== resolveCurrentUiConfirmationProjectPath() ) { cancelUiCommandConfirmation(); } // The callbacks intentionally read the latest project refs. // eslint-disable-next-line react-hooks/exhaustive-deps }, [localProject?.projectPath, projectPath]); useEffect(() => { if (!initialProjectPath || initialProjectOpenedRef.current) { return; } initialProjectOpenedRef.current = true; if (!isAbsoluteProjectPath(initialProjectPath)) { setWorkspaceStatus('请提供工作区绝对路径'); return; } if (projectPathHasControlCharacter(initialProjectPath)) { setWorkspaceStatus('工作区路径不能包含控制字符'); return; } if (directProjectMode) { // This component is mounted inside the already-created project workbench. // Hydrate localProject before the first direct turn so the direct runtime // cannot silently create a second workspace. void openWorkspace(initialProjectPath, false, 'open', initialProjectKind); return; } localProjectPathRef.current = initialProjectPath; void loadProjectConversation(initialProjectPath).finally(() => { if ( localProjectPathRef.current === initialProjectPath && !planningStartMode ) { void refreshAgentRunTrace(initialProjectPath); } }); // Initial project opening is guarded by initialProjectOpenedRef. // eslint-disable-next-line react-hooks/exhaustive-deps }, [initialProjectPath]); useLayoutEffect(() => { if (!planningChatShouldFollowLatestRef.current) { return; } const messageList = planningChatMessagesRef.current; if (messageList) { messageList.scrollTop = messageList.scrollHeight; } }, [ messages, projectChatError, designAgentTransientReply, designAgentReasoning, designAgentView, pendingUiConfirmation, chatFileImportNotice, ]); useEffect(() => { latestMessagesRef.current = messages; const invoke = resolveTauriInvoke(); const nextProjectPath = localProject?.projectPath; if (!invoke || !nextProjectPath) { return; } if (savedConversationProjectPathRef.current !== nextProjectPath) { savedConversationProjectPathRef.current = nextProjectPath; savedConversationCountRef.current = 0; projectConversationWriteConfirmedRef.current = null; projectConversationWriteCancelledRef.current = null; } // DirectProject history is written by Rust from raw app-server items. // The browser only renders that projection and must not append chat rows. if (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 // browser conversation writer. savedConversationCountRef.current = messages.length; return; } const start = savedConversationCountRef.current; const pendingMessages = messages.slice(start); if (pendingMessages.length === 0) { return; } if ( pendingMessages.every( (message) => (message.runtimeOwned && !isPersistableDirectCodexConversationMessage(message)) || isTransientProjectOpenMessage(message, nextProjectPath), ) ) { savedConversationCountRef.current = messages.length; return; } if (conversationWriteInFlightRef.current) { return; } if (pendingUiConfirmation) { return; } if ( projectConversationWriteCancelledRef.current?.projectPath === nextProjectPath && projectConversationWriteCancelledRef.current.messageCount === messages.length ) { return; } if ( projectConversationWriteCancelledRef.current?.projectPath === nextProjectPath && projectConversationWriteCancelledRef.current.messageCount < messages.length ) { projectConversationWriteCancelledRef.current = null; } if (projectConversationWriteConfirmedRef.current !== nextProjectPath) { void invoke( 'read_project_permission_policy', { projectPath: nextProjectPath }, ) .then((policyView) => { if ( !policyView.policy.confirmCommands.includes('conversation.write') ) { projectConversationWriteConfirmedRef.current = nextProjectPath; setConversationWriteVersion((current) => current + 1); return; } requestProjectPolicyConfirmation( 'conversation.write', nextProjectPath, `写入 ${nextProjectPath} 的项目对话`, () => { projectConversationWriteConfirmedRef.current = nextProjectPath; setConversationWriteVersion((current) => current + 1); }, ); setWorkspaceStatus('等待确认保存项目对话'); }) .catch(() => { projectConversationWriteConfirmedRef.current = nextProjectPath; setConversationWriteVersion((current) => current + 1); }); return; } conversationWriteInFlightRef.current = true; void (async () => { for (const [index, message] of pendingMessages.entries()) { if ( message.runtimeOwned && !isPersistableDirectCodexConversationMessage(message) ) { savedConversationCountRef.current = start + index + 1; continue; } if (isTransientProjectOpenMessage(message, nextProjectPath)) { savedConversationCountRef.current = start + index + 1; continue; } await invoke( 'append_local_conversation_message', { projectPath: nextProjectPath, agentId: null, ...(message.messageId ? { messageId: message.messageId } : {}), message: { role: message.role === 'user' ? 'user' : 'assistant', content: message.text, agentId: null, ...(typeof message.updatedAt === 'number' ? { updatedAt: message.updatedAt } : {}), }, }, ); savedConversationCountRef.current = start + index + 1; } setWorkspaceStatus((current) => current.startsWith('项目对话保存失败') || current === '等待确认保存项目对话' ? `已打开:${nextProjectPath}` : current, ); })() .catch((error) => { savedConversationCountRef.current = Math.min( savedConversationCountRef.current, start, ); setWorkspaceStatus( `项目对话保存失败:${ error instanceof Error ? error.message : String(error) }`, ); }) .finally(() => { conversationWriteInFlightRef.current = false; if ( savedConversationCountRef.current < latestMessagesRef.current.length ) { setConversationWriteVersion((current) => current + 1); } }); // Writes are driven by message and confirmation state; the confirmation helper // intentionally observes the latest policy refs. // eslint-disable-next-line react-hooks/exhaustive-deps }, [ localProject?.projectPath, messages, conversationWriteVersion, pendingUiConfirmation, directProjectMode, ]); function appendLocalPermissionLog( projectPath: string | null, event: | 'permission.pending' | 'permission.confirm' | 'permission.cancel' | 'command.auto', commandId: GameCreationAppCommandDescriptor['id'], ) { const invoke = resolveTauriInvoke(); if (!invoke || !projectPath) { return; } try { void Promise.resolve( invoke('append_local_permission_log', { projectPath, event, commandId, }), ).catch(() => undefined); } catch { // 权限日志只是审计旁路,写失败不能阻塞对话。 } } function resolveUiPermissionLogProjectPath( commandId: GameCreationAppCommandDescriptor['id'], ) { if (commandId === 'project.create') { return null; } if (needsInitializedChatProject(commandId)) { return resolveChatProjectPath(localProject); } return projectPath; } function resolveCurrentUiConfirmationProjectPath() { const openedProjectPath = localProjectPathRef.current?.trim(); if (openedProjectPath) { return openedProjectPath; } const draftProjectPath = projectPath.trim(); if ( !draftProjectPath || !isAbsoluteProjectPath(draftProjectPath) || projectPathHasControlCharacter(draftProjectPath) ) { return null; } return draftProjectPath; } function requestProjectPolicyConfirmation( commandId: GameCreationAppCommandDescriptor['id'], projectPath: string, detail: string, onConfirm: () => void, ) { pendingUiConfirmationActionRef.current = onConfirm; setPendingUiConfirmation({ commandId, detail, projectPath }); appendLocalPermissionLog(projectPath, 'permission.pending', commandId); } function markProjectPolicyDenied( commandId: GameCreationAppCommandDescriptor['id'], message: string, ) { if ( commandId.startsWith('project.') || commandId === 'task.list' || commandId.startsWith('preview.') ) { setWorkspaceStatus(message); } } async function queueProjectPolicyConfirmationIfNeeded( invoke: TauriInvoke, commandId: GameCreationAppCommandDescriptor['id'], projectPath: string, detail: string, readyMessage: string, onConfirm: () => void, ) { const policyView = await invoke( 'read_project_permission_policy', { projectPath }, ); if (policyView.policy.deniedCommands.includes(commandId)) { const message = `项目权限策略拒绝执行:${commandId}`; markProjectPolicyDenied(commandId, message); setMessages((current) => [ ...current, { role: 'assistant', text: message }, ]); return true; } if (!policyView.policy.confirmCommands.includes(commandId)) { return false; } requestProjectPolicyConfirmation(commandId, projectPath, detail, onConfirm); setMessages((current) => [ ...current, { role: 'assistant', text: readyMessage }, ]); return true; } async function denyPendingCommandIfNeeded( commandId: GameCreationAppCommandDescriptor['id'], projectPath: string | null, ) { const invoke = resolveTauriInvoke(); if (!invoke || !projectPath) { return false; } try { const policyView = await invoke( 'read_project_permission_policy', { projectPath }, ); if (!policyView.policy.deniedCommands.includes(commandId)) { return false; } const message = `项目权限策略拒绝执行:${commandId}`; markProjectPolicyDenied(commandId, message); setMessages((current) => [ ...current, { role: 'assistant', text: message }, ]); return true; } catch { return false; } } useEffect(() => { let cancelled = false; void readGamePublishAvailability() .then((allowed) => { if (!cancelled) setGamePublishAllowed(allowed); }) .catch(() => { if (!cancelled) setGamePublishAllowed(false); }); return () => { cancelled = true; }; }, [localProject?.projectPath]); /** * 导出试玩包并打开发布面板。 * * 权限口径沿用本地命令:`project.export_package` 需要确认时先入队,确认后再导出; * 导出结果只留在壳里,发布面板关闭即丢弃,不写入项目。 */ async function requestGamePublish() { const invoke = resolveTauriInvoke(); if (!invoke) { setWorkspaceStatus('需要在 Tauri App 内发布'); return; } const nextProjectPath = resolveChatProjectPath(localProject) ?? projectPath.trim(); if (!nextProjectPath) { setWorkspaceStatus('先打开一个项目再发布'); return; } const runExport = async () => { try { const result = await invoke( 'export_local_project_package', { projectPath: nextProjectPath }, ); setWorkspaceStatus(`已导出本地试玩包:${result.packageRelativePath}`); setPublishPackageResult(result); setPublishPanelOpen(true); appendLocalPermissionLog( nextProjectPath, 'command.auto', 'project.export_package', ); } catch (error) { setWorkspaceStatus( error instanceof Error ? error.message : String(error), ); } }; const queued = await queueProjectPolicyConfirmationIfNeeded( invoke, 'project.export_package', nextProjectPath, '导出试玩包并打开「发布到游戏广场」面板。', '导出试玩包需要确认,确认后继续。', () => void runExport(), ); if (!queued) { await runExport(); } } async function confirmUiCommand() { const pending = pendingUiConfirmation; if (!pending) { return; } if ( pending.projectPath && pending.projectPath !== resolveCurrentUiConfirmationProjectPath() ) { cancelUiCommandConfirmation(); return; } const permissionProjectPath = pending.projectPath ?? resolveUiPermissionLogProjectPath(pending.commandId); if ( await denyPendingCommandIfNeeded(pending.commandId, permissionProjectPath) ) { pendingUiConfirmationActionRef.current = null; setPendingUiConfirmation(null); return; } const action = pendingUiConfirmationActionRef.current; pendingUiConfirmationActionRef.current = null; setPendingUiConfirmation(null); appendLocalPermissionLog( permissionProjectPath, 'permission.confirm', pending.commandId, ); action?.(); } function cancelUiCommandConfirmation() { const pending = pendingUiConfirmation; if (!pending) { return; } if (pending.commandId === 'conversation.write') { const nextProjectPath = resolveChatProjectPath(localProject); if (nextProjectPath) { projectConversationWriteCancelledRef.current = { projectPath: nextProjectPath, messageCount: latestMessagesRef.current.length, }; setWorkspaceStatus((current) => current === '等待确认保存项目对话' ? `已打开:${nextProjectPath}` : current, ); } } if (pending.commandId === 'conversation.read') { if (!pending.detail.includes('Agent 对话')) { setWorkspaceStatus((current) => current === '等待确认' ? '已取消读取项目对话' : current, ); } } if (pending.commandId === 'memory.read') { if (!pending.detail.includes('Agent 私有记忆')) { setMessages((current) => [ ...current, { role: 'assistant', text: '已取消读取项目记忆。' }, ]); } } if ( pending.commandId === 'project.status' || pending.commandId === 'task.list' ) { const status = pending.commandId === 'task.list' ? '已取消读取任务拆分' : '已取消读取项目状态'; setWorkspaceStatus(status); } if (pending.commandId === 'asset.list') { setWorkspaceStatus('已取消读取项目资产'); } if ( pending.commandId === 'preview.start' || pending.commandId === 'preview.open' || pending.commandId === 'preview.stop' || pending.commandId === 'preview.status' ) { setWorkspaceStatus('已取消预览操作'); setMessages((current) => [ ...current, { role: 'assistant', text: '已取消预览操作' }, ]); } if ( pending.commandId === 'project.index' || pending.commandId === 'project.diff' || pending.commandId === 'project.export_package' ) { const status = pending.commandId === 'project.index' ? '已取消刷新项目索引' : pending.commandId === 'project.diff' ? '已取消对比项目 checkpoint' : '已取消导出本地试玩包'; setWorkspaceStatus(status); } if (pending.commandId === 'project.create') { setWorkspaceStatus('已取消'); } pendingUiConfirmationActionRef.current = null; setPendingUiConfirmation(null); appendLocalPermissionLog( pending.projectPath ?? resolveUiPermissionLogProjectPath(pending.commandId), 'permission.cancel', pending.commandId, ); } executeRunLocalRef.current = executeRunLocal; useEffect(() => { const nextProjectPath = localProject?.projectPath; if ( !playRequest || !nextProjectPath || playRequest.projectPath !== nextProjectPath ) { return; } const requestKey = `${playRequest.projectPath}\n${playRequest.requestId}`; if (handledPlayRequestRef.current === requestKey) { return; } handledPlayRequestRef.current = requestKey; onPlayRequestHandled?.(playRequest.requestId); void executeRunLocalRef.current(true); }, [localProject?.projectPath, onPlayRequestHandled, playRequest]); /** * 切换项目作用域时把这条链路自己的聊天状态清干净。 * * 策划会话与设计 Agent 视图都属于上一个项目;项目身份一变就不能留到下一个项目里。 */ function resetChatState() { setChatFilesImporting(false); setChatFileImportNotice(''); setProjectChatError(''); setDesignAgentTransientReplyTarget(''); designAgentPendingViewRef.current = null; setDesignAgentReasoning(''); setDesignAgentActive(planningStartMode); designAgentActiveRef.current = planningStartMode; designAgentLaneRef.current = planningStartMode; designAgentTurnRef.current = null; designAgentReasoningTurnRef.current = null; setDesignAgentView(null); } async function loadProjectConversation( nextProjectPath: string, skipPolicyConfirm = false, ) { const invoke = resolveTauriInvoke(); if (!invoke) { return; } if (!skipPolicyConfirm) { try { const policyView = await invoke( 'read_project_permission_policy', { projectPath: nextProjectPath }, ); if (policyView.policy.confirmCommands.includes('conversation.read')) { requestProjectPolicyConfirmation( 'conversation.read', nextProjectPath, `读取 ${nextProjectPath} 的项目对话历史`, () => void loadProjectConversation(nextProjectPath, true), ); setWorkspaceStatus('等待确认'); return; } } catch { return; } } try { const design = await hydrateDesignAgentSession(nextProjectPath); if (design) { if (localProjectPathRef.current !== nextProjectPath) { return; } setWorkspaceStatus((workspaceStatus) => workspaceStatus === '等待确认' ? `已打开:${nextProjectPath}` : workspaceStatus, ); return; } } catch (error) { if (planningStartMode) { setProjectChatError( `策划会话无法恢复:${error instanceof Error ? error.message : String(error)}`, ); } } if (planningStartMode) { if (localProjectPathRef.current !== nextProjectPath) { return; } designAgentActiveRef.current = true; setDesignAgentActive(true); designAgentLaneRef.current = true; setDesignAgentView(null); setMessages(createDefaultChatMessages()); savedConversationProjectPathRef.current = nextProjectPath; savedConversationCountRef.current = 0; latestMessagesRef.current = []; setWorkspaceStatus((workspaceStatus) => workspaceStatus === '等待确认' ? `已打开:${nextProjectPath}` : workspaceStatus, ); return; } // DirectProject 的项目对话由聊天容器自己的订阅与切片读取拥有:工作台壳不读项目 // 对话文件,也不把历史混进壳自己的 `messages`。 setProjectChatError(''); setWorkspaceStatus((workspaceStatus) => workspaceStatus === '等待确认' ? `已打开:${nextProjectPath}` : workspaceStatus, ); } async function openWorkspace( nextProjectPath: string, announceToChat: boolean, mode: 'create' | 'open' = 'create', // 工程类型已不再由壳层决定插件可用性(插件宿主自己判),保留入参只为调用契约。 _projectKind: LocalProjectKind = 'web', ) { const trimmedProjectPath = nextProjectPath.trim(); if (!trimmedProjectPath || !isAbsoluteProjectPath(trimmedProjectPath)) { setWorkspaceStatus('请提供工作区绝对路径'); return; } if (projectPathHasControlCharacter(trimmedProjectPath)) { setWorkspaceStatus('工作区路径不能包含控制字符'); return; } const invoke = resolveTauriInvoke(); if (!invoke) { setWorkspaceStatus('需要在 Tauri App 内运行'); if (announceToChat) { setMessages((current) => [ ...current, { role: 'assistant', text: '需要在 Tauri App 内运行。' }, ]); } return; } const projectScopeVersion = projectScopeVersionRef.current + 1; projectScopeVersionRef.current = projectScopeVersion; resetChatState(); updateClientPreview(null); setWorkspaceStatus('正在打开'); try { const result = mode === 'open' ? { projectPath: trimmedProjectPath, manifestPath: `${trimmedProjectPath.replace(/[\\/]+$/, '')}/.agent/manifest.json`, manifest: await invoke( 'get_local_game_manifest', { projectPath: trimmedProjectPath }, ), } : await invoke('init_local_game_project', { projectPath: trimmedProjectPath, projectId: createLocalProjectId(), name: seedManifest.name, }); if (projectScopeVersionRef.current !== projectScopeVersion) { return; } const openedProjectPath = result.projectPath.trim(); if ( !openedProjectPath || !isAbsoluteProjectPath(openedProjectPath) || projectPathHasControlCharacter(openedProjectPath) ) { const message = '本地项目路径无效'; setWorkspaceStatus(message); if (announceToChat) { setMessages((current) => [ ...current, { role: 'assistant', text: message }, ]); } return; } const openedProject = { ...result, projectPath: openedProjectPath }; const conversationMessages = createDefaultChatMessages(); if ( pendingUiConfirmation?.projectPath && pendingUiConfirmation.projectPath !== openedProject.projectPath ) { cancelUiCommandConfirmation(); } localProjectPathRef.current = openedProject.projectPath; setProjectPath(openedProject.projectPath); setLocalProject(openedProject); setManifest(openedProject.manifest); setAgentRuntimeById({}); setMessages(conversationMessages); setConversationVisibleCount(CONVERSATION_INITIAL_VISIBLE_COUNT); savedConversationProjectPathRef.current = openedProject.projectPath; savedConversationCountRef.current = conversationMessages.length; writeRecentWorkspace(openedProject.projectPath); setWorkspaceStatus(`已打开:${openedProject.projectPath}`); appendLocalPermissionLog( openedProject.projectPath, 'permission.confirm', mode === 'open' ? 'project.status' : 'project.create', ); if (announceToChat) { setMessages((current) => [ ...current, { role: 'assistant', text: `已设置本地项目:${openedProject.projectPath}`, }, ]); } void loadProjectConversation(openedProject.projectPath); if (!directProjectMode) { void refreshAgentRunTrace(openedProject.projectPath); } } catch (error) { if (projectScopeVersionRef.current !== projectScopeVersion) { return; } const message = error instanceof Error ? error.message : String(error); setWorkspaceStatus(message); if (announceToChat) { setMessages((current) => [ ...current, { role: 'assistant', text: message }, ]); } } } useEffect(() => { const handleResourceReferenceInsert = (event: Event) => { const detail = (event as CustomEvent) .detail; if (!detail?.reference) return; chatComposerRef.current?.insertReferences([detail.reference]); chatComposerRef.current?.focus(); }; window.addEventListener( RESOURCE_REFERENCE_INSERT_EVENT, handleResourceReferenceInsert, ); return () => window.removeEventListener( RESOURCE_REFERENCE_INSERT_EVENT, handleResourceReferenceInsert, ); }, []); /** * DirectProject 聊天的首轮需求:入口 latch 里那份原文与附件,项目匹配后由聊天认领。 * * 认领记录与 Design / Planning 入口共用一份(按页面保存),因此同一条 * 首轮需求不会被两个入口各发一次。 */ const initialDirectTurn: DirectProjectInitialTurn | null = (() => { const latch = initialPlanningPromptLatchRef.current; if (!latch.prompt || !latch.projectPath) return null; return { projectPath: latch.projectPath, // 首轮需求按 Composer 的 canonical content 形状交给聊天:文本与附件引用内联在 // 同一份 content 里,`@` 引用与附件的处理不需要两套入参。 content: [ { type: 'input_text', text: latch.prompt }, ...directCodexAttachmentContentParts(latch.attachments), ], creationType: latch.creationType, claimScope: latch.claimScope, }; })(); /** * DirectProject 读自己项目对话的门:策略要求确认时入队确认,读取由回调继续。 */ async function ensureDirectHistoryReadAllowed(input: { projectPath: string; onConfirmed: () => void; }) { const invoke = resolveTauriInvoke(); if (!invoke) return false; try { const policyView = await invoke( 'read_project_permission_policy', { projectPath: input.projectPath }, ); if (!policyView.policy.confirmCommands.includes('conversation.read')) { return true; } requestProjectPolicyConfirmation( 'conversation.read', input.projectPath, `读取 ${input.projectPath} 的项目对话历史`, input.onConfirmed, ); setWorkspaceStatus('等待确认'); return false; } catch { return false; } } /** * DirectProject 写自己项目对话的门:策略要求确认时入队确认,确认后重跑同一轮。 */ async function ensureDirectTurnWriteAllowed(input: { projectPath: string; onConfirmed: () => void; }) { const invoke = resolveTauriInvoke(); if (!invoke) return false; if (projectConversationWriteConfirmedRef.current === input.projectPath) { return true; } try { const paused = await queueProjectPolicyConfirmationIfNeeded( invoke, 'conversation.write', input.projectPath, '写入 DirectProject 对话历史', 'DirectProject 对话写入需要确认。', () => { projectConversationWriteConfirmedRef.current = input.projectPath; input.onConfirmed(); }, ); if (paused) return false; projectConversationWriteConfirmedRef.current = input.projectPath; return true; } catch (error) { if (localProjectPathRef.current === input.projectPath) { setProjectChatError( `DirectProject 对话权限检查失败:${ error instanceof Error ? error.message : String(error) }`, ); } return false; } } /** * 工作台壳要把一句结果说给用户在项目对话里听。 * * DirectProject 的会话由聊天容器持有,壳只把这句话交给聊天的本地消息流; * 立项策划路径仍写壳自己的 `messages`。 */ function announceProjectChatMessage(text: string) { if (directProjectMode) { directProjectChatRef.current?.announce(text); return; } setMessages((current) => [...current, { role: 'assistant', text }]); } async function executeChatAgentReply({ prompt, clientTurnId: directConversationTurnId, }: ExecuteChatAgentReplyInput) { const nextProjectPath = resolveChatProjectPath(localProject); if (!nextProjectPath) { setProjectChatError('请先初始化本地项目'); return; } await executeDesignAgentTurn( nextProjectPath, { type: 'message', text: prompt }, directConversationTurnId ?? createAgentChatRunId('design-agent-turn'), ); } executeChatAgentReplyRef.current = executeChatAgentReply; useEffect(() => { const latch = initialPlanningPromptLatchRef.current; if (!planningStartMode || !latch.prompt || !localProject) { return; } if (localProject.projectPath !== latch.projectPath) { // 不能在这里先"占用"这条初始消息:项目路径可能因为分隔符/大小写/时序先落到别的 // 路径上,一旦占用,真正匹配的项目就再也不会收到这条消息,用户的输入被静默丢掉。 // 这里只等待,占用留给下面真正要发送的那一步。 return; } if ( chatAgentBusy || !claimInitialTurnForPage(latch.projectPath, latch.claimScope) ) { return; } planningChatShouldFollowLatestRef.current = true; setMessages((current) => [ ...current, { role: 'user', text: latch.prompt, runtimeOwned: true, updatedAt: Date.now(), }, ]); // DirectProject 的首轮需求由聊天容器自己认领并发送(见 `DirectProjectChat`)。 void executeChatAgentReplyRef.current({ prompt: latch.prompt }); // 初始消息 latch 只由入口状态驱动;controller 内部函数保持稳定语义。 // eslint-disable-next-line react-hooks/exhaustive-deps }, [ chatAgentBusy, initialCreationType, initialPlanningPrompt, localProject, planningStartMode, ]); function cancelProjectCreateInNonEmptyFolder() { const pendingCreate = pendingNonEmptyProjectCreate; setPendingNonEmptyProjectCreate(null); setWorkspaceStatus('已取消'); if (pendingCreate?.announceToChat) { setMessages((current) => [ ...current, { role: 'assistant', text: `已取消在非空文件夹中新建项目:${pendingCreate.projectPath}`, }, ]); } } /** * 预览已经在跑时先切视图,不重启预览服务。 * * `activate_local_game_preview` 是 Rust 侧的「这条预览还活着、且属于这个项目」闸门: * 它只核对内存 registry 里的状态并回传可用的 loopback 地址,前端据此进客户端运行视图。 * 同一个动作过去由工作台壳的 `/preview open` 聊天命令承担,那条命令链随 Supervisor * 前端链路一起退役,行为改由「运行」入口承接,结果经 DirectProject 聊天的 `announce` * 交给聊天自己的消息流。返回 `null` 表示没有可复用的活体预览,调用方照旧重启预览。 */ async function activateRunningPreview( invoke: TauriInvoke, nextProjectPath: string, ): Promise { try { const status = await invoke( 'activate_local_game_preview', { projectPath: nextProjectPath }, ); if ( status.status !== 'running' || !status.url || !status.port || !status.root ) { return null; } return { url: status.url, port: status.port, root: status.root }; } catch { // 没在跑、不属于这个项目,或权限位要求确认:都不是错误,回落到重新启动预览。 return null; } } async function executeRunLocal(announceToChat: boolean) { const invoke = resolveTauriInvoke(); if (!invoke) { if (announceToChat) { announceProjectChatMessage('需要在 Tauri App 内运行。'); } return; } const nextProjectPath = resolveChatProjectPath(localProject); if (!nextProjectPath) { if (announceToChat) { announceProjectChatMessage('请先用 /project 设置本地项目。'); } return; } try { const activePreview = await activateRunningPreview( invoke, nextProjectPath, ); if (activePreview) { updateClientPreview(activePreview); if (announceToChat) { announceProjectChatMessage( `已切换到客户端运行视图:${activePreview.url}`, ); } return; } const previewResult = await invoke( 'start_local_game_preview', { projectPath: nextProjectPath }, ); updateClientPreview(previewResult); void refreshManifest(nextProjectPath); if (!directProjectMode) { void refreshAgentRunTrace(nextProjectPath); } if (announceToChat) { announceProjectChatMessage( `运行通过,已载入客户端运行视图:${previewResult.url}`, ); } } catch (error) { const message = error instanceof Error ? error.message : String(error); if (announceToChat) { announceProjectChatMessage(message); } } } async function loadAgentRunTraceFile( relativePath: string, nextProjectPath = resolveChatProjectPath(localProject) ?? '', ) { const invoke = resolveTauriInvoke(); if (!invoke) { return null; } if (!nextProjectPath) { return null; } try { const result = await invoke( 'read_local_project_file', { projectPath: nextProjectPath, relativePath, commandId: isAgentTraceFilePath(relativePath) ? 'agent.trace_read' : 'file.read', }, ); const trace = parseAgentRunTrace(result.content); return summarizeAgentRunCompletionForChat(trace); } catch { return null; } } function rememberAgentRuntimeState(runtime: AgentRuntimeState | null) { if (!runtime) { return; } const next = mergeAgentRuntimeStateIntoMap( agentRuntimeByIdRef.current, runtime, false, ); agentRuntimeByIdRef.current = next; setAgentRuntimeById(next); } async function refreshAgentRuntimes( nextProjectPath = resolveChatProjectPath(localProject) ?? '', ) { const invoke = resolveTauriInvoke(); if (!invoke || !nextProjectPath) { setAgentRuntimeById({}); agentRuntimeResumeProjectPathRef.current = null; return; } if (directProjectMode) { agentRuntimeResumeProjectPathRef.current = nextProjectPath; setAgentRuntimeById({}); return; } try { if (agentRuntimeResumeProjectPathRef.current !== nextProjectPath) { try { const resumedRuntimes = await invoke( 'resume_game_creator_agent_runtime_tasks', { projectPath: nextProjectPath }, ); if (localProjectPathRef.current !== nextProjectPath) { return; } agentRuntimeResumeProjectPathRef.current = nextProjectPath; for (const runtimeResult of resumedRuntimes) { rememberAgentRuntimeState( agentRuntimeStateFromResult(runtimeResult), ); } } catch (error) { if (localProjectPathRef.current !== nextProjectPath) { return; } const message = error instanceof Error ? error.message : String(error); if (isMissingAgentRuntimeResumeCommandError(error)) { agentRuntimeResumeProjectPathRef.current = nextProjectPath; } else if ( message.includes('项目权限策略要求用户确认:agent.resume') ) { agentRuntimeResumeProjectPathRef.current = nextProjectPath; requestProjectPolicyConfirmation( 'agent.resume', nextProjectPath, `恢复 ${nextProjectPath} 中未完成的 Agent Runtime 任务`, () => { void invoke( 'confirm_resume_game_creator_agent_runtime_tasks', { projectPath: nextProjectPath }, ) .then((resumedRuntimes) => { if (localProjectPathRef.current !== nextProjectPath) { return; } agentRuntimeResumeProjectPathRef.current = nextProjectPath; for (const runtimeResult of resumedRuntimes) { rememberAgentRuntimeState( agentRuntimeStateFromResult(runtimeResult), ); } }) .catch(() => { if (localProjectPathRef.current !== nextProjectPath) { return; } agentRuntimeResumeProjectPathRef.current = null; }); }, ); } else if (message.includes('项目权限策略拒绝执行:agent.resume')) { agentRuntimeResumeProjectPathRef.current = nextProjectPath; markProjectPolicyDenied('agent.resume', message); } else { agentRuntimeResumeProjectPathRef.current = null; } } } const runtimes = await invoke( 'read_game_creator_agent_runtimes', { projectPath: nextProjectPath }, ); if (localProjectPathRef.current !== nextProjectPath) { return; } const nextRuntimes: AgentRuntimeState[] = []; for (const runtimeResult of runtimes) { nextRuntimes.push(agentRuntimeStateFromResult(runtimeResult)); } setAgentRuntimeById((current) => nextRuntimes.reduce( (next, runtime) => mergeAgentRuntimeStateIntoMap(next, runtime, true), current, ), ); } catch { if (localProjectPathRef.current === nextProjectPath) { setAgentRuntimeById({}); } } } async function refreshAgentRunTrace( nextProjectPath = resolveChatProjectPath(localProject) ?? '', ) { if (!nextProjectPath) { await refreshAgentRuntimes(nextProjectPath); return null; } const summary = await loadAgentRunTraceFile( '.agent/run.latest.json', nextProjectPath, ); await refreshAgentRuntimes(nextProjectPath); return summary; } const professionalResultCandidates = taskRowsFromManifest(manifest).map( (task) => ({ agentId: agentConversationId(task), label: projectProfessionalAgentLabel(agentConversationId(task)), runtimeUpdatedAt: agentRuntimeById[agentConversationId(task)]?.updatedAt ?? 0, }), ); const professionalResultCandidateKey = professionalResultCandidates .map((candidate) => `${candidate.agentId}:${candidate.runtimeUpdatedAt}`) .join('|'); useEffect(() => { const nextProjectPath = localProject?.projectPath; if (!nextProjectPath || !onManifestChange) { return; } const invoke = resolveTauriInvoke(); if (!invoke) { return; } let cancelled = false; void (async () => { for (let attempt = 0; attempt < 2; attempt += 1) { const before = await invoke( 'get_local_game_project_revision', { projectPath: nextProjectPath }, ); const currentManifest = await invoke( 'get_local_game_manifest', { projectPath: nextProjectPath }, ); const after = await invoke( 'get_local_game_project_revision', { projectPath: nextProjectPath }, ); if (before.revision !== after.revision) { continue; } if (!cancelled) { onManifestChange(nextProjectPath, currentManifest, { projectId: currentManifest.projectId, revision: after.revision, source: 'chat', }); } return; } })().catch(() => undefined); return () => { cancelled = true; }; }, [localProject?.projectPath, manifest, onManifestChange]); useEffect(() => { const invoke = resolveTauriInvoke(); const nextProjectPath = localProject?.projectPath ?? null; if ( directProjectMode || !invoke || !nextProjectPath || professionalResultCandidates.length === 0 ) { setProfessionalAgentResultsById({}); return; } let disposed = false; void Promise.all( professionalResultCandidates.map(async (candidate) => { try { const conversation = await invoke( 'read_local_conversation', { projectPath: nextProjectPath, agentId: candidate.agentId, }, ); const finalMessage = conversation.messages .slice() .reverse() .find( (message) => message.role === 'assistant' && message.content.trim() && isAgentFinalizationMessageId(message.messageId), ); if (!finalMessage) { return { agentId: candidate.agentId, status: 'missing' as const, }; } return { agentId: candidate.agentId, status: 'loaded' as const, result: { agentId: candidate.agentId, runId: finalMessage.messageId!, label: candidate.label, title: `${candidate.label} 文本回执`, content: finalMessage.content.trim(), updatedAt: finalMessage.updatedAt, } satisfies ProjectAgentResultSummary, }; } catch { return { agentId: candidate.agentId, status: 'failed' as const, }; } }), ).then((results) => { if (disposed || localProjectPathRef.current !== nextProjectPath) { return; } setProfessionalAgentResultsById((current) => { const candidateAgentIds = new Set( professionalResultCandidates.map((candidate) => candidate.agentId), ); const next = Object.fromEntries( Object.entries(current).filter(([agentId]) => candidateAgentIds.has(agentId), ), ); for (const outcome of results) { if (outcome.status === 'loaded') { next[outcome.agentId] = outcome.result; } else if (outcome.status === 'missing') { delete next[outcome.agentId]; } } return next; }); }); return () => { disposed = true; }; // The semantic candidate key replaces the freshly allocated candidates array. // eslint-disable-next-line react-hooks/exhaustive-deps }, [ directProjectMode, localProject?.projectPath, professionalResultCandidateKey, ]); useEffect(() => { if (!onAgentRuntimeSummariesChange) { return; } // 工作台壳不再持有任何根 Runtime:普通项目的运行态由 `DirectProjectChatView` // 自己的订阅拥有,策划链路只投影设计 Agent 会话。这里保留回调契约,根 Runtime // 传 null,运行态叠加交给各入口自己的事实源。 onAgentRuntimeSummariesChange( projectAgentRuntimeSummaries(manifest, null, agentRuntimeById), ); }, [agentRuntimeById, manifest, onAgentRuntimeSummariesChange]); useEffect(() => { if (!onAgentResultsChange) { return; } onAgentResultsChange(Object.values(professionalAgentResultsById)); }, [onAgentResultsChange, professionalAgentResultsById]); const chatProjectAssets = manifest.assets.filter( (asset) => asset.localPath && !asset.localPath.startsWith('.agent/'), ); // `@` 面板「当前版本素材」的版本来源:版本列表来自 manifest, // 当前版本由工作台壳(`WorkspaceLauncherShell`)持有的那一份状态给出, // 传 `null` 表示回退到 manifest 中最新的版本。 const chatProjectVersions = manifest.versions ?? []; const chatActiveVersionId = activeVersionId; const visibleMessages = latestVisibleItems( messages, conversationVisibleCount, ); const hiddenConversationCount = Math.max( 0, messages.length - visibleMessages.length, ); const hasEarlierConversationMessages = hiddenConversationCount > 0; async function showEarlierConversationMessages() { setConversationVisibleCount((current) => Math.min(messages.length, current + CONVERSATION_VISIBLE_STEP), ); } function handleConversationScroll(event: UIEvent) { if (!hasEarlierConversationMessages) { return; } if (event.currentTarget.scrollTop <= 24) { showEarlierConversationMessages(); } } function handlePlanningChatScroll(event: UIEvent) { handleConversationScroll(event); const messageList = event.currentTarget; const distanceFromBottom = messageList.scrollHeight - messageList.scrollTop - messageList.clientHeight; planningChatShouldFollowLatestRef.current = distanceFromBottom <= AGENT_CHAT_SCROLL_BOTTOM_THRESHOLD; } /** * 策划输入盒导入本地文件:文件直接写进策划工作区(`references/`),既不进游戏资源 * 清单,也不生成回合附件;导入期间通知工作台壳暂缓切换游戏运行态。 */ async function handleDesignComposerUploadFiles(files: readonly File[]) { const invoke = resolveTauriInvoke(); const nextProjectPath = resolveChatProjectPath(localProject); if (!invoke || !nextProjectPath) { setChatFileImportNotice('需要先打开本地项目,才能导入文件'); return; } if (chatFilesImporting || files.length === 0) { return; } setChatFilesImporting(true); onDesignFilesImportingChange?.(nextProjectPath, true); setChatFileImportNotice('正在导入文件'); try { const notice = await importDesignFiles(invoke, nextProjectPath, files); if (localProjectPathRef.current === nextProjectPath) { setChatFileImportNotice(notice); } } finally { setChatFilesImporting(false); onDesignFilesImportingChange?.(nextProjectPath, false); } } function handlePlanningChatSubmit(event: FormEvent) { event.preventDefault(); const content = chatComposerRef.current?.getDraft().content ?? []; const legacyContent = directCodexContentToLegacyContentDto( content, manifest.assets, ); const canonicalPrompt = legacyContent.text; if (!hasMeaningfulDirectCodexContent(content)) { return; } if (chatAgentBusy) { return; } if (!canonicalPrompt) { return; } planningChatShouldFollowLatestRef.current = true; const clientTurnId = createAgentChatRunId('design-agent-turn'); chatComposerRef.current?.clear(); setMessages((current) => [ ...current, { role: 'user', text: canonicalPrompt, runtimeOwned: true, updatedAt: Date.now(), }, ]); void executeChatAgentReply({ prompt: canonicalPrompt, clientTurnId }); } const useDesignAgentSurface = Boolean(designAgentView) || designAgentActive; if (directProjectMode) { // 普通项目固定走 DirectProject 自己的聊天容器:订阅、历史、发送、队列和附件都由 // 容器持有,工作台壳只提供项目身份、入口首轮需求和两条权限门。 return ( <> setPublishPanelOpen(false)} /> ); } return ( <> { const nextProjectPath = resolveChatProjectPath(localProject); const invoke = resolveTauriInvoke(); if (!nextProjectPath || !invoke) { return; } const clientTurnId = createAgentChatRunId('design-agent-turn'); designAgentTurnRef.current = { projectPath: nextProjectPath, clientTurnId, }; designAgentReasoningTurnRef.current = { projectPath: nextProjectPath, clientTurnId, }; designAgentPendingViewRef.current = null; setDesignAgentTransientReplyTarget(''); setDesignAgentReasoning(''); setChatAgentBusy(true); void designAgentEventSubscriptionReady() .then(() => invoke('decide_design_phase', { projectPath: nextProjectPath, clientTurnId, requestId, approved, }), ) .then((view) => { if ( localProjectPathRef.current !== nextProjectPath || designAgentTurnRef.current?.projectPath !== nextProjectPath || designAgentTurnRef.current?.clientTurnId !== clientTurnId ) { return; } applyDesignAgentViewAfterTransient( view, nextProjectPath, clientTurnId, ); }) .catch((error) => { if ( localProjectPathRef.current !== nextProjectPath || designAgentTurnRef.current?.projectPath !== nextProjectPath || designAgentTurnRef.current?.clientTurnId !== clientTurnId ) { return; } setProjectChatError(String(error)); }) .finally(() => { if ( localProjectPathRef.current !== nextProjectPath || designAgentTurnRef.current?.projectPath !== nextProjectPath || designAgentTurnRef.current?.clientTurnId !== clientTurnId ) { return; } if (!designAgentPendingViewRef.current) { designAgentTurnRef.current = null; setDesignAgentTransientReplyTarget(''); } setChatAgentBusy(false); }); } : undefined } onDesignClarify={ useDesignAgentSurface ? (question: DesignClarificationRequest, optionIndex, text) => { const nextProjectPath = resolveChatProjectPath(localProject); if (!nextProjectPath) { return; } void executeDesignAgentTurn(nextProjectPath, { type: 'clarification', requestId: question.requestId, optionIndex, text: text.trim() ? text : null, }); } : undefined } onDesignRetry={ useDesignAgentSurface ? () => { const nextProjectPath = resolveChatProjectPath(localProject); if (!nextProjectPath) { return; } void executeDesignAgentTurn(nextProjectPath, { type: 'retry' }); } : undefined } error={projectChatError} controlBusy={chatAgentBusy} attachmentNotice={chatFileImportNotice} importingFiles={chatFilesImporting} onUploadFiles={(files) => void handleDesignComposerUploadFiles(files)} versions={chatProjectVersions} /> {runtimeConfigOpen ? ( setRuntimeConfigOpen(false)} /> ) : null} setPublishPanelOpen(false)} /> ); }