From 1e4ff4da6b0d3829f2fb8c46862897b87cd86015 Mon Sep 17 00:00:00 2001 From: kdletters Date: Mon, 14 Sep 2026 12:31:40 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20AGC=20=E5=BC=82=E6=AD=A5?= =?UTF-8?q?=E6=93=8D=E4=BD=9C=E6=81=A2=E5=A4=8D=E9=97=AD=E7=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 认证响应体读取加入可取消超时并释放 refresh singleflight 将 Runner 会话安装与清除移到 blocking worker 并增加 UI fence 让最近项目逐项检查且单目录超时不阻塞其它项目 将首页自动创建锁提升到 WorkspaceLauncher 生命周期 补充定向测试、跨页创建测试和异步闭环规范 --- .../src-tauri/src/commands.rs | 36 ++++++---- .../src/app/AuthenticatedClient.tsx | 19 ++++-- .../features/app-shell/WorkspaceLauncher.tsx | 1 + .../src/features/app-shell/model.ts | 53 ++++++++------- .../app-shell/useHomeProjectCreation.ts | 66 ++++++++++++------- .../features/app-shell/useRecentProjects.ts | 53 ++++++++++----- .../src/services/clientAuth.ts | 14 +++- .../src/services/clientHttp.ts | 54 +++++++++++++++ .../src/view/home/index.tsx | 8 ++- .../tests/appSurface/home.suite.ts | 23 +++++-- .../tests/clientApi.test.ts | 32 +++++++++ .../tests/clientHttp.test.ts | 24 +++++++ .../tests/recentProjectsModel.test.ts | 39 +++++++++++ docs/README.md | 1 + ...术方案】AGC异步操作可恢复闭环-2026-09-14.md | 44 +++++++++++++ 15 files changed, 370 insertions(+), 97 deletions(-) create mode 100644 apps/ai-game-creator-shell/tests/recentProjectsModel.test.ts create mode 100644 docs/【技术方案】AGC异步操作可恢复闭环-2026-09-14.md diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index fbfd893e2..c81dd2663 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -1945,28 +1945,36 @@ pub(crate) fn read_platform_account_session_generation() -> u64 { } #[tauri::command] -pub(crate) fn install_platform_account_session( +pub(crate) async fn install_platform_account_session( user_id: String, access_token: String, api_base_url: String, generation: u64, ) -> Result<(), String> { - validate_platform_session_input(&user_id, &access_token, &api_base_url, generation)?; - install_external_agent_runner_platform_session( - &user_id, - &access_token, - &api_base_url, - generation, - )?; - install_platform_session(&user_id, &access_token, &api_base_url, generation) + tokio::task::spawn_blocking(move || { + validate_platform_session_input(&user_id, &access_token, &api_base_url, generation)?; + install_external_agent_runner_platform_session( + &user_id, + &access_token, + &api_base_url, + generation, + )?; + install_platform_session(&user_id, &access_token, &api_base_url, generation) + }) + .await + .map_err(|error| format!("安装本地运行时会话任务意外终止:{error}"))? } #[tauri::command] -pub(crate) fn clear_platform_account_session(generation: u64) -> Result<(), String> { - shutdown_game_creator_codex_app_servers()?; - clear_external_agent_runner_platform_session(generation)?; - clear_platform_session(generation); - Ok(()) +pub(crate) async fn clear_platform_account_session(generation: u64) -> Result<(), String> { + tokio::task::spawn_blocking(move || { + shutdown_game_creator_codex_app_servers()?; + clear_external_agent_runner_platform_session(generation)?; + clear_platform_session(generation); + Ok(()) + }) + .await + .map_err(|error| format!("清除本地运行时会话任务意外终止:{error}"))? } #[tauri::command] diff --git a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx index 3878476a3..a91af7ff4 100644 --- a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx +++ b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx @@ -75,6 +75,7 @@ function withAuthCheckTimeout( timeoutMs: number, message: string, ) { + void promise.catch(() => undefined); let timeoutId: number | undefined; const timeout = new Promise((_, reject) => { timeoutId = window.setTimeout(() => reject(new Error(message)), timeoutMs); @@ -492,10 +493,14 @@ export function AuthenticatedClient({ password, loginApiBaseUrl, ); - const committedGeneration = await commitAuthenticatedPlatformSession( - user, - loginGeneration, - loginApiBaseUrl, + const committedGeneration = await withAuthCheckTimeout( + commitAuthenticatedPlatformSession( + user, + loginGeneration, + loginApiBaseUrl, + ), + AUTH_CHECK_RUNNER_TIMEOUT_MS, + '连接本地运行时超时,请重试或重启客户端', ); if (committedGeneration === null) { return; @@ -524,7 +529,11 @@ export function AuthenticatedClient({ clearStoredAuthAccessToken(); } try { - await clearCommittedPlatformSession(logoutGeneration); + await withAuthCheckTimeout( + clearCommittedPlatformSession(logoutGeneration), + AUTH_CHECK_RUNNER_TIMEOUT_MS, + '清理本地运行时超时,请重启客户端后再登录', + ); } catch (error) { nativeClearError = error; } diff --git a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx index 94b415df1..913ecac2f 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx @@ -522,6 +522,7 @@ export function WorkspaceLauncherShell({ onStatusChange={setStatus} recentProjectRows={recentProjectRows} onCreateDraftAutomatically={createHomeDraftAutomatically} + creationBusy={homeProject.projectAction === 'creating'} onProjectsOpen={() => setLauncherView('projects')} onProjectOpen={(path) => { setProjectPath(path); diff --git a/apps/ai-game-creator-shell/src/features/app-shell/model.ts b/apps/ai-game-creator-shell/src/features/app-shell/model.ts index 6e1fd8d1d..5c54a5055 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/model.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/model.ts @@ -230,6 +230,9 @@ export function buildRecentProjectRows( >, recentWorkspaceRefreshing: boolean, ): RecentProjectRow[] { + // The refresh flag is kept for the page-level indicator. Each row owns its + // pending state so a slow directory cannot disable already inspected rows. + void recentWorkspaceRefreshing; return recentWorkspaces.map((workspace) => { const directoryStatus = recentWorkspaceStatuses[workspace]; const isPendingStatus = directoryStatus === undefined; @@ -237,34 +240,31 @@ export function buildRecentProjectRows( directoryStatus?.projectName || workspace.split(/[\\/]/).filter(Boolean).pop() || workspace; - const status = recentWorkspaceRefreshing + const status = isPendingStatus ? '检查中' - : isPendingStatus - ? '检查中' - : directoryStatus === null - ? '检查失败' - : directoryStatus?.exists === false - ? '未找到' - : directoryStatus?.isDirectory === false - ? '不是文件夹' - : directoryStatus?.manifestError - ? '无法读取' - : (directoryStatus?.isGodotProject === true || - directoryStatus?.isCocosProject === true) && - directoryStatus?.isGameCreatorProject === false - ? '可导入' - : directoryStatus?.isGameCreatorProject === false - ? '未初始化' - : directoryStatus?.recentRunStatus - ? formatRecentProjectRunStatus( - directoryStatus.recentRunStatus, - directoryStatus.recentRunStopReason, - ) - : directoryStatus?.isGodotProject - ? '可打开' - : '本地项目'; + : directoryStatus === null + ? '检查失败' + : directoryStatus?.exists === false + ? '未找到' + : directoryStatus?.isDirectory === false + ? '不是文件夹' + : directoryStatus?.manifestError + ? '无法读取' + : (directoryStatus?.isGodotProject === true || + directoryStatus?.isCocosProject === true) && + directoryStatus?.isGameCreatorProject === false + ? '可导入' + : directoryStatus?.isGameCreatorProject === false + ? '未初始化' + : directoryStatus?.recentRunStatus + ? formatRecentProjectRunStatus( + directoryStatus.recentRunStatus, + directoryStatus.recentRunStopReason, + ) + : directoryStatus?.isGodotProject + ? '可打开' + : '本地项目'; const canReveal = - !recentWorkspaceRefreshing && Boolean(directoryStatus) && directoryStatus?.exists !== false && directoryStatus?.isDirectory !== false; @@ -286,7 +286,6 @@ export function buildRecentProjectRows( recentRunStopReason: directoryStatus?.recentRunStopReason ?? null, canReveal, canOpen: - !recentWorkspaceRefreshing && Boolean(directoryStatus) && directoryStatus?.exists !== false && directoryStatus?.isDirectory !== false && diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts index dca3466fe..8ecc89fb7 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts @@ -644,37 +644,53 @@ export function useHomeProjectCreation({ startMode: ProjectStartMode, options: { suggestName: boolean }, ) { + if (projectActionRef.current) { + return '已有项目操作进行中,请稍候'; + } const invoke = resolveTauriInvoke(); if (!invoke) { throw new Error('需要在陶泥儿客户端内运行'); } - const suggestedName = options.suggestName - ? await suggestAutomaticProjectName(invoke, draft) - : null; - const result = await invoke( - 'create_automatic_local_game_project', - { - name: suggestedName, - planning: startMode === 'planning', - }, - ); + // This action is owned by WorkspaceLauncher rather than HomeView. The + // launcher survives navigation, so unmounting the home page cannot release + // the guard while project creation or first-turn import is still running. + projectActionRef.current = 'creating'; + setProjectAction('creating'); + setStatus('正在创建工作区'); try { - await enterCreatedHomeProject( - invoke, - result, - draft.creationType, - draft.prompt, - draft.attachments, - startMode, + const suggestedName = options.suggestName + ? await suggestAutomaticProjectName(invoke, draft) + : null; + const result = await invoke( + 'create_automatic_local_game_project', + { + name: suggestedName, + planning: startMode === 'planning', + }, ); - setStatus('已创建工作区,正在开始智能创作'); - return '已创建工作区并进入项目开发'; - } catch (error) { - const message = `工作区已创建;首条需求投递失败:${ - error instanceof Error ? error.message : String(error) - }`; - setStatus(message); - throw new Error(message); + try { + await enterCreatedHomeProject( + invoke, + result, + draft.creationType, + draft.prompt, + draft.attachments, + startMode, + ); + setStatus('已创建工作区,正在开始智能创作'); + return '已创建工作区并进入项目开发'; + } catch (error) { + const message = `工作区已创建;首条需求投递失败:${ + error instanceof Error ? error.message : String(error) + }`; + setStatus(message); + throw new Error(message); + } + } finally { + if (projectActionRef.current === 'creating') { + projectActionRef.current = null; + setProjectAction(null); + } } } diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts b/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts index d930bb61d..2733aff91 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts @@ -19,6 +19,8 @@ import { writeRecentWorkspace, } from './model'; +const RECENT_WORKSPACE_CHECK_TIMEOUT_MS = 5_000; + export function useRecentProjects(setStatus: Dispatch>) { const [recentWorkspaces, setRecentWorkspaces] = useState(readRecentWorkspaces); @@ -34,14 +36,26 @@ export function useRecentProjects(setStatus: Dispatch>) { invoke: NonNullable>, workspace: string, ): Promise<[string, LocalProjectDirectoryStatus | null]> { + let timeoutHandle: number | undefined; try { - const result = await invoke( - 'inspect_local_project_directory', - { projectPath: workspace }, - ); + const result = await Promise.race([ + invoke('inspect_local_project_directory', { + projectPath: workspace, + }), + new Promise((_, reject) => { + timeoutHandle = window.setTimeout( + () => reject(new Error('项目目录检查超时')), + RECENT_WORKSPACE_CHECK_TIMEOUT_MS, + ); + }), + ]); return [workspace, result]; } catch { return [workspace, null]; + } finally { + if (timeoutHandle !== undefined) { + window.clearTimeout(timeoutHandle); + } } } @@ -53,18 +67,27 @@ export function useRecentProjects(setStatus: Dispatch>) { return; } let disposed = false; + let pendingCount = recentWorkspaces.length; + setRecentWorkspaceStatuses({}); setRecentWorkspaceRefreshing(true); - void Promise.all( - recentWorkspaces.map((workspace) => - inspectRecentWorkspace(invoke, workspace), - ), - ).then((entries) => { - if (disposed) { - return; - } - setRecentWorkspaceStatuses(Object.fromEntries(entries)); - setRecentWorkspaceRefreshing(false); - }); + + for (const workspace of recentWorkspaces) { + void inspectRecentWorkspace(invoke, workspace).then( + ([projectPath, status]) => { + if (disposed) { + return; + } + setRecentWorkspaceStatuses((current) => ({ + ...current, + [projectPath]: status, + })); + pendingCount -= 1; + if (pendingCount === 0) { + setRecentWorkspaceRefreshing(false); + } + }, + ); + } return () => { disposed = true; }; diff --git a/apps/ai-game-creator-shell/src/services/clientAuth.ts b/apps/ai-game-creator-shell/src/services/clientAuth.ts index c8c3494c4..a529ef10b 100644 --- a/apps/ai-game-creator-shell/src/services/clientAuth.ts +++ b/apps/ai-game-creator-shell/src/services/clientAuth.ts @@ -15,7 +15,11 @@ import { API_RESPONSE_ENVELOPE_VERSION, unwrapApiResponse, } from '../../../../packages/shared/src/http'; -import { fetchClientHttp, getClientServerBaseUrl } from './clientHttp'; +import { + fetchClientHttp, + getClientServerBaseUrl, + readClientHttpResponseText, +} from './clientHttp'; const ACCESS_TOKEN_STORAGE_KEY = 'genarrative.auth.access-token.v1'; @@ -104,7 +108,9 @@ export function getClientAuthErrorMessage(error: unknown, fallback: string) { } async function readAuthErrorMessage(response: Response, fallback: string) { - const text = await response.text(); + const text = await readClientHttpResponseText(response, { + url: 'auth error response', + }); if (!text.trim()) { return fallback; } @@ -158,7 +164,9 @@ async function requestAuthJson( { status: response.status }, ); } - const text = await response.text(); + const text = await readClientHttpResponseText(response, { + url, + }); return text ? unwrapApiResponse(JSON.parse(text) as T) : (null as T); } diff --git a/apps/ai-game-creator-shell/src/services/clientHttp.ts b/apps/ai-game-creator-shell/src/services/clientHttp.ts index 34a37c6e6..83196ea26 100644 --- a/apps/ai-game-creator-shell/src/services/clientHttp.ts +++ b/apps/ai-game-creator-shell/src/services/clientHttp.ts @@ -31,6 +31,60 @@ export function isClientHttpTimeoutError( return error instanceof ClientHttpTimeoutError; } +/** + * Read a response body with the same bounded lifetime as the request that + * produced it. Some transports resolve fetch() after headers arrive while + * leaving body consumption pending indefinitely. + */ +export async function readClientHttpResponseText( + response: Response, + options: { timeoutMs?: number | null; url?: string } = {}, +) { + const timeoutMs = + options.timeoutMs === undefined + ? CLIENT_HTTP_DEFAULT_TIMEOUT_MS + : options.timeoutMs; + if (timeoutMs === null) { + return response.text(); + } + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { + throw new RangeError('响应体超时时间必须是大于 0 的有限数值'); + } + + let timedOut = false; + let timeoutHandle: ReturnType | undefined; + const bodyPromise = response.text(); + // A transport may reject after cancel() unblocks the stream. The race owns + // the observable result, so keep the late rejection out of the global queue. + void bodyPromise.catch(() => undefined); + const timeout = new Promise((_, reject) => { + timeoutHandle = setTimeout(() => { + timedOut = true; + try { + void response.body?.cancel().catch(() => undefined); + } catch { + // Response doubles and older WebViews may not expose cancel(). + } + reject( + new ClientHttpTimeoutError(options.url ?? 'response body', timeoutMs), + ); + }, timeoutMs); + }); + try { + return await Promise.race([bodyPromise, timeout]); + } catch (error) { + if (timedOut) { + throw new ClientHttpTimeoutError( + options.url ?? 'response body', + timeoutMs, + ); + } + throw error; + } finally { + if (timeoutHandle !== undefined) clearTimeout(timeoutHandle); + } +} + export type ClientServerPreset = 'release' | 'dev' | 'custom'; export type ClientServerSelection = { diff --git a/apps/ai-game-creator-shell/src/view/home/index.tsx b/apps/ai-game-creator-shell/src/view/home/index.tsx index e037e01e5..6e6a82824 100644 --- a/apps/ai-game-creator-shell/src/view/home/index.tsx +++ b/apps/ai-game-creator-shell/src/view/home/index.tsx @@ -112,6 +112,7 @@ type HomeViewProps = { draft: HomeDraft, startMode: ProjectStartMode, ) => Promise; + creationBusy?: boolean; onProjectsOpen: () => void; onProjectOpen: (path: string) => void; onProjectPick: () => void; @@ -123,6 +124,7 @@ export default function HomeView({ onStatusChange, recentProjectRows, onCreateDraftAutomatically, + creationBusy = false, onProjectsOpen, onProjectOpen, onProjectPick, @@ -148,7 +150,7 @@ export default function HomeView({ homeCreationType === 'doc' ? 'planning' : 'direct-build'; async function createFromHome() { - if (homeCreationBusyRef.current) { + if (homeCreationBusyRef.current || creationBusy) { return; } const referencedAttachments = richTextToAttachments(homeRichText); @@ -261,7 +263,7 @@ export default function HomeView({