From 8701f43f754721743ed046a8add308a1f18875e3 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:15:02 +0800 Subject: [PATCH] =?UTF-8?q?AGC=20=E6=B8=B2=E6=9F=93=E5=B1=82=E7=BD=91?= =?UTF-8?q?=E7=BB=9C=E4=B8=8E=E7=8A=B6=E6=80=81=E4=B8=8B=E6=B2=89=20Rust?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E4=BF=AE=E5=A4=8D=E7=94=BB=E5=B8=83=E4=BA=A4?= =?UTF-8?q?=E4=BA=92=E4=B8=8E=E5=A4=B1=E8=B4=A5=E6=96=87=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除渲染层传输层:services/clientApi.ts、clientHttp 的 fetchClientHttp 与超时封装整体退场,账户、认证、素材直传、错误上报与游戏分发发布改走 Rust typed command。 - 新增宿主封装:accountHost、editorAssetHost、platformSessionRetry、agentRuntimeErrorDetail 与 features/app-shell/workspacePreferences;渲染层不再读写 access token / refresh cookie,localStorage 只剩测试态旧键迁移 shim。 - 修复 DirectProject 失败文案 v2 映射与「查看详情」按需读取,画布标签面板高度夹紧、框选每帧重算、文档预览入口与场景标题栏 key、资源筛选面板样式。 - 补齐与调整回归:accountHost、agentRuntimeErrorDetail、clientAuthHost、clientHttp、platformSession、platformSessionRetry、resourceFilterPanelStyle、directThreadChatSubscription 等定向用例,并清理测试 harness 中无调用方的残留。 --- .../scripts/build-release.test.mjs | 21 +- .../scripts/check-config.mjs | 18 + .../scripts/run-rust-shell-test-shards.mjs | 71 + apps/ai-game-creator-shell/src/App.tsx | 232 +-- .../src/app/AuthenticatedClient.tsx | 314 +-- .../AssetImporter/ImageImporterPreview.tsx | 8 +- .../src/components/AssetImporter/index.tsx | 14 +- .../src/components/AssetImporter/utils.ts | 18 +- .../error-report/ErrorReportDialog.tsx | 3 - .../agent-runtime/directActiveTurns.ts | 65 +- .../src/features/agent-runtime/model.ts | 7 + .../src/features/app-shell/model.ts | 92 +- .../features/app-shell/useAccountWallet.ts | 2 +- .../app-shell/useHomeProjectCreation.ts | 4 - .../app-shell/useProjectCreationDirectory.ts | 93 +- .../features/app-shell/useRecentProjects.ts | 121 +- .../app-shell/workspacePreferences.ts | 352 ++++ .../ConversationModelSelect.tsx | 21 +- .../ResourceReferenceInput.tsx | 57 +- .../project-workspace/chatPromptPolish.ts | 73 +- .../resourceCanvasAssetGenerationQueue.ts | 174 +- .../runtime-config/RuntimeConfigDialog.tsx | 47 +- .../template-library/useTemplateLibrary.ts | 3 - .../src/services/accountHost.ts | 62 + .../src/services/agentRuntimeErrorDetail.ts | 56 + .../src/services/assetDirectUpload.ts | 183 +- .../src/services/clientApi.ts | 306 --- .../src/services/clientAuth.ts | 529 ++--- .../src/services/clientHttp.ts | 378 +--- .../src/services/editorAssetHost.ts | 30 + .../src/services/errorReporting.ts | 83 +- .../src/services/gameDistributionPublish.ts | 321 +-- .../src/services/llmModelCatalog.ts | 24 +- .../src/services/platformSession.ts | 730 ++----- .../src/services/platformSessionRetry.ts | 48 + .../src/services/pluginHost.ts | 3 + .../src/stores/useWalletStore.ts | 2 +- apps/ai-game-creator-shell/src/styles.css | 77 +- .../chat/DirectProjectChatView.tsx | 78 +- .../useDirectProjectChatController.ts | 56 +- .../useDirectThreadChatSubscription.ts | 13 + .../chat/conversation/directCodexSession.ts | 23 +- .../src/view/project-development/index.tsx | 37 +- .../project-development/resourceBookModel.ts | 22 + .../tests/ErrorReportDialog.test.tsx | 1 - .../tests/accountHost.test.ts | 103 + .../tests/agentRuntimeErrorDetail.test.ts | 83 + .../tests/agentRuntimeModel.test.ts | 30 + .../tests/appSurface/auth.suite.ts | 1798 +++-------------- .../tests/appSurface/chat-composer.suite.ts | 47 + .../tests/appSurface/design-agent.suite.ts | 22 +- .../tests/appSurface/harness.ts | 256 +-- .../tests/appSurface/home.suite.ts | 474 +++-- .../appSurface/runtime-settings.suite.ts | 97 +- .../tests/assetDirectUpload.test.ts | 176 +- .../tests/assetImporter.test.ts | 41 +- .../tests/chatPromptPolish.test.tsx | 100 +- .../tests/clientApi.test.ts | 288 --- .../tests/clientAuthHost.test.ts | 187 ++ .../tests/clientAuthStorage.test.ts | 123 -- .../tests/clientHttp.test.ts | 389 ++-- .../tests/conversationModelSelect.test.tsx | 56 +- .../tests/directActiveTurns.test.tsx | 114 +- .../directThreadChatSubscription.test.tsx | 109 + .../tests/errorReporting.test.ts | 57 +- .../tests/gameDistributionPublish.test.ts | 480 ++--- .../tests/gameDistributionPublishLive.test.ts | 213 +- .../tests/platformSession.test.ts | 187 ++ .../tests/platformSessionRetry.test.ts | 157 ++ .../tests/projectCreationDirectory.test.ts | 115 +- .../projectResourceLiveIntegration.test.tsx | 50 + .../tests/recentProjectsHook.test.tsx | 63 + .../resourceBatchTagsIntegration.test.tsx | 2 + .../tests/resourceBookModel.test.ts | 21 + ...resourceCanvasAssetGenerationQueue.test.ts | 28 + .../tests/resourceFilterPanelStyle.test.ts | 49 + .../tests/resourceReferenceInput.test.tsx | 21 +- .../tests/resourceTagStatsRefresh.test.tsx | 2 + .../tests/useTemplateLibrary.test.tsx | 2 +- .../tests/walletStore.test.ts | 2 +- 80 files changed, 4735 insertions(+), 6079 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/features/app-shell/workspacePreferences.ts create mode 100644 apps/ai-game-creator-shell/src/services/accountHost.ts create mode 100644 apps/ai-game-creator-shell/src/services/agentRuntimeErrorDetail.ts delete mode 100644 apps/ai-game-creator-shell/src/services/clientApi.ts create mode 100644 apps/ai-game-creator-shell/src/services/editorAssetHost.ts create mode 100644 apps/ai-game-creator-shell/src/services/platformSessionRetry.ts create mode 100644 apps/ai-game-creator-shell/tests/accountHost.test.ts create mode 100644 apps/ai-game-creator-shell/tests/agentRuntimeErrorDetail.test.ts delete mode 100644 apps/ai-game-creator-shell/tests/clientApi.test.ts create mode 100644 apps/ai-game-creator-shell/tests/clientAuthHost.test.ts delete mode 100644 apps/ai-game-creator-shell/tests/clientAuthStorage.test.ts create mode 100644 apps/ai-game-creator-shell/tests/directThreadChatSubscription.test.tsx create mode 100644 apps/ai-game-creator-shell/tests/platformSession.test.ts create mode 100644 apps/ai-game-creator-shell/tests/platformSessionRetry.test.ts create mode 100644 apps/ai-game-creator-shell/tests/resourceFilterPanelStyle.test.ts diff --git a/apps/ai-game-creator-shell/scripts/build-release.test.mjs b/apps/ai-game-creator-shell/scripts/build-release.test.mjs index 3cdd735c1..fe43ec984 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.test.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.test.mjs @@ -302,7 +302,7 @@ test('channel config keeps the client window contract across the Tauri config me title: '陶泥儿 Release', }); // 原生标题栏、尺寸与默认窗口标签都是回归点:任何一项回落都会让自绘标题栏失效, - // 并让按 label 绑定的 capability(平台 HTTP 权限)不再命中。 + // 并让按 label 绑定的 capability(剪贴板 / 原生对话框 / opener / updater 权限)不再命中。 assert.equal(clientWindow.label, 'client'); assert.equal(clientWindow.decorations, false); assert.equal(clientWindow.width, 1280); @@ -319,7 +319,16 @@ test('channel config keeps the client window contract across the Tauri config me .map((name) => JSON.parse(readFileSync(new URL(name, capabilitiesDirectory), 'utf8')), ); - const httpCapability = capabilities.find((capability) => + const clientCapability = capabilities.find((capability) => + (capability.windows ?? []).includes(clientWindow.label), + ); + assert.ok( + clientCapability, + `必须存在绑定 ${clientWindow.label} 窗口的 capability(剪贴板 / 原生对话框 / opener / updater 权限)`, + ); + // 渲染层是离线前端:平台接口、OSS 直传、Provider 与更新清单的网络 IO 全部在 Rust 侧, + // 因此任何 capability 都不应再授予 http:default(历史上用于渲染层直传素材)。 + const httpPermissionHolders = capabilities.filter((capability) => (capability.permissions ?? []).some( (permission) => permission === 'http:default' || @@ -327,10 +336,10 @@ test('channel config keeps the client window contract across the Tauri config me permission?.identifier === 'http:default'), ), ); - assert.ok(httpCapability, '客户端必须保留承载平台 HTTP 权限的 capability'); - assert.ok( - (httpCapability.windows ?? []).includes(clientWindow.label), - `平台 HTTP capability 必须绑定 ${clientWindow.label} 窗口,实际:${httpCapability.windows}`, + assert.deepEqual( + httpPermissionHolders, + [], + '渲染层不得再被授予 http:default;平台与 OSS 的网络 IO 由 Rust facade 承担', ); }); diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index b22a44081..a64426a09 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -122,6 +122,24 @@ const rustSharedContractSource = fs.readFileSync( ); const allowedUncalledTauriCommands = [ 'append_direct_project_conversation_message', + // React 不再把页面消息当作持久化事实;策划/Runtime 由 Rust coordinator 写入, + // 该通用命令仅保留给 Rust 内部链路与测试。 + 'append_local_conversation_message', + // 发行 facade 由 typed native service 通过注入的 invoke 调用;整包准备、旧分片上传 + // 与资料命令仍保留给 Rust/旧 native 测试,不允许重新接回 React 直连 HTTP。 + 'generate_game_distribution_cover', + 'prepare_local_project_game_package', + 'read_game_cover_generation_price', + 'read_game_publish_availability', + 'suggest_game_distribution_publish_metadata', + 'upload_local_project_game_package', + // 账户与钱包由 Rust typed command 持有 origin/Bearer/envelope;命令名在 `accountHost.ts` + // 里以字面量出现,静态扫描仍按共享注册表核验。 + 'read_profile_recharge_center', + 'read_profile_wallet_ledger', + 'create_profile_recharge_order', + 'confirm_wechat_profile_recharge_order', + 'redeem_profile_reward_code', // Supervisor 调试窗口、开发者面板与专业 Agent 对话的前端调用方已随 Supervisor // 前端链路整体删除;命令本身仍注册在 Rust 侧供 native Runtime 与 Rust 测试使用, // 保留 present,仅不出现在 App 前端源码里。 diff --git a/apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs b/apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs index ecd2a50b3..2698de093 100644 --- a/apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs +++ b/apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs @@ -293,6 +293,7 @@ function assertShardsCoverEveryTest(testNames, shards) { function runShard(executable, shardIndex, shardCount, shardTestNames) { const label = `shard ${shardIndex + 1}/${shardCount}`; + const chunks = chunkTestNamesForArgumentLimit(shardTestNames); const shardTmpDirectory = path.join( options.shardTmpRoot, `shard-${shardIndex + 1}`, @@ -301,6 +302,76 @@ function runShard(executable, shardIndex, shardCount, shardTestNames) { fs.mkdirSync(shardTmpDirectory, { recursive: true }); const startedAt = Date.now(); + // 一片内的用例名会整串拼成 argv;Windows 的整条命令行上限约 32 KiB,734 个名字 + // (约 30 KiB)正好把它顶爆(`spawn ENAMETOOLONG`)。这里只在超限时把该片拆成多次 + // 「同一二进制 + 同一 TMPDIR」的运行并汇总结果,Linux 上短 argv 仍是单次运行。 + return runShardChunks( + executable, + label, + shardTmpDirectory, + chunks, + startedAt, + ); +} + +function chunkTestNamesForArgumentLimit(testNames) { + const MAX_FILTER_ARGUMENT_CHARS = 12_000; + const chunks = []; + let current = []; + let currentLength = 0; + for (const testName of testNames) { + const cost = testName.length + 3; + if ( + current.length > 0 && + currentLength + cost > MAX_FILTER_ARGUMENT_CHARS + ) { + chunks.push(current); + current = []; + currentLength = 0; + } + current.push(testName); + currentLength += cost; + } + if (current.length > 0) { + chunks.push(current); + } + return chunks; +} + +async function runShardChunks( + executable, + label, + shardTmpDirectory, + chunks, + startedAt, +) { + const chunkResults = []; + for (const [index, chunk] of chunks.entries()) { + const chunkLabel = + chunks.length === 1 + ? label + : `${label} chunk ${index + 1}/${chunks.length}`; + chunkResults.push( + await runShardChunk(executable, chunkLabel, shardTmpDirectory, chunk), + ); + } + + return { + label, + ok: chunkResults.every((result) => result.ok), + durationMs: Date.now() - startedAt, + testCount: chunks.reduce((total, chunk) => total + chunk.length, 0), + failures: chunkResults.flatMap((result) => result.failures), + stderr: chunkResults + .map((result) => result.stderr) + .filter((value) => value.length > 0) + .join('\n'), + }; +} + +function runShardChunk(executable, label, shardTmpDirectory, shardTestNames) { + const startedAt = Date.now(); + return new Promise((resolve) => { const child = spawn( executable, diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 59635c242..f1d3bee15 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -65,14 +65,13 @@ import { 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 { rememberRecentWorkspace } from './features/app-shell/workspacePreferences'; import { projectAgentRuntimeSummaries } from './features/project-summary/agentPresentation'; import { isAbsoluteProjectPath, @@ -105,7 +104,6 @@ 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, @@ -119,25 +117,6 @@ import { PlanningChatView } from './view/project-development/planning/PlanningCh import { useDesignReplyAnimation } from './view/project-development/planning/useDesignReplyAnimation'; 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); -} - /** * 历史回读与"尚未落盘的运行时消息"合并。 * @@ -530,7 +509,7 @@ export function App({ return messages; } - function applyDesignView(view: DesignView, projectPath: string) { + function applyDesignView(view: DesignView) { designAgentLaneRef.current = true; setDesignAgentView(view); designAgentActiveRef.current = true; @@ -539,8 +518,6 @@ export function App({ setChatAgentBusy(view.running); const conversation = designMessagesToChat(view); setMessages(conversation); - savedConversationProjectPathRef.current = projectPath; - savedConversationCountRef.current = conversation.length; latestMessagesRef.current = conversation; } @@ -551,7 +528,7 @@ export function App({ ) { if (!isCurrentDesignTurn(projectPath, clientTurnId)) return; designReplyAnimation.receiveView(view); - applyDesignView(view, projectPath); + applyDesignView(view); setDesignAgentReasoning(''); if (!view.running) setDesignAgentStatus(''); } @@ -571,7 +548,7 @@ export function App({ if (!view) { return null; } - applyDesignView(view, nextProjectPath); + applyDesignView(view); return view; } @@ -642,22 +619,17 @@ export function App({ 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); + // 策划对话和 DirectProject 对话都由 Rust 持有正式历史;这里的消息只服务当前页面投影。 + latestMessagesRef.current = messages; const projectScopeVersionRef = useRef(0); + useEffect(() => { + // DirectProject 的权限门按项目作用域缓存;切换项目后必须重新向 Rust 询问。 + projectConversationWriteConfirmedRef.current = null; + }, [localProject?.projectPath]); + const refreshManifest = useCallback( (nextProjectPath = localProjectPathRef.current ?? ''): Promise => { const invoke = resolveTauriInvoke(); @@ -809,166 +781,6 @@ export function App({ 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: @@ -1185,20 +997,6 @@ export function App({ 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) => @@ -1364,8 +1162,6 @@ export function App({ designAgentLaneRef.current = true; setDesignAgentView(null); setMessages(createDefaultChatMessages()); - savedConversationProjectPathRef.current = nextProjectPath; - savedConversationCountRef.current = 0; latestMessagesRef.current = []; setWorkspaceStatus((workspaceStatus) => workspaceStatus === '等待确认' @@ -1469,9 +1265,9 @@ export function App({ setAgentRuntimeById({}); setMessages(conversationMessages); setConversationVisibleCount(CONVERSATION_INITIAL_VISIBLE_COUNT); - savedConversationProjectPathRef.current = openedProject.projectPath; - savedConversationCountRef.current = conversationMessages.length; - writeRecentWorkspace(openedProject.projectPath); + void rememberRecentWorkspace(openedProject.projectPath).catch(() => { + // 最近项目是辅助投影;Rust 建项/打开结果不因偏好写入失败而回滚。 + }); setWorkspaceStatus(`已打开:${openedProject.projectPath}`); appendLocalPermissionLog( openedProject.projectPath, diff --git a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx index b44270c17..947a8c4ac 100644 --- a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx +++ b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx @@ -12,16 +12,14 @@ import type { AuthUser } from '../../../../packages/shared/src/contracts/auth'; import brandIcon from '../../../../packages/shared/src/icons/taonier-product-ip.png'; import { ErrorReportNotice } from '../components/error-report/ErrorReportNotice'; import { - clearStoredAuthAccessToken, getClientAuthErrorMessage, - getCurrentClientAuthUser, - getStoredAuthAccessToken, - isClientAuthRecoverableCheckError, loginClientWithPassword, loginClientWithPhoneCode, logoutClientAuthSession, normalizeAuthPhoneInput, + readClientAuthState, sendClientPhoneLoginCode, + subscribeClientAuthState, } from '../services/clientAuth'; import { type ClientServerPreset, @@ -29,6 +27,7 @@ import { clientServerSelectionEnabled, getClientServerBaseUrl, getClientServerSelection, + hydrateClientServerSelection, normalizeClientServerBaseUrl, setClientServerSelection, } from '../services/clientHttp'; @@ -40,12 +39,8 @@ import { import { beginPlatformSessionClearTransition, beginPlatformSessionTransition, - clearCommittedPlatformSession, commitAuthenticatedPlatformSession, - currentPlatformSessionApiBaseUrl, currentPlatformSessionGeneration, - refreshPlatformSessionForGeneration, - subscribePlatformSessionRefresh, } from '../services/platformSession'; type ClientRuntimeErrorBoundaryProps = { @@ -193,6 +188,20 @@ export function AuthenticatedClient({ setServerSelection(next); setLoginStatus(`已选择 ${preset} 服务器`); } + // 服务器选择是 Rust 客户端偏好:本地 debug 启动时读一次,打包产物始终用渠道默认值。 + useEffect(() => { + if (!clientServerSelectionEnabled) return; + let disposed = false; + void hydrateClientServerSelection().then((selection) => { + if (disposed) return; + setServerSelection(selection); + setCustomServerUrl(selection.customBaseUrl); + }); + return () => { + disposed = true; + }; + }, []); + useEffect(() => { const uninstallWebviewLogBridge = installWebviewLogBridge(); const handleError = (event: ErrorEvent) => { @@ -212,166 +221,53 @@ export function AuthenticatedClient({ }; }, []); + /** 恢复登录态:凭据续期、用户复核与原生会话安装都在 Rust 内完成。 */ useEffect(() => { let disposed = false; async function hydrateAuth() { const runId = ++authCheckRunRef.current; const isActiveRun = () => !disposed && authCheckRunRef.current === runId; - const hydrationGeneration = currentPlatformSessionGeneration(); - const hydrationApiBaseUrl = getClientServerBaseUrl(); setAuthCheckError(''); - setAuthCheckStage('token'); + setAuthCheckStage('refresh'); try { - if (!getStoredAuthAccessToken()) { - setAuthCheckStage('refresh'); - const refreshed = await withAuthCheckTimeout( - refreshPlatformSessionForGeneration( - hydrationGeneration, - hydrationApiBaseUrl, - ), - AUTH_CHECK_REQUEST_TIMEOUT_MS, - '刷新登录状态超时,请检查服务器地址和网络后重试', - ); - if (!refreshed) { - if (isActiveRun()) { - setAuthStatus('unauthenticated'); - } - return; - } - } - if (!isActiveRun()) return; - setAuthCheckStage('me'); - const user = await withAuthCheckTimeout( - getCurrentClientAuthUser(hydrationApiBaseUrl), + const state = await withAuthCheckTimeout( + readClientAuthState(getClientServerBaseUrl()), AUTH_CHECK_REQUEST_TIMEOUT_MS, - '读取当前用户超时,请检查服务器地址和网络后重试', + '检查登录状态超时,请检查服务器地址和网络后重试', ); - if (!isActiveRun()) { - return; - } - if (user) { - setAuthCheckStage('runner'); - const committedGeneration = await withAuthCheckTimeout( - commitAuthenticatedPlatformSession( - user, - hydrationGeneration, - hydrationApiBaseUrl, - ), - AUTH_CHECK_RUNNER_TIMEOUT_MS, - '连接本地运行时超时,请重试或重启客户端', - ); - if (committedGeneration === null) { - return; - } - if (!isActiveRun()) { - return; - } - setAuthUser(user); - setAuthCheckError(''); - setAuthStatus('authenticated'); - return; - } - clearStoredAuthAccessToken(); - setAuthStatus('unauthenticated'); - } catch (error) { - if (!isActiveRun()) { - return; - } - if ( - getStoredAuthAccessToken() && - isClientAuthRecoverableCheckError(error) - ) { - const message = getClientAuthErrorMessage( - error, - '登录服务暂时不可用,请稍后重试', - ); - setAuthCheckError(message); - setLoginStatus(message); + if (!isActiveRun()) return; + if (state.status === 'unavailable') { + setAuthCheckError(state.message); + setLoginStatus(state.message); setAuthStatus('unauthenticated'); return; } - if (getStoredAuthAccessToken()) { - try { - if (!isActiveRun()) return; - setAuthCheckStage('refresh'); - const refreshed = await withAuthCheckTimeout( - refreshPlatformSessionForGeneration( - hydrationGeneration, - hydrationApiBaseUrl, - ), - AUTH_CHECK_REQUEST_TIMEOUT_MS, - '刷新登录状态超时,请检查服务器地址和网络后重试', - ); - if (!refreshed) { - if (isActiveRun()) { - setAuthStatus('unauthenticated'); - } - return; - } - if (!isActiveRun()) return; - setAuthCheckStage('me'); - const user = await withAuthCheckTimeout( - getCurrentClientAuthUser(hydrationApiBaseUrl), - AUTH_CHECK_REQUEST_TIMEOUT_MS, - '读取当前用户超时,请检查服务器地址和网络后重试', - ); - if (!isActiveRun()) { - return; - } - if (user) { - if (!isActiveRun()) return; - setAuthCheckStage('runner'); - const committedGeneration = await withAuthCheckTimeout( - commitAuthenticatedPlatformSession( - user, - hydrationGeneration, - hydrationApiBaseUrl, - ), - AUTH_CHECK_RUNNER_TIMEOUT_MS, - '连接本地运行时超时,请重试或重启客户端', - ); - if (committedGeneration === null) { - return; - } - if (!isActiveRun()) { - return; - } - setAuthCheckError(''); - setAuthUser(user); - setAuthStatus('authenticated'); - return; - } - } catch (retryError) { - if (!isActiveRun()) { - return; - } - if ( - getStoredAuthAccessToken() && - isClientAuthRecoverableCheckError(retryError) - ) { - const message = getClientAuthErrorMessage( - retryError, - '登录服务暂时不可用,请稍后重试', - ); - setAuthCheckError(message); - setLoginStatus(message); - setAuthStatus('unauthenticated'); - return; - } - } - } - if (!isActiveRun()) { + if (state.status !== 'authenticated') { + setAuthStatus('unauthenticated'); return; } - if (isClientAuthRecoverableCheckError(error)) { - const message = getClientAuthErrorMessage( - error, - '登录服务暂时不可用,请稍后重试', - ); - setAuthCheckError(message); - setLoginStatus(message); - } - clearStoredAuthAccessToken(); + setAuthCheckStage('runner'); + const committedGeneration = await withAuthCheckTimeout( + commitAuthenticatedPlatformSession( + state.user, + currentPlatformSessionGeneration(), + state.apiBaseUrl, + ), + AUTH_CHECK_RUNNER_TIMEOUT_MS, + '连接本地运行时超时,请重试或重启客户端', + ); + if (committedGeneration === null || !isActiveRun()) return; + setAuthUser(state.user); + setAuthCheckError(''); + setAuthStatus('authenticated'); + } catch (error) { + if (!isActiveRun()) return; + const message = getClientAuthErrorMessage( + error, + '登录服务暂时不可用,请稍后重试', + ); + setAuthCheckError(message); + setLoginStatus(message); setAuthStatus('unauthenticated'); } } @@ -380,7 +276,6 @@ export function AuthenticatedClient({ disposed = true; }; }, [authCheckRetryKey]); - useEffect(() => { if (authStatus !== 'checking') { return; @@ -392,26 +287,34 @@ export function AuthenticatedClient({ return () => window.clearInterval(timer); }, [authStatus, authCheckRetryKey]); - useEffect( - () => - subscribePlatformSessionRefresh((result) => { - if (result.status === 'refreshed') { - setAuthUser((current) => - current?.id === result.user.id ? result.user : current, - ); - return; - } - // 仅服务端明确否认当前身份时才登出。网络错误、5xx 和网关错误属于刷新暂时 - // 不可用,必须保留既有会话与 access token。 - if (result.status === 'failed' && result.authoritative) { - clearStoredAuthAccessToken(); - setAuthUser(null); - setAuthStatus('unauthenticated'); - setLoginStatus('登录已失效,请重新登录'); - } - }), - [], - ); + // Rust 是认证态事实源:另一个窗口登录/登出、或 Rust 内部判定登录失效时同步界面。 + useEffect(() => { + let disposed = false; + let unsubscribe: (() => void) | null = null; + void subscribeClientAuthState((state) => { + if (disposed) return; + if (state.status === 'authenticated') { + setAuthUser((current) => + current?.id === state.user.id ? state.user : state.user, + ); + setAuthCheckError(''); + setAuthStatus('authenticated'); + return; + } + setAuthUser(null); + setAuthStatus('unauthenticated'); + setLoginStatus('登录已失效,请重新登录'); + }) + .then((release) => { + if (disposed) release(); + else unsubscribe = release; + }) + .catch(() => undefined); + return () => { + disposed = true; + unsubscribe?.(); + }; + }, []); useEffect(() => { if (codeCooldownSeconds <= 0) { @@ -480,6 +383,7 @@ export function AuthenticatedClient({ setLoginStatus('正在登录'); const loginGeneration = beginPlatformSessionTransition(); try { + // Rust 登录命令内部完成:凭据持久化 → 本机运行时会话安装 → Runner 传播 → 事件。 const user = loginMode === 'code' ? await loginClientWithPhoneCode( @@ -492,46 +396,21 @@ export function AuthenticatedClient({ password, loginApiBaseUrl, ); - const commitRequest = commitAuthenticatedPlatformSession( - user, - loginGeneration, - loginApiBaseUrl, + const committedGeneration = await withAuthCheckTimeout( + commitAuthenticatedPlatformSession( + user, + loginGeneration, + loginApiBaseUrl, + ), + AUTH_CHECK_RUNNER_TIMEOUT_MS, + '连接本地运行时超时,请重试或重启客户端', ); - let commitFenceExpired = false; - // 围栏只放弃等待,不放弃结果:本地运行时确实装好会话时,界面必须跟着进工作区, - // 否则用户停在登录页、而后端已经认为登录成功(重试也会被已装的会话挡住)。 - void commitRequest - .then((committedGeneration) => { - if ( - committedGeneration === null || - !commitFenceExpired || - loginAttemptRef.current !== loginAttempt || - currentPlatformSessionGeneration() !== committedGeneration - ) { - return; - } - setAuthUser(user); - setAuthCheckError(''); - setAuthStatus('authenticated'); - setCode(''); - setPassword(''); - setLoginStatus('本地运行时登录态已确认'); - }) - .catch(() => undefined); - let committedGeneration: number | null; - try { - committedGeneration = await withAuthCheckTimeout( - commitRequest, - AUTH_CHECK_RUNNER_TIMEOUT_MS, - '连接本地运行时超时,请重试或重启客户端', - ); - } catch (error) { - commitFenceExpired = true; - throw error; - } if (committedGeneration === null) { return; } + if (loginAttemptRef.current !== loginAttempt) { + return; + } setAuthUser(user); setAuthCheckError(''); setAuthStatus('authenticated'); @@ -540,24 +419,17 @@ export function AuthenticatedClient({ } catch (error) { if (shouldCaptureClientError(error)) void captureClientError(error, { source: 'auth', action: 'login' }); - setLoginStatus(error instanceof Error ? error.message : String(error)); + setLoginStatus(getClientAuthErrorMessage(error, '登录失败')); } finally { setLoginBusy(false); } } - async function logout() { - const logoutApiBaseUrl = currentPlatformSessionApiBaseUrl(); - const logoutGeneration = beginPlatformSessionClearTransition(); + beginPlatformSessionClearTransition(); let nativeClearError: unknown = null; - try { - await logoutClientAuthSession(logoutApiBaseUrl); - } catch { - clearStoredAuthAccessToken(); - } try { await withAuthCheckTimeout( - clearCommittedPlatformSession(logoutGeneration), + logoutClientAuthSession(), AUTH_CHECK_RUNNER_TIMEOUT_MS, '清理本地运行时超时,请重启客户端后再登录', ); diff --git a/apps/ai-game-creator-shell/src/components/AssetImporter/ImageImporterPreview.tsx b/apps/ai-game-creator-shell/src/components/AssetImporter/ImageImporterPreview.tsx index 44c54109e..ad2b2620f 100644 --- a/apps/ai-game-creator-shell/src/components/AssetImporter/ImageImporterPreview.tsx +++ b/apps/ai-game-creator-shell/src/components/AssetImporter/ImageImporterPreview.tsx @@ -2,7 +2,7 @@ import { invoke } from '@tauri-apps/api/core'; import { useEffect, useRef, useState } from 'react'; import { SpriteImagePreview } from '../../features/ui-editor/components/SpriteImagePreview'; -import { resolveClientAssetReadUrl } from '../../services/clientApi'; +import { readEditorAssetPreview } from '../../services/editorAssetHost'; import { cancelLocalProjectResourcePreviewScope, createProjectResourcePreviewRequestId, @@ -42,9 +42,9 @@ export function ImageImporterPreview({ setPreviewUrl(selected.previewUrl); return; } - if (selected.source === 'remote' && selected.remoteObjectKey) { - void resolveClientAssetReadUrl(selected.remoteObjectKey) - .then(setPreviewUrl) + if (selected.source === 'remote' && selected.remoteAssetId) { + void readEditorAssetPreview(selected.remoteAssetId) + .then((preview) => setPreviewUrl(preview.previewUrl)) .catch(() => setPreviewUrl(null)); return; } diff --git a/apps/ai-game-creator-shell/src/components/AssetImporter/index.tsx b/apps/ai-game-creator-shell/src/components/AssetImporter/index.tsx index 6dff48d17..243018a69 100644 --- a/apps/ai-game-creator-shell/src/components/AssetImporter/index.tsx +++ b/apps/ai-game-creator-shell/src/components/AssetImporter/index.tsx @@ -8,9 +8,9 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import type { GameCreationAppAssetKind } from '../../../../../packages/shared/src/contracts/gameCreationApp'; import { - loadEditorAssetLibrary, - resolveClientAssetReadUrl, -} from '../../services/clientApi'; + readEditorAssetLibrary, + readEditorAssetPreview, +} from '../../services/editorAssetHost'; import { ThemedModal } from '../modal/ThemedModal'; import { type AssetImporterSettings, @@ -158,7 +158,7 @@ export function AssetImporter({ ); const loadRemote = useCallback(async () => { - const library = await loadEditorAssetLibrary(); + const library = await readEditorAssetLibrary(); if (!settings.remote) return []; const remoteFiles = buildRemoteFolderFiles( library, @@ -346,13 +346,11 @@ export function AssetImporter({ remoteAssets: await Promise.all( remoteChosen.map(async (item) => { const asset = item.asset!; - const downloadUrl = asset.localPath.startsWith('http') - ? asset.localPath - : await resolveClientAssetReadUrl(asset.localPath); + const preview = await readEditorAssetPreview(asset.id); return { assetId: asset.id, objectKey: asset.localPath, - downloadUrl, + downloadUrl: preview.previewUrl, }; }), ), diff --git a/apps/ai-game-creator-shell/src/components/AssetImporter/utils.ts b/apps/ai-game-creator-shell/src/components/AssetImporter/utils.ts index 42b5e1754..3dfe3e001 100644 --- a/apps/ai-game-creator-shell/src/components/AssetImporter/utils.ts +++ b/apps/ai-game-creator-shell/src/components/AssetImporter/utils.ts @@ -93,6 +93,7 @@ export type ManagerFile = FileManagerFile & { previewUrl?: string; source?: 'local' | 'remote'; remoteObjectKey?: string; + remoteAssetId?: string; asset?: ImportedAsset; /** Project-relative source path for a local disk candidate. */ localProjectPath?: string; @@ -351,12 +352,13 @@ export function buildRemoteFolderFiles( ? `${baseName} (${safeManagerName(asset.assetId, String(usedNames.size + 1))})` : baseName; usedNames.add(fileName); - const localPath = - asset.objectKey || asset.imageSrc || asset.assetId || fileName; + const assetId = + asset.assetId || asset.objectKey || asset.imageSrc || fileName; + const localPath = assetId; const candidate: RemoteAssetCandidate = { - assetId: asset.assetId || asset.objectKey || asset.imageSrc || fileName, + assetId, name: baseName, - objectKey: asset.objectKey || asset.imageSrc || fileName, + objectKey: asset.objectKey || assetId, assetKind: asset.assetKind == null ? null @@ -375,13 +377,9 @@ export function buildRemoteFolderFiles( size: asset.size, previewUrl: asset.previewUrl, source: 'remote', - remoteObjectKey: asset.objectKey, + remoteAssetId: assetId, asset: { - id: - asset.assetId || - asset.objectKey || - asset.imageSrc || - 'platform-image', + id: assetId, localPath, assetKind: asset.assetKind == null ? null : candidate.assetKind, }, diff --git a/apps/ai-game-creator-shell/src/components/error-report/ErrorReportDialog.tsx b/apps/ai-game-creator-shell/src/components/error-report/ErrorReportDialog.tsx index 216f04b61..c5f1c0a36 100644 --- a/apps/ai-game-creator-shell/src/components/error-report/ErrorReportDialog.tsx +++ b/apps/ai-game-creator-shell/src/components/error-report/ErrorReportDialog.tsx @@ -5,7 +5,6 @@ import { type ClientErrorEvent, type DiagnosticLogFile, getPendingClientErrorEvents, - getStableErrorReportSubmissionId, readApplicationDiagnosticLogs, submitErrorReportBatch, } from '../../services/errorReporting'; @@ -108,12 +107,10 @@ export function ErrorReportDialog({ setBusy(true); setStatus('正在提交…'); try { - const submissionId = getStableErrorReportSubmissionId(selectedEvents); await submitErrorReportBatch({ events: selectedEvents, logs: includeLogs ? logs : [], userDescription: description, - submissionId, }); setStatus('已提交,感谢你的反馈'); window.setTimeout(onClose, 700); diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts b/apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts index f7b69de22..926706f9d 100644 --- a/apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts @@ -1,12 +1,13 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import type { GameCreatorDirectActiveTurn, TauriInvoke } from '../../app/types'; +import { + canSubscribeTauriEvents, + subscribeTauriEvent, +} from '../../services/tauriEventSubscription'; -/** - * 轮询间隔:注册表是进程内只读快照,一次查询只是一次 IPC + 一次内存遍历。 - * "哪些项目正在跑"不值得再建一套事件流,而且轮询能在丢事件时自愈。 - */ -export const DIRECT_ACTIVE_TURNS_POLL_INTERVAL_MS = 5_000; +export const DIRECT_ACTIVE_TURNS_CHANGED_EVENT = + 'game-creator-direct-active-turns-changed'; /** * 单次刷新里的读取尝试次数。快照读取失败最多重试 3 次,3 次全部失败才把 @@ -26,11 +27,9 @@ const DIRECT_ACTIVE_TURNS_READ_RETRY_DELAY_MS = 300; export function useDirectActiveTurns({ invoke, enabled, - pollIntervalMs = DIRECT_ACTIVE_TURNS_POLL_INTERVAL_MS, }: { invoke: TauriInvoke | null | undefined; enabled: boolean; - pollIntervalMs?: number; }) { const [activeTurns, setActiveTurns] = useState( [], @@ -48,6 +47,7 @@ export function useDirectActiveTurns({ const lastSnapshotSignatureRef = useRef('[]'); const requestGenerationRef = useRef(0); const retryTimerRef = useRef(null); + const refreshPendingRef = useRef(false); useEffect(() => { mountedRef.current = true; @@ -64,10 +64,12 @@ export function useDirectActiveTurns({ if (!enabled || !invoke) { return; } - // 单飞:轮询与"回合刚开始/刚结束"的主动刷新不叠成两个在途请求。 + // 单飞:通知与手动刷新不叠成两个在途请求;在途期间到达的通知只记一笔 dirty。 if (inFlightRef.current) { + refreshPendingRef.current = true; return inFlightRef.current; } + refreshPendingRef.current = false; const generation = requestGenerationRef.current; const isCurrent = () => mountedRef.current && generation === requestGenerationRef.current; @@ -113,29 +115,62 @@ export function useDirectActiveTurns({ } })(); inFlightRef.current = request; + void request.finally(() => { + if (!isCurrent() || !refreshPendingRef.current) { + return; + } + refreshPendingRef.current = false; + void refreshActiveTurns(); + }); return request; }, [enabled, invoke]); useEffect(() => { if (!enabled || !invoke) { lastSnapshotSignatureRef.current = '[]'; + refreshPendingRef.current = false; // 空态也要保持引用稳定:已经空了就不要再换一个新数组。 setActiveTurns((current) => (current.length === 0 ? current : [])); setSnapshotReadFailed((current) => (current ? false : current)); return; } - void refreshActiveTurns(); - const timer = window.setInterval( - () => void refreshActiveTurns(), - Math.max(1_000, pollIntervalMs), - ); + let disposed = false; + let unsubscribe: (() => void) | null = null; + const setup = async () => { + if (canSubscribeTauriEvents()) { + try { + const release = await subscribeTauriEvent<{ revision?: number }>( + DIRECT_ACTIVE_TURNS_CHANGED_EVENT, + () => { + if (!disposed) { + void refreshActiveTurns(); + } + }, + ); + if (disposed) { + release(); + return; + } + unsubscribe = release; + } catch { + // 事件桥不可用时仍做一次启动快照;不恢复定时轮询。 + } + } + if (!disposed) { + // 首次读取必须发生在订阅之后,避免登记与快照之间的状态变化丢失。 + await refreshActiveTurns(); + } + }; + void setup(); return () => { - window.clearInterval(timer); + disposed = true; + unsubscribe?.(); // 停用或切换读取器后,旧请求不得覆盖新状态,也不能占住新一轮单飞。 requestGenerationRef.current += 1; inFlightRef.current = null; + refreshPendingRef.current = false; }; - }, [enabled, invoke, pollIntervalMs, refreshActiveTurns]); + }, [enabled, invoke, refreshActiveTurns]); return { activeTurns, refreshActiveTurns, snapshotReadFailed }; } diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts index 246b9ab14..379354897 100644 --- a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts @@ -891,8 +891,15 @@ function redactDirectFailureMarkers(value: string) { function directCodexDiagnosticFailureDetail(message: string) { const trimmed = message.trim(); const match = + // v1 是历史记录里的形状。 /^direct-codex-failure:v1 stage=(request|art-preparation|code-generation|browser-validation|version-registration) retryable=(true|false) summary=(.+?);建议:(.+?);(?:已保存脱敏项目诊断|未能保存项目诊断)$/u.exec( trimmed, + ) ?? + // v2 是当前 Native 运行时实际下发的形状:在 v1 之上追加了 `code=`,并在末尾拼上 + // 诊断引用(`;详情:.agent/runtime/errors/.json`)。引用只服务「查看详情」 + // 入口,不是用户可见文案,因此 `code=` 与引用都不进捕获组。 + /^direct-codex-failure:v2 stage=(request|art-preparation|code-generation|browser-validation|version-registration) code=[a-z0-9-]+ retryable=(true|false) summary=(.+?);建议:(.+?);(?:已保存脱敏项目诊断|未能保存项目诊断)(?:;详情:\S+)?$/u.exec( + trimmed, ); if (!match) { return null; 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 ba86f1dcb..81fa83256 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 @@ -23,11 +23,6 @@ import { projectPathHasControlCharacter, } from '../project-summary/projectSummary'; -const RECENT_WORKSPACES_STORAGE_KEY = - 'genarrative-ai-game-creator.recent-workspaces.v1'; -const PROJECT_CREATION_DIRECTORY_STORAGE_KEY = - 'genarrative-ai-game-creator.project-creation-directory.v1'; - export type WorkspaceLauncherProps = { currentUser: AuthUser; onLogout: () => void; @@ -107,7 +102,7 @@ export type LauncherNotice = { message: string; }; -function normalizeRecentWorkspaceList(values: unknown[]) { +export function normalizeRecentWorkspaces(values: unknown[]) { const recent: string[] = []; for (const value of values) { if (typeof value !== 'string') { @@ -129,52 +124,6 @@ function normalizeRecentWorkspaceList(values: unknown[]) { return recent; } -export function readRecentWorkspaces() { - try { - const raw = window.localStorage.getItem(RECENT_WORKSPACES_STORAGE_KEY); - const parsed: unknown = raw ? JSON.parse(raw) : []; - return Array.isArray(parsed) ? normalizeRecentWorkspaceList(parsed) : []; - } catch { - return []; - } -} - -export function writeRecentWorkspace(path: string) { - const recent = normalizeRecentWorkspaceList([ - path, - ...readRecentWorkspaces(), - ]); - try { - window.localStorage.setItem( - RECENT_WORKSPACES_STORAGE_KEY, - JSON.stringify(recent), - ); - } catch { - // WebView storage can be unavailable in restricted test shells. - } - return recent; -} - -export function removeRecentWorkspace(path: string) { - const trimmedPath = path.trim(); - const recent = readRecentWorkspaces().filter( - (workspace) => workspace !== trimmedPath, - ); - try { - if (recent.length > 0) { - window.localStorage.setItem( - RECENT_WORKSPACES_STORAGE_KEY, - JSON.stringify(recent), - ); - } else { - window.localStorage.removeItem(RECENT_WORKSPACES_STORAGE_KEY); - } - } catch { - // WebView storage can be unavailable in restricted test shells. - } - return recent; -} - /** * 「项目创建目录」偏好:空串表示沿用 AGC 管理的默认位置(应用数据目录下的 projects)。 * @@ -183,7 +132,11 @@ export function removeRecentWorkspace(path: string) { */ export function normalizeProjectCreationDirectory(value: string) { const trimmed = value.trim(); - if (!trimmed || projectPathHasControlCharacter(trimmed)) { + if ( + !trimmed || + !isAbsoluteProjectPath(trimmed) || + projectPathHasControlCharacter(trimmed) + ) { return ''; } const withoutTrailingSeparator = trimmed.replace(/[\\/]+$/, ''); @@ -193,39 +146,6 @@ export function normalizeProjectCreationDirectory(value: string) { : withoutTrailingSeparator; } -export function readProjectCreationDirectory() { - try { - const raw = window.localStorage.getItem( - PROJECT_CREATION_DIRECTORY_STORAGE_KEY, - ); - const parsed: unknown = raw ? JSON.parse(raw) : ''; - if (typeof parsed !== 'string') { - return ''; - } - const directory = normalizeProjectCreationDirectory(parsed); - return isAbsoluteProjectPath(directory) ? directory : ''; - } catch { - return ''; - } -} - -export function writeProjectCreationDirectory(path: string) { - const directory = normalizeProjectCreationDirectory(path); - try { - if (directory) { - window.localStorage.setItem( - PROJECT_CREATION_DIRECTORY_STORAGE_KEY, - JSON.stringify(directory), - ); - } else { - window.localStorage.removeItem(PROJECT_CREATION_DIRECTORY_STORAGE_KEY); - } - } catch { - // WebView storage can be unavailable in restricted test shells. - } - return directory; -} - export function isTransientProjectOpenMessage( message: ChatMessage, projectPath: string, diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts b/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts index 4005134e0..1d6f0a339 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts @@ -13,7 +13,7 @@ import { getClientProfileRechargeCenter, getClientProfileWalletLedger, redeemClientProfileRewardCode, -} from '../../services/clientApi'; +} from '../../services/accountHost'; import { useWalletStore } from '../../stores/useWalletStore'; export function useAccountWallet(currentUserId: string) { 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 f47f69b4a..a1ae6592e 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 @@ -58,7 +58,6 @@ import { ensureHomeWebCreationEnvironment, HOME_WEB_PREFLIGHT_FAILURE, } from './homeWebPreflight'; -import { readProjectCreationDirectory } from './model'; import { resolveSessionPreviewOnProjectOpen } from './sessionPreview'; /** 首页输入框当前的纯文本(Lexical 编辑器状态 -> 文本);没有输入就返回空串。 */ @@ -898,9 +897,6 @@ export function useHomeProjectCreation({ { name: suggestedName, planning: startMode === 'planning', - // 用户在首页选过「项目创建目录」就用它;没选传 null,由 Rust 侧回落到 - // AGC 管理的默认位置(应用数据目录下的 projects)。 - projectsRoot: readProjectCreationDirectory() || null, }, ); createdProjectPath = result.projectPath; diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useProjectCreationDirectory.ts b/apps/ai-game-creator-shell/src/features/app-shell/useProjectCreationDirectory.ts index 1f8a772b3..f7b9c2146 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useProjectCreationDirectory.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useProjectCreationDirectory.ts @@ -1,10 +1,11 @@ -import { useCallback, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { resolveTauriInvoke } from '../../app/tauri'; import { - readProjectCreationDirectory, - writeProjectCreationDirectory, -} from './model'; + readWorkspacePreferences, + setProjectCreationDirectory, + subscribeWorkspacePreferences, +} from './workspacePreferences'; /** * 「项目创建目录」用户偏好。 @@ -14,14 +15,76 @@ import { * 再由 Rust 侧私有路径门禁复核一次。 */ export function useProjectCreationDirectory() { - const [projectCreationDirectory, setProjectCreationDirectory] = useState( - readProjectCreationDirectory, - ); + const [projectCreationDirectory, setProjectCreationDirectoryState] = + useState(''); const [projectCreationDirectoryBusy, setProjectCreationDirectoryBusy] = useState(false); const [projectCreationDirectoryStatus, setProjectCreationDirectoryStatus] = useState(''); const pickInFlightRef = useRef(false); + const projectCreationDirectorySignatureRef = useRef(''); + const workspacePreferenceEventGenerationRef = useRef(0); + + function applyWorkspacePreferencesSnapshot( + directory: string | null, + ): boolean { + const next = directory ?? ''; + if (projectCreationDirectorySignatureRef.current === next) { + return false; + } + projectCreationDirectorySignatureRef.current = next; + setProjectCreationDirectoryState(next); + return true; + } + + useEffect(() => { + let disposed = false; + let unsubscribe: (() => void) | null = null; + const setup = async () => { + try { + unsubscribe = await subscribeWorkspacePreferences((snapshot) => { + if (!disposed) { + workspacePreferenceEventGenerationRef.current += 1; + applyWorkspacePreferencesSnapshot( + snapshot.projectCreationDirectory, + ); + } + }); + } catch (error) { + if (!disposed) { + setProjectCreationDirectoryStatus( + error instanceof Error ? error.message : String(error), + ); + } + } + if (disposed) { + return; + } + const eventGenerationAtRead = + workspacePreferenceEventGenerationRef.current; + try { + const snapshot = await readWorkspacePreferences(); + if ( + !disposed && + workspacePreferenceEventGenerationRef.current === + eventGenerationAtRead + ) { + applyWorkspacePreferencesSnapshot(snapshot.projectCreationDirectory); + } + } catch (error) { + if (!disposed) { + setProjectCreationDirectoryStatus( + error instanceof Error ? error.message : String(error), + ); + } + } + }; + void setup(); + return () => { + disposed = true; + unsubscribe?.(); + }; + }, []); const pickProjectCreationDirectory = useCallback(async () => { if (pickInFlightRef.current) { @@ -49,7 +112,8 @@ export function useProjectCreationDirectory() { setProjectCreationDirectoryStatus('已取消'); return; } - setProjectCreationDirectory(writeProjectCreationDirectory(selected)); + const snapshot = await setProjectCreationDirectory(selected); + applyWorkspacePreferencesSnapshot(snapshot.projectCreationDirectory); setProjectCreationDirectoryStatus('已更新项目创建目录'); } catch (error) { setProjectCreationDirectoryStatus( @@ -62,9 +126,16 @@ export function useProjectCreationDirectory() { }, [projectCreationDirectory]); const resetProjectCreationDirectory = useCallback(() => { - writeProjectCreationDirectory(''); - setProjectCreationDirectory(''); - setProjectCreationDirectoryStatus('已恢复默认位置'); + void setProjectCreationDirectory(null) + .then((snapshot) => { + applyWorkspacePreferencesSnapshot(snapshot.projectCreationDirectory); + setProjectCreationDirectoryStatus('已恢复默认位置'); + }) + .catch((error) => { + setProjectCreationDirectoryStatus( + error instanceof Error ? error.message : String(error), + ); + }); }, []); return { 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 00080005e..30accd232 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 @@ -13,12 +13,13 @@ import { isAbsoluteProjectPath, projectPathHasControlCharacter, } from '../project-summary/projectSummary'; +import { buildRecentProjectRows } from './model'; import { - buildRecentProjectRows, - readRecentWorkspaces, - removeRecentWorkspace, - writeRecentWorkspace, -} from './model'; + readWorkspacePreferences, + rememberRecentWorkspace as rememberRecentWorkspacePreference, + removeRecentWorkspace as removeRecentWorkspacePreference, + subscribeWorkspacePreferences, +} from './workspacePreferences'; const RECENT_WORKSPACE_CHECK_TIMEOUT_MS = 5_000; /** 单次检查失败后的就地重试退避,数组长度即重试次数。 */ @@ -107,8 +108,9 @@ async function inspectRecentWorkspaceWithRetry( } export function useRecentProjects(setStatus: Dispatch>) { - const [recentWorkspaces, setRecentWorkspaces] = - useState(readRecentWorkspaces); + const setStatusRef = useRef(setStatus); + setStatusRef.current = setStatus; + const [recentWorkspaces, setRecentWorkspaces] = useState([]); const [recentWorkspaceStatuses, setRecentWorkspaceStatuses] = useState< Record >({}); @@ -124,6 +126,68 @@ export function useRecentProjects(setStatus: Dispatch>) { const lastRetryableFailureKeyRef = useRef(''); // 提权类失败在用户再次主动操作前不再自动重试。 const nonRetryablePathsRef = useRef>(new Set()); + const recentWorkspaceSignatureRef = useRef(''); + const workspacePreferenceEventGenerationRef = useRef(0); + + function applyWorkspacePreferencesSnapshot( + recent: readonly string[], + ): boolean { + const signature = recent.join('\u0000'); + if (recentWorkspaceSignatureRef.current === signature) { + return false; + } + recentWorkspaceSignatureRef.current = signature; + setRecentWorkspaces([...recent]); + setRecentWorkspaceRefreshKey((current) => current + 1); + return true; + } + + useEffect(() => { + let disposed = false; + let unsubscribe: (() => void) | null = null; + const setup = async () => { + try { + unsubscribe = await subscribeWorkspacePreferences((snapshot) => { + if (!disposed) { + workspacePreferenceEventGenerationRef.current += 1; + applyWorkspacePreferencesSnapshot(snapshot.recentWorkspaces); + } + }); + } catch (error) { + if (!disposed) { + setStatusRef.current( + error instanceof Error ? error.message : String(error), + ); + } + } + if (disposed) { + return; + } + const eventGenerationAtRead = + workspacePreferenceEventGenerationRef.current; + try { + const snapshot = await readWorkspacePreferences(); + if ( + !disposed && + workspacePreferenceEventGenerationRef.current === + eventGenerationAtRead + ) { + applyWorkspacePreferencesSnapshot(snapshot.recentWorkspaces); + } + } catch (error) { + if (!disposed) { + setStatusRef.current( + error instanceof Error ? error.message : String(error), + ); + } + } + }; + void setup(); + return () => { + disposed = true; + unsubscribe?.(); + }; + }, []); function scheduleFailureRecheck(retryableFailureCount: number) { window.clearTimeout(failureRecheckTimerRef.current); @@ -225,8 +289,22 @@ export function useRecentProjects(setStatus: Dispatch>) { function rememberRecentWorkspace(projectPath: string) { // 用户主动打开或新建项目:解除提权类失败的跳过标记。 nonRetryablePathsRef.current.clear(); - setRecentWorkspaces(writeRecentWorkspace(projectPath)); - setRecentWorkspaceRefreshKey((current) => current + 1); + const eventGenerationAtCommand = + workspacePreferenceEventGenerationRef.current; + void rememberRecentWorkspacePreference(projectPath) + .then((snapshot) => { + if ( + workspacePreferenceEventGenerationRef.current === + eventGenerationAtCommand + ) { + applyWorkspacePreferencesSnapshot(snapshot.recentWorkspaces); + } + }) + .catch((error) => { + setStatusRef.current( + error instanceof Error ? error.message : String(error), + ); + }); } async function refreshRecentWorkspace(projectPath: string) { @@ -257,11 +335,26 @@ export function useRecentProjects(setStatus: Dispatch>) { function handleRecentWorkspaceRemove(projectPath: string) { nonRetryablePathsRef.current.delete(projectPath); - setRecentWorkspaces(removeRecentWorkspace(projectPath)); - setRecentWorkspaceStatuses((current) => { - const { [projectPath]: _removed, ...rest } = current; - return rest; - }); + const eventGenerationAtCommand = + workspacePreferenceEventGenerationRef.current; + void removeRecentWorkspacePreference(projectPath) + .then((snapshot) => { + if ( + workspacePreferenceEventGenerationRef.current === + eventGenerationAtCommand + ) { + applyWorkspacePreferencesSnapshot(snapshot.recentWorkspaces); + } + setRecentWorkspaceStatuses((current) => { + const { [projectPath]: _removed, ...rest } = current; + return rest; + }); + }) + .catch((error) => { + setStatusRef.current( + error instanceof Error ? error.message : String(error), + ); + }); } async function handleRevealProjectDirectory(projectPath: string) { diff --git a/apps/ai-game-creator-shell/src/features/app-shell/workspacePreferences.ts b/apps/ai-game-creator-shell/src/features/app-shell/workspacePreferences.ts new file mode 100644 index 000000000..fc10237f4 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/app-shell/workspacePreferences.ts @@ -0,0 +1,352 @@ +import { resolveTauriInvoke } from '../../app/tauri'; +import { + canSubscribeTauriEvents, + subscribeTauriEvent, + type TauriEventUnsubscribe, +} from '../../services/tauriEventSubscription'; +import { + normalizeProjectCreationDirectory, + normalizeRecentWorkspaces, +} from './model'; + +export const WORKSPACE_PREFERENCES_CHANGED_EVENT = + 'game-creator-workspace-preferences-changed'; + +export type WorkspacePreferencesSnapshot = { + projectCreationDirectory: string | null; + recentWorkspaces: string[]; + chatPromptPolishReminderDisabled: boolean; +}; + +const EMPTY_WORKSPACE_PREFERENCES: WorkspacePreferencesSnapshot = { + projectCreationDirectory: null, + recentWorkspaces: [], + chatPromptPolishReminderDisabled: false, +}; + +const LEGACY_RECENT_WORKSPACES_STORAGE_KEY = + 'genarrative-ai-game-creator.recent-workspaces.v1'; +const LEGACY_PROJECT_CREATION_DIRECTORY_STORAGE_KEY = + 'genarrative-ai-game-creator.project-creation-directory.v1'; + +let browserSnapshot: WorkspacePreferencesSnapshot = { + ...EMPTY_WORKSPACE_PREFERENCES, +}; +let legacyMigrationPromise: Promise | null = null; + +function isTestRuntime() { + return import.meta.env.MODE === 'test'; +} + +function normalizeSnapshot( + value: Partial | null | undefined, +): WorkspacePreferencesSnapshot { + return { + projectCreationDirectory: + normalizeProjectCreationDirectory( + typeof value?.projectCreationDirectory === 'string' + ? value.projectCreationDirectory + : '', + ) || null, + recentWorkspaces: normalizeRecentWorkspaces( + Array.isArray(value?.recentWorkspaces) ? value.recentWorkspaces : [], + ), + chatPromptPolishReminderDisabled: + value?.chatPromptPolishReminderDisabled === true, + }; +} + +function updateBrowserSnapshot( + snapshot: WorkspacePreferencesSnapshot, +): WorkspacePreferencesSnapshot { + browserSnapshot = normalizeSnapshot(snapshot); + return browserSnapshot; +} + +function requireNativeSnapshot( + snapshot: WorkspacePreferencesSnapshot | null, + message: string, +) { + if (!snapshot) { + throw new Error(message); + } + return snapshot; +} + +function readLegacySnapshot(): WorkspacePreferencesSnapshot { + try { + const recentRaw = window.localStorage.getItem( + LEGACY_RECENT_WORKSPACES_STORAGE_KEY, + ); + const directoryRaw = window.localStorage.getItem( + LEGACY_PROJECT_CREATION_DIRECTORY_STORAGE_KEY, + ); + const recentParsed: unknown = recentRaw ? JSON.parse(recentRaw) : []; + const directoryParsed: unknown = directoryRaw + ? JSON.parse(directoryRaw) + : ''; + return normalizeSnapshot({ + projectCreationDirectory: + typeof directoryParsed === 'string' ? directoryParsed : '', + recentWorkspaces: Array.isArray(recentParsed) ? recentParsed : [], + }); + } catch { + return { ...EMPTY_WORKSPACE_PREFERENCES }; + } +} + +function clearLegacySnapshot() { + try { + window.localStorage.removeItem(LEGACY_RECENT_WORKSPACES_STORAGE_KEY); + window.localStorage.removeItem( + LEGACY_PROJECT_CREATION_DIRECTORY_STORAGE_KEY, + ); + } catch { + // 隐私模式或受限 WebView 不可写时,Rust 快照仍是正式数据源。 + } +} + +function writeLegacySnapshot(snapshot: WorkspacePreferencesSnapshot) { + try { + if (snapshot.recentWorkspaces.length > 0) { + window.localStorage.setItem( + LEGACY_RECENT_WORKSPACES_STORAGE_KEY, + JSON.stringify(snapshot.recentWorkspaces), + ); + } else { + window.localStorage.removeItem(LEGACY_RECENT_WORKSPACES_STORAGE_KEY); + } + if (snapshot.projectCreationDirectory) { + window.localStorage.setItem( + LEGACY_PROJECT_CREATION_DIRECTORY_STORAGE_KEY, + JSON.stringify(snapshot.projectCreationDirectory), + ); + } else { + window.localStorage.removeItem( + LEGACY_PROJECT_CREATION_DIRECTORY_STORAGE_KEY, + ); + } + } catch { + // 仅测试兼容 fallback;生产命令失败会向调用方报告。 + } +} + +async function migrateLegacySnapshot( + invoke: NonNullable>, + snapshot: WorkspacePreferencesSnapshot, +) { + if ( + snapshot.projectCreationDirectory !== null || + snapshot.recentWorkspaces.length > 0 + ) { + return snapshot; + } + const legacy = readLegacySnapshot(); + if ( + legacy.projectCreationDirectory === null && + legacy.recentWorkspaces.length === 0 + ) { + return snapshot; + } + const migrated = await invoke( + 'migrate_workspace_preferences', + legacy, + ); + if (!migrated) { + return legacy; + } + clearLegacySnapshot(); + return normalizeSnapshot(migrated); +} + +export async function readWorkspacePreferences(): Promise { + const invoke = resolveTauriInvoke(); + if (!invoke) { + return browserSnapshot; + } + let snapshot: WorkspacePreferencesSnapshot; + try { + const nativeSnapshot = await invoke( + 'read_workspace_preferences', + ); + if (!nativeSnapshot) { + if (!isTestRuntime()) { + return browserSnapshot; + } + return updateBrowserSnapshot(readLegacySnapshot()); + } + snapshot = normalizeSnapshot(nativeSnapshot); + } catch (error) { + if (!isTestRuntime()) { + throw error; + } + return updateBrowserSnapshot(readLegacySnapshot()); + } + if (!legacyMigrationPromise) { + legacyMigrationPromise = migrateLegacySnapshot(invoke, snapshot).finally( + () => { + legacyMigrationPromise = null; + }, + ); + } + return updateBrowserSnapshot(await legacyMigrationPromise); +} + +export async function setProjectCreationDirectory( + directory: string | null, +): Promise { + const normalized = normalizeProjectCreationDirectory(directory ?? ''); + const invoke = resolveTauriInvoke(); + if (!invoke) { + return updateBrowserSnapshot({ + ...browserSnapshot, + projectCreationDirectory: normalized || null, + }); + } + try { + return updateBrowserSnapshot( + normalizeSnapshot( + requireNativeSnapshot( + await invoke( + 'set_project_creation_directory', + { directory: normalized || null }, + ), + '工作区偏好写入没有返回快照', + ), + ), + ); + } catch (error) { + if (!isTestRuntime()) { + throw error; + } + const snapshot = updateBrowserSnapshot({ + ...browserSnapshot, + projectCreationDirectory: normalized || null, + }); + writeLegacySnapshot(snapshot); + return snapshot; + } +} + +export async function rememberRecentWorkspace( + projectPath: string, +): Promise { + const invoke = resolveTauriInvoke(); + if (!invoke) { + return updateBrowserSnapshot({ + ...browserSnapshot, + recentWorkspaces: normalizeRecentWorkspaces([ + projectPath, + ...browserSnapshot.recentWorkspaces, + ]), + }); + } + try { + return updateBrowserSnapshot( + normalizeSnapshot( + requireNativeSnapshot( + await invoke( + 'remember_recent_workspace', + { projectPath }, + ), + '最近工作区写入没有返回快照', + ), + ), + ); + } catch (error) { + if (!isTestRuntime()) { + throw error; + } + const snapshot = updateBrowserSnapshot({ + ...browserSnapshot, + recentWorkspaces: normalizeRecentWorkspaces([ + projectPath, + ...browserSnapshot.recentWorkspaces, + ]), + }); + writeLegacySnapshot(snapshot); + return snapshot; + } +} + +export async function removeRecentWorkspace( + projectPath: string, +): Promise { + const invoke = resolveTauriInvoke(); + if (!invoke) { + return updateBrowserSnapshot({ + ...browserSnapshot, + recentWorkspaces: browserSnapshot.recentWorkspaces.filter( + (path) => path !== projectPath.trim(), + ), + }); + } + try { + return updateBrowserSnapshot( + normalizeSnapshot( + requireNativeSnapshot( + await invoke( + 'remove_recent_workspace', + { projectPath }, + ), + '最近工作区移除没有返回快照', + ), + ), + ); + } catch (error) { + if (!isTestRuntime()) { + throw error; + } + const snapshot = updateBrowserSnapshot({ + ...browserSnapshot, + recentWorkspaces: browserSnapshot.recentWorkspaces.filter( + (path) => path !== projectPath.trim(), + ), + }); + writeLegacySnapshot(snapshot); + return snapshot; + } +} + +export async function setChatPromptPolishReminderDisabled( + disabled: boolean, +): Promise { + const invoke = resolveTauriInvoke(); + if (!invoke) { + return updateBrowserSnapshot({ + ...browserSnapshot, + chatPromptPolishReminderDisabled: disabled, + }); + } + return updateBrowserSnapshot( + normalizeSnapshot( + requireNativeSnapshot( + await invoke( + 'set_chat_prompt_polish_reminder_disabled', + { disabled }, + ), + '聊天提醒偏好写入没有返回快照', + ), + ), + ); +} + +export function subscribeWorkspacePreferences( + onChanged: (snapshot: WorkspacePreferencesSnapshot) => void, +): Promise { + if (!canSubscribeTauriEvents()) { + return Promise.resolve(() => {}); + } + return subscribeTauriEvent( + WORKSPACE_PREFERENCES_CHANGED_EVENT, + (event) => { + const snapshot = updateBrowserSnapshot(normalizeSnapshot(event.payload)); + onChanged(snapshot); + }, + ); +} + +export function resetWorkspacePreferencesForTests() { + browserSnapshot = { ...EMPTY_WORKSPACE_PREFERENCES }; + legacyMigrationPromise = null; +} diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx index 0a0426d8e..2da81b306 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx @@ -10,14 +10,10 @@ import { import { resolveTauriInvoke } from '../../app/tauri'; import type { GameCreatorAppConfigView } from '../../app/types'; -import type { - ClientLlmModel, - ClientLlmModelCatalog, -} from '../../services/clientApi'; -import { ClientAuthRequestError } from '../../services/clientApi'; -import { ClientHttpTimeoutError } from '../../services/clientHttp'; import { cachedLlmModelCatalog, + type ClientLlmModel, + type ClientLlmModelCatalog, LLM_CONFIG_CHANGED_EVENT, LlmModelCatalogConfigError, refreshLlmModelCatalog, @@ -31,12 +27,19 @@ export type ConversationModelSelectHandle = { /** 客户端配置读取/写回失败:与「模型目录加载失败」区分,避免误导提示。 */ class ModelSelectionConfigError extends Error {} +/** + * 目录失败文案:Rust typed command 已把结果归类成稳定字符串,这里不再依赖带 HTTP 状态码的 + * 渲染层错误类型,也不把服务端原文(可能含内部地址)直接铺给用户。 + */ function modelCatalogErrorMessage(error: unknown) { if (error instanceof LlmModelCatalogConfigError) return error.message; - if (error instanceof ClientHttpTimeoutError) + const message = error instanceof Error ? error.message : String(error ?? ''); + if (message.includes('authentication-required')) { + return '模型列表加载失败:登录已失效,请重新登录'; + } + if (/超时|timed?\s*out|timeout/iu.test(message)) { return '模型列表请求超时,请重试'; - if (error instanceof ClientAuthRequestError && error.status) - return `模型列表加载失败(HTTP ${error.status})`; + } return '模型列表加载失败'; } 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 9abe553e0..6da0d8280 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 @@ -41,11 +41,13 @@ import { import { createPortal } from 'react-dom'; import RichTextInput from '../../components/RichTextInput'; +import { captureClientError } from '../../services/errorReporting'; import type { DirectCodexUserContentPart } from '../../view/project-development/chat/generated/DirectCodexUserContentPart'; import { chatPromptDraftKey, readChatPromptPolishReminderDisabled, shouldRemindChatPromptPolish, + subscribeChatPromptPolishReminderDisabled, writeChatPromptPolishReminderDisabled, } from './chatPromptPolish'; import { ChatPromptPolishReminder } from './ChatPromptPolishReminder'; @@ -656,9 +658,8 @@ function ResourceReferenceEditor({ // 聊天特有的「发送前提醒」:提醒偏好、本轮已确认草稿指纹与表单拦截。 const [reminderOpen, setReminderOpen] = useState(false); const [reminderPolishing, setReminderPolishing] = useState(false); - const [reminderDisabled, setReminderDisabled] = useState(() => - readChatPromptPolishReminderDisabled(), - ); + // 偏好正式数据在 Rust:初始值只是「未关闭」的占位,挂载后由快照与事件补齐。 + const [reminderDisabled, setReminderDisabled] = useState(false); const acknowledgedDraftKeyRef = useRef(null); // 拦截表单提交需要读到最新草稿,用 ref 保存本次渲染的草稿与派生值,避免闭包读到旧值。 const liveDraftRef = useRef({ @@ -670,6 +671,44 @@ function ResourceReferenceEditor({ const reminderDisabledRef = useRef(reminderDisabled); reminderDisabledRef.current = reminderDisabled; + /** + * 偏好写入代次。 + * + * 偏好正式数据在 Rust:挂载时读一次快照并订阅事件。用户点击「不再提醒」或 Rust 事件 + * 到达都会推进代次,迟到的挂载快照因此不能把更新的选择覆盖回旧值。 + */ + const reminderPreferenceGenerationRef = useRef(0); + + useEffect(() => { + let disposed = false; + let unsubscribe: (() => void) | null = null; + void subscribeChatPromptPolishReminderDisabled((disabled) => { + if (disposed) return; + reminderPreferenceGenerationRef.current += 1; + setReminderDisabled(disabled); + }) + .then((release) => { + if (disposed) release(); + else unsubscribe = release; + }) + .catch(() => undefined); + // 快照读取与订阅并发,但只有「读取期间没有任何更新」时才允许写回。 + const generationAtRead = reminderPreferenceGenerationRef.current; + void readChatPromptPolishReminderDisabled().then((disabled) => { + if ( + disposed || + reminderPreferenceGenerationRef.current !== generationAtRead + ) { + return; + } + setReminderDisabled(disabled); + }); + return () => { + disposed = true; + unsubscribe?.(); + }; + }, []); + /** * 我们自己回填进草稿的那一份文本,用于区分「润色回填」与「用户手改」: * 只有后者该把上一轮往返留下的提示(截断 / 与原文相同)收掉, @@ -765,8 +804,18 @@ function ResourceReferenceEditor({ }, [reminderPolishing, runPolish, submitCurrentDraft]); const setPromptPolishReminderDisabled = useCallback((disabled: boolean) => { - writeChatPromptPolishReminderDisabled(disabled); + // 先本地收口交互反馈,再以 Rust 落盘后的投影为准;失败回退并提示。 + reminderPreferenceGenerationRef.current += 1; setReminderDisabled(disabled); + void writeChatPromptPolishReminderDisabled(disabled) + .then((persisted) => setReminderDisabled(persisted)) + .catch((error) => { + setReminderDisabled(!disabled); + captureClientError(error, { + source: 'chat-prompt-polish', + action: 'set-reminder-disabled', + }); + }); }, []); // 发送前提醒拦截:在捕获阶段拦下 form 的 submit,阻止 React 的表单提交处理器执行, 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 435b76604..a35ca1a4c 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,60 +1,51 @@ import { resolveTauriInvoke } from '../../app/tauri'; import type { DirectCodexUserContentPart } from '../../view/project-development/chat/generated/DirectCodexUserContentPart'; +import { + readWorkspacePreferences, + setChatPromptPolishReminderDisabled as setChatPromptPolishReminderPreference, + subscribeWorkspacePreferences, +} from '../app-shell/workspacePreferences'; /** - * 「不再提醒」偏好存本机 localStorage,不进 manifest、不进后端。 + * 「不再提醒」偏好由 Rust 客户端偏好存储持有,不进 manifest、不进后端。 * - * **刻意只留这一个全局键,不按入口分域**:它关掉的是聊天输入区那一条「发送前提醒」 + * **刻意只留这一个全局偏好,不按入口分域**:它关掉的是聊天输入区那一条「发送前提醒」 * (纯文本 ≥40 字)——资源侧的生成素材 / 快速编辑没有这条提醒可关,润色在那里 * 是显式按钮动作,不存在被打断的场景。分域只有在「每个入口都有可关的提醒」时才有意义, * 现在分域等于给不存在的提醒提前留位。 - */ -export const CHAT_PROMPT_POLISH_REMINDER_STORAGE_KEY = - 'agc.chat.prompt-polish-reminder.disabled'; - -/** - * 发送前提醒判据(长度阈值)。 - * 纯文本(trim 后)达到该长度且用户没有关闭提醒、且本轮草稿还没有润色过 / 确认过时, - * 点发送会先弹出独立提醒面板,而不是直接发出。 + * + * 渲染层只在内存里缓存当前投影:初始为「未关闭」,挂载后读一次 Rust 快照并订阅偏好事件; + * 原生桥不可用(浏览器预览、单元测试)时保持默认值,不写任何本地存储。 */ export const CHAT_PROMPT_POLISH_REMINDER_MIN_TEXT_LENGTH = 40; -function readLocalStorage(): Storage | null { +/** 读取 Rust 偏好投影;原生桥不可用时按未关闭处理。 */ +export async function readChatPromptPolishReminderDisabled(): Promise { + if (!resolveTauriInvoke()) return false; try { - return typeof window === 'undefined' ? null : window.localStorage; - } catch { - return null; - } -} - -/** 读取本机的「不再提醒」偏好;存储不可用时按未关闭处理。 */ -export function readChatPromptPolishReminderDisabled(): boolean { - const storage = readLocalStorage(); - if (!storage) { - return false; - } - try { - return storage.getItem(CHAT_PROMPT_POLISH_REMINDER_STORAGE_KEY) === 'true'; + const snapshot = await readWorkspacePreferences(); + return snapshot.chatPromptPolishReminderDisabled; } catch { return false; } } -/** 写入本机的「不再提醒」偏好;存储不可用时静默跳过,不影响本次发送。 */ -export function writeChatPromptPolishReminderDisabled(disabled: boolean) { - const storage = readLocalStorage(); - if (!storage) { - return; - } - try { - if (disabled) { - storage.setItem(CHAT_PROMPT_POLISH_REMINDER_STORAGE_KEY, 'true'); - } else { - storage.removeItem(CHAT_PROMPT_POLISH_REMINDER_STORAGE_KEY); - } - } catch { - // 浏览器隐私模式等场景下 localStorage 不可写:偏好只当次生效,不阻断发送。 - } +/** 写入 Rust 偏好并返回落盘后的投影;原生桥不可用时只当成次生效。 */ +export async function writeChatPromptPolishReminderDisabled( + disabled: boolean, +): Promise { + if (!resolveTauriInvoke()) return disabled; + const snapshot = await setChatPromptPolishReminderPreference(disabled); + return snapshot.chatPromptPolishReminderDisabled; +} + +/** 订阅 Rust 偏好事件,返回幂等释放函数。 */ +export function subscribeChatPromptPolishReminderDisabled( + onChanged: (disabled: boolean) => void, +): Promise<() => void> { + return subscribeWorkspacePreferences((snapshot) => { + onChanged(snapshot.chatPromptPolishReminderDisabled); + }); } /** 草稿指纹:用于判断「本轮草稿」是否已经被润色或确认过。 */ @@ -66,7 +57,7 @@ export function chatPromptDraftKey( /** * 发送前提醒判据(全部满足才提醒): - * 1. 提醒没有被用户在偏好里关掉(本机 localStorage); + * 1. 提醒没有被用户在 Rust 客户端偏好里关掉; * 2. 当前草稿指纹不等于「本轮已确认草稿」指纹 —— 即本轮还没有润色过、也没有选过「使用原文提交」; * 3. 纯文本(trim 后)长度达到 {@link CHAT_PROMPT_POLISH_REMINDER_MIN_TEXT_LENGTH}。 */ diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationQueue.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationQueue.ts index 35801a3e3..e8e867daf 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationQueue.ts +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationQueue.ts @@ -8,6 +8,8 @@ import { } from './resourceCanvasAssetGenerationTaskModel'; export const RESOURCE_CANVAS_ASSET_GENERATION_POLL_INTERVAL_MILLIS = 2_000; +export const RESOURCE_CANVAS_ASSET_GENERATION_CHANGED_EVENT = + 'game-creator-asset-generation-task-changed'; /** * 账本里连续多少次找不到这条任务就放弃等待。 @@ -43,6 +45,15 @@ export type ResourceCanvasAssetGenerationSettlement = { export type ResourceCanvasAssetGenerationQueueDeps = { invoke(command: string, args: Record): Promise; + /** + * Rust 账本变化通知。真实 Tauri 运行时使用事件唤醒,测试/浏览器预览未提供桥接时 + * 才回退到注入的 wait(不在产品运行时建立定时器)。 + */ + subscribe?: ( + projectPath: string, + taskId: string, + onChanged: () => void, + ) => Promise<() => void>; /** * 当前项目路径。 * @@ -141,6 +152,35 @@ export function createResourceCanvasAssetGenerationQueue( task: ResourceCanvasAssetGenerationTask, ): Promise { const projectPath = deps.projectPath(); + let releaseTaskSubscription: (() => void) | null = null; + let taskEventPending = false; + let resolveTaskEvent: (() => void) | null = null; + const waitForTaskEvent = () => { + if (taskEventPending) { + taskEventPending = false; + return Promise.resolve(); + } + return new Promise((resolve) => { + resolveTaskEvent = resolve; + }); + }; + if (deps.subscribe) { + try { + releaseTaskSubscription = await deps.subscribe( + projectPath, + task.taskId, + () => { + taskEventPending = true; + const resolve = resolveTaskEvent; + resolveTaskEvent = null; + resolve?.(); + }, + ); + } catch { + // 事件桥不可用时仅回退到测试/浏览器预览的注入等待器。 + releaseTaskSubscription = null; + } + } // 命令名写成字面量:`scripts/check-config.mjs` 的 invoke 门禁按字符串字面量登记调用方, // 抽成常量会让这两条 IPC 被判成「没有前端调用方」。 /* @@ -170,76 +210,84 @@ export function createResourceCanvasAssetGenerationQueue( : {}), }), })) as LocalProjectAssetGenerationTaskRecord; - let started: LocalProjectAssetGenerationTaskRecord; try { - started = await (deps.refreshPlatformSession - ? deps.refreshPlatformSession(start) - : start()); - } catch (error) { - return { - taskId: task.taskId, - projectId: task.projectId, - status: 'failed', - record: null, - error: errorMessage(error), - }; - } - replaceFromRecord(task, started); - let missingRecordPolls = 0; - let readFailed = false; - let lastReadError = RESOURCE_CANVAS_ASSET_GENERATION_MALFORMED_READ_ERROR; - for (;;) { - let records: LocalProjectAssetGenerationTaskRecord[]; + let started: LocalProjectAssetGenerationTaskRecord; try { - records = (await deps.invoke('list_local_project_asset_generations', { - projectPath, - })) as LocalProjectAssetGenerationTaskRecord[]; + started = await (deps.refreshPlatformSession + ? deps.refreshPlatformSession(start) + : start()); } catch (error) { - // IPC 拒绝(未注册 / 权限拒绝 / 账本读坏):按「本轮读不到」处理,绝不把拒绝往上抛—— - // 派发循环是 `void (async …)()`,抛出去就是未处理的 Promise 拒绝。 - records = []; - readFailed = true; - lastReadError = errorMessage(error); + return { + taskId: task.taskId, + projectId: task.projectId, + status: 'failed', + record: null, + error: errorMessage(error), + }; } - if (!Array.isArray(records)) { - records = []; - readFailed = true; - lastReadError = RESOURCE_CANVAS_ASSET_GENERATION_MALFORMED_READ_ERROR; - } - const record = records.find((item) => item.taskId === task.taskId); - if (record) { - missingRecordPolls = 0; - readFailed = false; - replaceFromRecord(task, record); - if (isTerminalStatus(record.status)) { - return { - taskId: task.taskId, - projectId: task.projectId, - status: record.status === 'completed' ? 'completed' : 'failed', - record, - error: record.error, - }; + replaceFromRecord(task, started); + let missingRecordPolls = 0; + let readFailed = false; + let lastReadError = RESOURCE_CANVAS_ASSET_GENERATION_MALFORMED_READ_ERROR; + for (;;) { + let records: LocalProjectAssetGenerationTaskRecord[]; + try { + records = (await deps.invoke('list_local_project_asset_generations', { + projectPath, + })) as LocalProjectAssetGenerationTaskRecord[]; + } catch (error) { + // IPC 拒绝(未注册 / 权限拒绝 / 账本读坏):按「本轮读不到」处理,绝不把拒绝往上抛—— + // 派发循环是 `void (async …)()`,抛出去就是未处理的 Promise 拒绝。 + records = []; + readFailed = true; + lastReadError = errorMessage(error); } - } else { - missingRecordPolls += 1; - if ( - missingRecordPolls >= - RESOURCE_CANVAS_ASSET_GENERATION_MISSING_RECORD_POLL_LIMIT - ) { - // 「读不到通道」与「读到数组但没有这条」是两件事,文案必须能区分:前者是账本不可用, - // 后者是记录被账本上限淘汰或项目被换掉。 - return { - taskId: task.taskId, - projectId: task.projectId, - status: 'failed', - record: null, - error: readFailed - ? `生成任务状态读取失败,已停止等待:${lastReadError}` - : '生成任务账本里已找不到这条任务,已停止等待', - }; + if (!Array.isArray(records)) { + records = []; + readFailed = true; + lastReadError = RESOURCE_CANVAS_ASSET_GENERATION_MALFORMED_READ_ERROR; + } + const record = records.find((item) => item.taskId === task.taskId); + if (record) { + missingRecordPolls = 0; + readFailed = false; + replaceFromRecord(task, record); + if (isTerminalStatus(record.status)) { + return { + taskId: task.taskId, + projectId: task.projectId, + status: record.status === 'completed' ? 'completed' : 'failed', + record, + error: record.error, + }; + } + } else { + missingRecordPolls += 1; + if ( + missingRecordPolls >= + RESOURCE_CANVAS_ASSET_GENERATION_MISSING_RECORD_POLL_LIMIT + ) { + // 「读不到通道」与「读到数组但没有这条」是两件事,文案必须能区分:前者是账本不可用, + // 后者是记录被账本上限淘汰或项目被换掉。 + return { + taskId: task.taskId, + projectId: task.projectId, + status: 'failed', + record: null, + error: readFailed + ? `生成任务状态读取失败,已停止等待:${lastReadError}` + : '生成任务账本里已找不到这条任务,已停止等待', + }; + } + } + if (releaseTaskSubscription) { + await waitForTaskEvent(); + } else { + await wait(pollIntervalMillis); } } - await wait(pollIntervalMillis); + } finally { + releaseTaskSubscription?.(); } } diff --git a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx index 38d3edcea..45b9d7bab 100644 --- a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx +++ b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx @@ -26,6 +26,7 @@ import { import { appUpdateCheckEnabled } from '../../app/featureFlags'; import { resolveTauriInvoke } from '../../app/tauri'; import { + type AgcExtensionSummary, type AgcPluginPanel, type AgcPluginSummary, type ClientExtensionImportResult, @@ -38,6 +39,7 @@ import { import { checkForAppUpdate } from '../../services/appUpdate'; import { notifyLlmConfigChanged } from '../../services/llmModelCatalog'; import { + AGC_PLUGIN_STATE_CHANGED_EVENT, listAgcExtensions, reloadAgcPlugin, setAgcPluginEnabled, @@ -45,6 +47,10 @@ import { startAgcPlugin, stopAgcPlugin, } from '../../services/pluginHost'; +import { + canSubscribeTauriEvents, + subscribeTauriEvent, +} from '../../services/tauriEventSubscription'; import { useProjectCreationDirectory } from '../app-shell/useProjectCreationDirectory'; import { PluginPanelHost } from '../plugins/PluginPanelHost'; import { reasoningEffortLabel } from '../project-workspace/composerReasoningEffort'; @@ -234,7 +240,7 @@ export function RuntimeConfigDialog({ const [activeSection, setActiveSection] = useState('general'); /** - * 「项目创建目录」是客户端本地偏好(localStorage),不随下面的配置文件一起保存: + * 「项目创建目录」由 Rust 工作区偏好持有,不随下面的运行时配置一起保存: * 选择目录当场生效,做游戏 / 做方案与模板建项下一次建项就落在该目录下。 */ const projectCreationDirectory = useProjectCreationDirectory(); @@ -270,8 +276,43 @@ export function RuntimeConfigDialog({ useEffect(() => { if (activeSection !== 'extensions') return; - const timer = window.setInterval(() => void readAgcPlugins(), 1000); - return () => window.clearInterval(timer); + let disposed = false; + let unsubscribe: (() => void) | null = null; + void readAgcPlugins(); + if (!canSubscribeTauriEvents()) { + return undefined; + } + void subscribeTauriEvent( + AGC_PLUGIN_STATE_CHANGED_EVENT, + (event) => { + if (disposed) return; + setAgcPlugins( + event.payload.flatMap((extension) => + extension.kind === 'plugin' && extension.plugin + ? [extension.plugin] + : [], + ), + ); + }, + ) + .then((release) => { + if (disposed) { + release(); + } else { + unsubscribe = release; + } + }) + .catch((error) => { + if (!disposed) { + setAgcPluginsStatus( + error instanceof Error ? error.message : String(error), + ); + } + }); + return () => { + disposed = true; + unsubscribe?.(); + }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeSection]); diff --git a/apps/ai-game-creator-shell/src/features/template-library/useTemplateLibrary.ts b/apps/ai-game-creator-shell/src/features/template-library/useTemplateLibrary.ts index eadf2d2d0..fab31d47a 100644 --- a/apps/ai-game-creator-shell/src/features/template-library/useTemplateLibrary.ts +++ b/apps/ai-game-creator-shell/src/features/template-library/useTemplateLibrary.ts @@ -18,7 +18,6 @@ import { currentPlatformSessionGeneration, subscribePlatformSessionGeneration, } from '../../services/platformSession'; -import { readProjectCreationDirectory } from '../app-shell/model'; import { collectGameTemplateRuntimes, EMPTY_TEMPLATE_LIBRARY_FILTERS, @@ -268,8 +267,6 @@ export function useTemplateLibrary({ templateVersion: template.templateVersion, name: null, planning: false, - // 与首页自动建项共用一个「项目创建目录」偏好;没选时由 Rust 侧回落到默认位置。 - projectsRoot: readProjectCreationDirectory() || null, }, ); if (!isCurrent()) throw new Error('登录态已变化,模板操作已停止'); diff --git a/apps/ai-game-creator-shell/src/services/accountHost.ts b/apps/ai-game-creator-shell/src/services/accountHost.ts new file mode 100644 index 000000000..479874be9 --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/accountHost.ts @@ -0,0 +1,62 @@ +import type { + ConfirmWechatProfileRechargeOrderResponse, + CreateProfileRechargeOrderResponse, + ProfileRechargeCenterResponse, + ProfileWalletLedgerResponse, + RedeemProfileRewardCodeResponse, +} from '../../../../packages/shared/src/contracts/runtime'; +import { resolveTauriInvoke } from '../app/tauri'; +import { withPlatformSessionRefreshRetry } from './platformSessionRetry'; + +/** + * 账户与钱包 typed façade。 + * + * 平台 origin、Bearer、响应 envelope 解析与错误分类都在 Rust;渲染层只提交结构化业务 + * 参数。access token 过期时 Rust 返回稳定的 `authentication-required`,这里复用既有 + * 会话续期链路重试一次;付费订单的未知结果不自动重放。 + */ +function invokeAccount( + command: string, + args?: Record, +): Promise { + return withPlatformSessionRefreshRetry(() => { + const invoke = resolveTauriInvoke(); + if (!invoke) { + throw new Error('需要在 Tauri App 内读取账户信息'); + } + return invoke(command, args); + }); +} + +export function getClientProfileRechargeCenter() { + return invokeAccount( + 'read_profile_recharge_center', + ); +} + +export function getClientProfileWalletLedger() { + return invokeAccount( + 'read_profile_wallet_ledger', + ); +} + +export function createClientProfileRechargeOrder(productId: string) { + return invokeAccount( + 'create_profile_recharge_order', + { productId }, + ); +} + +export function confirmClientWechatProfileRechargeOrder(orderId: string) { + return invokeAccount( + 'confirm_wechat_profile_recharge_order', + { orderId }, + ); +} + +export function redeemClientProfileRewardCode(code: string) { + return invokeAccount( + 'redeem_profile_reward_code', + { code }, + ); +} diff --git a/apps/ai-game-creator-shell/src/services/agentRuntimeErrorDetail.ts b/apps/ai-game-creator-shell/src/services/agentRuntimeErrorDetail.ts new file mode 100644 index 000000000..3f0fdf134 --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/agentRuntimeErrorDetail.ts @@ -0,0 +1,56 @@ +import type { TauriInvoke } from '../app/types'; + +/** + * 统一错误诊断引用在 DirectProject 失败文案里的标记。 + * + * Rust 的失败投影以「;详情:.agent/runtime/errors/.json」收尾,渲染层只按这个 + * 字面量把 `detailRef` 取回来,再走只读命令读有界、脱敏的诊断;错误分类与脱敏都在 Rust + * 侧,不在 React 侧重判。 + * + * 这个后缀是**跨层协议**:改文案或改标记时必须同时改这里的解析和两侧测试 + * (Rust:`direct_codex_failure_text_keeps_the_detail_ref_marker_for_the_renderer`; + * 本文件:`agentRuntimeErrorDetail.test.ts`)。 + */ +export const AGENT_RUNTIME_ERROR_DETAIL_MARKER = '详情:'; + +const AGENT_RUNTIME_ERROR_DETAIL_REF_PATTERN = + /详情:(\.agent\/runtime\/errors\/[^\s;]+)/; + +/** + * 从失败文案里取出诊断引用。 + * + * 只做形状判别(目录前缀 + `.json` 后缀):引用的目录归属、穿越和大小上限由原生命令 + * `read_agent_runtime_error_detail` 校验,这里不放宽也不替代那道门。 + */ +export function extractAgentRuntimeErrorDetailRef( + message: string, +): string | null { + const matched = message.match(AGENT_RUNTIME_ERROR_DETAIL_REF_PATTERN)?.[1]; + if (!matched || !matched.endsWith('.json')) return null; + return matched; +} + +/** + * 按已知引用读取诊断正文。 + * + * 面板把引用与展示文案分开持有(文案里不再拼引用),因此按需读取走这一入口;引用是否在 + * 项目错误目录内、是否是单个 `.json` 文件名仍由原生命令校验。 + * + * 读不到(旧项目无 sidecar、权限拒绝、命令缺失)时返回空串:诊断是附加信息,缺了只保留 + * 安全 `publicText`,不能把读取失败升级成新的用户可见错误。 + */ +export async function readAgentRuntimeErrorDetailByRef( + invoke: TauriInvoke, + projectPath: string, + detailRef: string, +): Promise { + if (!detailRef.trim()) return ''; + try { + return await invoke('read_agent_runtime_error_detail', { + projectPath, + detailRef, + }); + } catch { + return ''; + } +} diff --git a/apps/ai-game-creator-shell/src/services/assetDirectUpload.ts b/apps/ai-game-creator-shell/src/services/assetDirectUpload.ts index d0b00b106..0c6cea5d1 100644 --- a/apps/ai-game-creator-shell/src/services/assetDirectUpload.ts +++ b/apps/ai-game-creator-shell/src/services/assetDirectUpload.ts @@ -1,91 +1,43 @@ -import { fetch as tauriHttpFetch } from '@tauri-apps/plugin-http'; - -import { requestClientApi } from './clientApi'; - -/** 直传凭证里的对象存储目标;字段口径与平台 `/api/assets/direct-upload-tickets` 一致。 */ -type DirectUploadTicketResponse = { - upload: { - bucket: string; - host: string; - objectKey: string; - legacyPublicPath: string; - formFields: Record; - }; -}; - -type ConfirmAssetObjectResponse = { - assetObject: { - assetObjectId: string; - objectKey: string; - assetKind: string; - }; -}; +import { resolveTauriInvoke } from '../app/tauri'; +import type { TauriInvoke } from '../app/types'; export type UploadedPlatformAsset = { assetObjectId: string; objectKey: string; }; -type FetchLike = (input: string, init?: RequestInit) => Promise; - -/** - * 平台直传凭证支持的 OSS 主机白名单前缀。 - * - * Tauri 的 http 插件只允许访问 `capabilities/main.json` 里声明的地址,这里再做一次校验, - * 保证即使能力配置放宽到 `*.aliyuncs.com`,客户端也只把文件发到平台素材存储,而不是任意主机。 - */ -const PLATFORM_UPLOAD_HOST_SUFFIXES = ['.aliyuncs.com']; - -function isLocalUploadHost(parsed: URL) { - return ( - parsed.protocol === 'http:' && - (parsed.hostname === '127.0.0.1' || parsed.hostname === 'localhost') - ); -} - -/** 校验直传地址;不是平台素材存储时直接失败关闭,避免把本地文件发给第三方主机。 */ -export function resolvePlatformAssetUploadUrl(host: string) { - const trimmedHost = host.trim(); - if (!trimmedHost) { - throw new Error('素材上传地址为空,请稍后重试'); - } - let parsed: URL; - try { - parsed = new URL(trimmedHost); - } catch { - throw new Error('素材上传地址无效,请稍后重试'); - } - const isOssHost = - parsed.protocol === 'https:' && - PLATFORM_UPLOAD_HOST_SUFFIXES.some((suffix) => - parsed.hostname.endsWith(suffix), - ); - if (!isOssHost && !isLocalUploadHost(parsed)) { - throw new Error('素材上传地址不属于平台素材存储,已终止上传'); - } - return parsed.toString(); -} - -function buildDirectUploadFormData( - upload: DirectUploadTicketResponse['upload'], - file: File, -) { - const formData = new FormData(); - Object.entries(upload.formFields ?? {}).forEach(([key, value]) => { - if (value !== null && value !== undefined) { - formData.append(key, value); +async function readFileBytes(file: File): Promise { + const arrayBuffer = ( + file as File & { + arrayBuffer?: () => Promise; } + ).arrayBuffer; + if (typeof arrayBuffer === 'function') { + return Array.from(new Uint8Array(await arrayBuffer.call(file))); + } + if (typeof FileReader === 'undefined') { + throw new Error('无法读取待上传素材'); + } + return await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + if (!(reader.result instanceof ArrayBuffer)) { + reject(new Error('无法读取待上传素材')); + return; + } + resolve(Array.from(new Uint8Array(reader.result))); + }; + reader.onerror = () => + reject(reader.error ?? new Error('无法读取待上传素材')); + reader.readAsArrayBuffer(file); }); - // OSS 要求 file 字段位于表单末尾,否则签名校验会失败。 - formData.append('file', file, file.name); - return formData; } /** - * 把本地文件上传成平台素材对象并返回素材标识。 + * 把用户选择的本地素材交给 Rust typed command。 * - * 三步与网页端一致:申请直传凭证 → 直传对象存储 → confirm 登记素材。AGC 无法在浏览器里 - * 直接跨域直传 OSS,所以直传这一步固定走 Tauri HTTP 插件(Rust 侧发起请求)。 + * File/ArrayBuffer 只代表当前用户动作的临时输入;凭证申请、对象存储直传、confirm、 + * origin 与登录态都由 Rust 持有,React 不再访问平台 API、读取 token 或拼接上传地址。 */ export async function uploadPlatformMediaAsset(args: { file: File; @@ -93,75 +45,36 @@ export async function uploadPlatformMediaAsset(args: { pathSegments: string[]; entityId: string; metadata?: Record; - /** 单测注入的直传实现;正式运行时固定使用 Tauri HTTP。 */ - fetchImpl?: FetchLike; + invoke?: TauriInvoke; }): Promise { + const invoke = args.invoke ?? resolveTauriInvoke(); + if (!invoke) { + throw new Error('需要在 Tauri App 内上传平台素材'); + } const fileName = args.file.name.trim() || 'cover.png'; const contentType = args.file.type.trim() || 'application/octet-stream'; - const ticket = await requestClientApi( - '/api/assets/direct-upload-tickets', + const bytes = await readFileBytes(args.file); + const confirmed = await invoke( + 'upload_platform_media_asset', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - legacyPrefix: 'generated-character-drafts', - pathSegments: args.pathSegments, + input: { fileName, contentType, - access: 'private', - maxSizeBytes: args.file.size, - metadata: { - asset_kind: args.assetKind, - ...args.metadata, - }, - }), - }, - '创建素材上传凭证失败', - ); - - const uploadHost = resolvePlatformAssetUploadUrl(ticket.upload.host); - const uploadFetch = args.fetchImpl ?? tauriHttpFetch; - let uploadResponse: Response; - try { - uploadResponse = await uploadFetch(uploadHost, { - method: 'POST', - body: buildDirectUploadFormData(ticket.upload, args.file), - }); - } catch (error) { - // Tauri http 插件在目标不在能力作用域、网络不可达或请求被取消时直接抛错; - // 统一转成可操作文案,避免把英文插件错误原样暴露给作者。 - const detail = error instanceof Error ? error.message.trim() : ''; - throw new Error( - `上传素材失败:无法访问素材存储,请检查网络后重试${ - detail ? `(${detail.slice(0, 120)})` : '' - }`, - ); - } - if (!uploadResponse.ok) { - throw new Error( - `上传素材到对象存储失败(HTTP ${uploadResponse.status}),请重试`, - ); - } - - const confirmed = await requestClientApi( - '/api/assets/objects/confirm', - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - bucket: ticket.upload.bucket, - objectKey: ticket.upload.objectKey, - contentType, - contentLength: args.file.size, assetKind: args.assetKind, - accessPolicy: 'private', + pathSegments: args.pathSegments, entityId: args.entityId, - }), + bytes, + metadata: args.metadata ?? {}, + }, }, - '确认素材资产失败', ); + const assetObjectId = String(confirmed?.assetObjectId ?? '').trim(); + const objectKey = String(confirmed?.objectKey ?? '').trim(); + if (!assetObjectId || !objectKey) { + throw new Error('平台素材上传响应缺少素材标识'); + } return { - assetObjectId: confirmed.assetObject.assetObjectId, - objectKey: confirmed.assetObject.objectKey, + assetObjectId, + objectKey, }; } diff --git a/apps/ai-game-creator-shell/src/services/clientApi.ts b/apps/ai-game-creator-shell/src/services/clientApi.ts deleted file mode 100644 index 9c9416b6b..000000000 --- a/apps/ai-game-creator-shell/src/services/clientApi.ts +++ /dev/null @@ -1,306 +0,0 @@ -import { - API_RESPONSE_ENVELOPE_HEADER, - API_RESPONSE_ENVELOPE_VERSION, - ConfirmWechatProfileRechargeOrderResponse, - CreateProfileRechargeOrderResponse, - ProfileDashboardSummary, - ProfileRechargeCenterResponse, - ProfileWalletLedgerResponse, - RedeemProfileRewardCodeResponse, - unwrapApiResponse, -} from '../../../../packages/shared/src'; -import { getStoredAuthAccessToken } from './clientAuth'; -import { fetchClientHttp, readClientHttpResponseText } from './clientHttp'; -import { captureClientError } from './errorReporting'; -import { - currentPlatformSessionGeneration, - requestPlatformSessionRefresh, -} from './platformSession'; - -export { - clearStoredAuthAccessToken, - getStoredAuthAccessToken, - setStoredAuthAccessToken, -} from './clientAuth'; - -export class ClientAuthRequestError extends Error { - readonly status: number | null; - readonly networkError: boolean; - - constructor( - message: string, - options: { status?: number | null; networkError?: boolean } = {}, - ) { - super(message); - this.status = options.status ?? null; - this.networkError = options.networkError ?? false; - } -} - -async function readApiErrorMessage( - response: Response, - fallback: string, - url: string, -) { - const text = await readClientHttpResponseText(response, { url }); - if (!text.trim()) { - return fallback; - } - try { - unwrapApiResponse(JSON.parse(text) as unknown); - } catch (error) { - return error instanceof Error ? error.message : fallback; - } - return fallback; -} - -function captureApiErrorStatus(url: string, response: Response) { - if (response.status < 500 && response.status !== 408) return; - void captureClientError( - new ClientAuthRequestError(`HTTP ${response.status}`, { - status: response.status, - }), - { source: 'api', action: url.split('?')[0] }, - ); -} - -function apiNetworkError(url: string, error: unknown) { - void captureClientError(error, { - source: 'api', - action: url.split('?')[0], - }); - return new ClientAuthRequestError( - '无法连接登录服务,请确认配套后端或 API 代理已启动后重试', - { networkError: true }, - ); -} - -export async function requestClientApi( - url: string, - init: RequestInit, - fallbackMessage: string, - options: { skipAuth?: boolean } = {}, -) { - const generation = currentPlatformSessionGeneration(); - const request = async () => { - const headers = new Headers(init.headers); - headers.set(API_RESPONSE_ENVELOPE_HEADER, API_RESPONSE_ENVELOPE_VERSION); - if (!options.skipAuth) { - const token = getStoredAuthAccessToken(); - if (token) { - headers.set('Authorization', `Bearer ${token}`); - } - } - try { - return await fetchClientHttp(url, { - ...init, - credentials: 'same-origin', - headers, - }); - } catch (error) { - throw apiNetworkError(url, error); - } - }; - - let response = await request(); - // Access tokens are short lived. Refresh the cookie-backed session once and - // retry the original request so callers do not need to handle token expiry. - if (!options.skipAuth && response.status === 401) { - if (currentPlatformSessionGeneration() === generation) { - const refresh = await requestPlatformSessionRefresh(); - if ( - refresh.status === 'refreshed' && - currentPlatformSessionGeneration() === refresh.generation - ) { - response = await request(); - } - } - } - if (!response.ok) { - captureApiErrorStatus(url, response); - throw new ClientAuthRequestError( - await readApiErrorMessage(response, fallbackMessage, url), - { status: response.status }, - ); - } - const text = await readClientHttpResponseText(response, { url }); - return text ? unwrapApiResponse(JSON.parse(text) as T) : (null as T); -} - -/** - * Authenticated binary request for internal APIs (for example asset bytes). - * This intentionally shares the same token and URL resolution as requestClientApi - * instead of using the external editor API key flow. - */ -export async function requestClientApiBytes( - url: string, - fallbackMessage: string, - init: RequestInit = {}, -) { - const headers = new Headers(init.headers); - headers.set(API_RESPONSE_ENVELOPE_HEADER, API_RESPONSE_ENVELOPE_VERSION); - const token = getStoredAuthAccessToken(); - if (token) { - headers.set('Authorization', `Bearer ${token}`); - } - let response: Response; - try { - response = await fetchClientHttp(url, { - ...init, - credentials: 'same-origin', - headers, - }); - } catch (error) { - throw apiNetworkError(url, error); - } - if (!response.ok) { - captureApiErrorStatus(url, response); - throw new ClientAuthRequestError( - await readApiErrorMessage(response, fallbackMessage, url), - { status: response.status }, - ); - } - return response; -} - -export type ClientAssetReadUrlResponse = { - read?: { signedUrl?: string; objectKey?: string; expiresAt?: string }; - signedUrl?: string; - objectKey?: string; - expiresAt?: string; -}; - -export type ClientEditorAssetLibrary = { - folders: Array<{ - folderId: string; - label: string; - sortOrder?: number; - collapsed?: boolean; - systemDefault?: boolean; - }>; - assets: Array<{ - assetId: string; - folderId: string; - label: string; - imageSrc?: string; - thumbnailSrc?: string | null; - objectKey?: string | null; - assetObjectId?: string | null; - width?: number; - height?: number; - assetKind?: string | null; - [key: string]: unknown; - }>; -}; - -export type ClientLlmModel = { - displayName: string; - id: string; -}; - -export type ClientLlmModelCatalog = { - defaultModelId: string; - models: ClientLlmModel[]; - revision: number; -}; - -export function loadClientLlmModels() { - return requestClientApi( - '/api/llm/models', - { method: 'GET' }, - '读取可用模型失败', - ); -} - -export function loadEditorAssetLibrary() { - return requestClientApi<{ library: ClientEditorAssetLibrary }>( - '/api/editor/assets/library', - { method: 'GET' }, - '读取平台素材库失败', - ).then((response) => response.library); -} - -function buildClientAssetObjectKeyQuery(objectKey: string) { - return new URLSearchParams({ objectKey: objectKey.replace(/^\/+/, '') }); -} - -export async function resolveClientAssetReadUrl(objectKey: string) { - const params = buildClientAssetObjectKeyQuery(objectKey); - const payload = await requestClientApi( - `/api/assets/read-url?${params.toString()}`, - { method: 'GET' }, - '读取平台素材预览地址失败', - ); - const signedUrl = payload?.read?.signedUrl ?? payload?.signedUrl; - if (!signedUrl?.trim()) { - throw new Error('平台素材预览地址缺失'); - } - return signedUrl; -} - -export function readClientAssetBytes(objectKey: string) { - const params = buildClientAssetObjectKeyQuery(objectKey); - return requestClientApiBytes( - `/api/assets/read-bytes?${params.toString()}`, - '读取平台素材内容失败', - ); -} - -export function getClientProfileDashboard() { - return requestClientApi( - '/api/profile/dashboard', - { method: 'GET' }, - '读取泥点余额失败', - ); -} - -export function getClientProfileRechargeCenter(signal?: AbortSignal) { - return requestClientApi( - '/api/profile/recharge-center', - { method: 'GET', signal }, - '读取泥点明细失败', - ); -} - -export function createClientProfileRechargeOrder(productId: string) { - return requestClientApi( - '/api/profile/recharge/orders', - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - productId, - paymentChannel: 'wechat_native', - }), - }, - '充值失败', - ); -} - -export function confirmClientWechatProfileRechargeOrder(orderId: string) { - return requestClientApi( - `/api/profile/recharge/orders/${encodeURIComponent(orderId)}/wechat/confirm`, - { method: 'POST' }, - '确认微信支付订单失败', - ); -} - -export function getClientProfileWalletLedger() { - return requestClientApi( - '/api/profile/wallet-ledger', - { method: 'GET' }, - '读取泥点账单失败', - ); -} - -export function redeemClientProfileRewardCode(code: string) { - return requestClientApi( - '/api/profile/redeem-codes/redeem', - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ code }), - }, - '兑换失败', - ); -} diff --git a/apps/ai-game-creator-shell/src/services/clientAuth.ts b/apps/ai-game-creator-shell/src/services/clientAuth.ts index 13441c5ef..acbe37ed3 100644 --- a/apps/ai-game-creator-shell/src/services/clientAuth.ts +++ b/apps/ai-game-creator-shell/src/services/clientAuth.ts @@ -1,38 +1,25 @@ -import type { - AuthEntryRequest, - AuthEntryResponse, - AuthMeResponse, - AuthPhoneLoginRequest, - AuthPhoneLoginResponse, - AuthPhoneNumberInput, - AuthPhoneSendCodeRequest, - AuthPhoneSendCodeResponse, - AuthRefreshResponse, - LogoutResponse, -} from '../../../../packages/shared/src/contracts/auth'; -import { - API_RESPONSE_ENVELOPE_HEADER, - API_RESPONSE_ENVELOPE_VERSION, - isApiResponse, - parseApiErrorMessage, - unwrapApiResponse, -} from '../../../../packages/shared/src/http'; -import { - fetchClientHttp, - getClientServerBaseUrl, - readClientHttpResponseText, -} from './clientHttp'; -import { - type ClientOperation, - createClientOperation, - transitionClientOperation, -} from './clientOperation'; +import type { AuthUser } from '../../../../packages/shared/src/contracts/auth'; +import { resolveTauriInvoke } from '../app/tauri'; +import { subscribeTauriEvent } from './tauriEventSubscription'; -const ACCESS_TOKEN_STORAGE_KEY = 'genarrative.auth.access-token.v1'; -const ACCESS_TOKEN_ORIGIN_STORAGE_KEY = - 'genarrative.auth.access-token-origin.v1'; -const LEGACY_SERVER_SELECTION_STORAGE_KEY = - 'genarrative.client.server-selection.v1'; +/** Rust 认证态事件:只承载状态投影,不含 token 或 refresh 凭据。 */ +export const CLIENT_AUTH_STATE_CHANGED_EVENT = 'agc-client-auth-state-changed'; + +export type ClientAuthState = + | { status: 'authenticated'; user: AuthUser; apiBaseUrl: string } + | { status: 'unauthenticated' } + | { status: 'unavailable'; message: string }; + +export type ClientAuthRefreshResult = + | { status: 'refreshed'; user: AuthUser } + | { status: 'unauthenticated' } + | { status: 'stale' } + | { status: 'failed'; message: string; authoritative: boolean }; + +export type ClientLoginCodeResult = { + cooldownSeconds: number; + expiresInSeconds: number; +}; export function normalizeAuthPhoneInput(phone: string) { const compactPhone = phone.replace(/[^\d+]/gu, '').trim(); @@ -41,383 +28,159 @@ export function normalizeAuthPhoneInput(phone: string) { return mainlandChinaInternationalPhone?.[1] ?? compactPhone; } -function buildClientAuthPhoneInput(phone: string): AuthPhoneNumberInput { - return { - countryCode: '86', - purePhoneNumber: normalizeAuthPhoneInput(phone), - }; -} - -export function getStoredAuthAccessToken( - apiBaseUrl = getClientServerBaseUrl(), -) { - const token = - window.localStorage.getItem(ACCESS_TOKEN_STORAGE_KEY)?.trim() || ''; - if (!token) return ''; - const storedOrigin = window.localStorage.getItem( - ACCESS_TOKEN_ORIGIN_STORAGE_KEY, - ); - if (storedOrigin === apiBaseUrl) return token; - // Old preferences were editable independently of the token, so they cannot - // establish an unmarked session's origin. A marked token from another server - // is discarded, while the user's current debug server preference remains. - clearStoredAuthAccessToken(); - if (!storedOrigin) { - window.localStorage.removeItem(LEGACY_SERVER_SELECTION_STORAGE_KEY); +function requireInvoke() { + const invoke = resolveTauriInvoke(); + if (!invoke) { + throw new Error('需要在 Tauri App 内登录'); } - return ''; -} - -export function setStoredAuthAccessToken( - token: string, - apiBaseUrl = getClientServerBaseUrl(), -) { - const nextToken = token.trim(); - if (nextToken) { - window.localStorage.setItem(ACCESS_TOKEN_STORAGE_KEY, nextToken); - window.localStorage.setItem(ACCESS_TOKEN_ORIGIN_STORAGE_KEY, apiBaseUrl); - return; - } - clearStoredAuthAccessToken(); -} - -export function clearStoredAuthAccessToken() { - window.localStorage.removeItem(ACCESS_TOKEN_STORAGE_KEY); - window.localStorage.removeItem(ACCESS_TOKEN_ORIGIN_STORAGE_KEY); -} - -const clientAuthRefreshPromises = new Map>(); -const clientAuthRefreshOperations = new Map< - string, - ClientOperation<'auth-refresh', { apiBaseUrl: string }> ->(); - -export function getClientAuthRefreshOperation(apiBaseUrl: string) { - return clientAuthRefreshOperations.get(apiBaseUrl) ?? null; -} - -const CLIENT_AUTH_NETWORK_ERROR_MESSAGE = - '无法连接登录服务,请确认配套后端或 API 代理已启动后重试'; - -function getClientAuthHttpErrorMessage(status: number, fallback: string) { - switch (status) { - case 408: - case 504: - return '登录服务响应超时,请检查服务器地址和网络后重试'; - case 429: - return '登录请求过于频繁,请稍后重试'; - case 500: - return '登录服务内部错误(HTTP 500),请稍后重试'; - case 502: - return '登录服务暂不可用:上游服务请求失败,请稍后重试'; - case 503: - return '登录服务暂不可用(HTTP 503),服务器可能正在维护,请稍后重试'; - default: - return fallback; - } -} - -function getClientAuthNetworkErrorMessage(error: unknown) { - const detail = - error instanceof Error ? error.message.trim() : String(error).trim(); - if (/timed? ?out|timeout|超时/iu.test(detail)) { - return '无法连接登录服务:连接超时,请检查服务器地址和网络后重试'; - } - if (/econnrefused|connection refused|拒绝连接/iu.test(detail)) { - return '无法连接登录服务:服务器拒绝连接,请确认服务已启动并检查端口'; - } - if (/dns|resolve|name or service not known|无法解析/iu.test(detail)) { - return '无法连接登录服务:服务器地址无法解析,请检查网络后重试'; - } - if (/certificate|tls|ssl|证书/iu.test(detail)) { - return '无法连接登录服务:安全连接失败,请检查服务器地址和证书'; - } - return CLIENT_AUTH_NETWORK_ERROR_MESSAGE; -} - -class ClientAuthRequestError extends Error { - readonly status: number | null; - readonly networkError: boolean; - - constructor( - message: string, - options: { status?: number | null; networkError?: boolean } = {}, - ) { - super(message); - this.name = 'ClientAuthRequestError'; - this.status = options.status ?? null; - this.networkError = options.networkError ?? false; - } -} - -/** - * 服务端明确否认当前身份(401/403)才算权威失效。 - * - * 网络错误、5xx、网关错误和响应契约异常都属于"刷新暂时不可用":调用方必须保留既有 - * 会话与 access token,不能把一次瞬时失败放大成登出。 - */ -export function isClientAuthAuthorityFailure(error: unknown) { - return ( - error instanceof ClientAuthRequestError && - (error.status === 401 || error.status === 403) - ); -} - -export function isClientAuthRecoverableCheckError(error: unknown) { - return !isClientAuthAuthorityFailure(error); + return invoke; } export function getClientAuthErrorMessage(error: unknown, fallback: string) { - return error instanceof Error ? error.message : fallback; + if (error instanceof Error && error.message.trim()) return error.message; + const message = String(error ?? '').trim(); + return message || fallback; } +type RustAuthStateView = { + status?: string; + user?: AuthUser | null; + apiBaseUrl?: string | null; + errorKind?: string | null; + errorMessage?: string | null; +}; + /** - * 旧形态错误体:未带 `x-genarrative-response-envelope` 时后端返回 - * `{ error: { code, message }, meta }`,没有 `ok` 字段,但 message 同样是给用户看的原因。 + * 恢复登录态。 + * + * 凭据续期、当前用户复核与本机运行时会话安装都在 Rust 内完成;这里只把结果投影成 + * `authenticated` / `unauthenticated` / `unavailable` 三态,供登录页决定展示分支。 */ -function isLegacyApiErrorBody(value: unknown) { - if (typeof value !== 'object' || value === null || Array.isArray(value)) { - return false; - } - const record = value as Record; - return 'error' in record || 'message' in record || 'code' in record; -} - -async function readAuthErrorMessage(response: Response, fallback: string) { - const httpFallback = getClientAuthHttpErrorMessage(response.status, fallback); - const text = await readClientHttpResponseText(response, { - url: 'auth error response', +export async function readClientAuthState( + expectedApiBaseUrl?: string, +): Promise { + const invoke = requireInvoke(); + const view = await invoke('read_client_auth_state', { + expectedApiBaseUrl: expectedApiBaseUrl?.trim() || null, }); - if (!text.trim()) { - return httpFallback; + if (view?.status === 'authenticated' && view.user && view.apiBaseUrl) { + return { + status: 'authenticated', + user: view.user, + apiBaseUrl: view.apiBaseUrl, + }; } - let parsed: unknown; - try { - parsed = JSON.parse(text) as unknown; - } catch { - // 非 JSON(代理错误页、纯文本)不把内部英文原样抛给用户。 - return httpFallback; + if (view?.status === 'unavailable') { + return { + status: 'unavailable', + message: view.errorMessage?.trim() || '登录服务暂时不可用,请稍后重试', + }; } - if (isApiResponse(parsed)) { - try { - unwrapApiResponse(parsed); - } catch (error) { - const message = error instanceof Error ? error.message.trim() : ''; - return message && message !== '请求失败' ? message : httpFallback; - } - return httpFallback; - } - if (!isLegacyApiErrorBody(parsed)) { - return httpFallback; - } - // 旧形态错误体仍按共享契约解析,否则“手机号或密码错误”这类明确原因会退化成固定文案。 - const legacyMessage = parseApiErrorMessage(text, httpFallback).trim(); - // 共享解析器在认不出结构时会回显原始 JSON,这里不允许把它当成用户可见文案。 - return legacyMessage && legacyMessage !== text.trim() - ? legacyMessage - : httpFallback; + return { status: 'unauthenticated' }; } -async function requestAuthJson( - url: string, - init: RequestInit, - fallbackMessage: string, - options: { skipAuth?: boolean; apiBaseUrl?: string } = {}, -) { - const headers = new Headers(init.headers); - headers.set(API_RESPONSE_ENVELOPE_HEADER, API_RESPONSE_ENVELOPE_VERSION); - if (!options.skipAuth) { - const token = getStoredAuthAccessToken(options.apiBaseUrl); - if (token) { - headers.set('Authorization', `Bearer ${token}`); - } - } - let response: Response; - try { - response = await fetchClientHttp( - url, - { - ...init, - credentials: 'same-origin', - headers, - }, - { serverBaseUrl: options.apiBaseUrl }, - ); - } catch (error) { - throw new ClientAuthRequestError(getClientAuthNetworkErrorMessage(error), { - networkError: true, - }); - } - if (!response.ok) { - throw new ClientAuthRequestError( - await readAuthErrorMessage(response, fallbackMessage), - { status: response.status }, - ); - } - const text = await readClientHttpResponseText(response, { - url, - }); - return text ? unwrapApiResponse(JSON.parse(text) as T) : (null as T); -} - -export async function getCurrentClientAuthUser( - apiBaseUrl = getClientServerBaseUrl(), -) { - const response = await requestAuthJson( - '/api/auth/me', - { method: 'GET' }, - '读取当前用户失败', - { apiBaseUrl }, - ); - return response.user; -} - -export async function refreshClientAuthAccessToken( - apiBaseUrl = getClientServerBaseUrl(), -) { - const current = clientAuthRefreshPromises.get(apiBaseUrl); - if (current) return current; - const operation = createClientOperation( - 'auth-refresh', - { apiBaseUrl }, - { scope: { apiBaseUrl }, deadlineMs: 15_000 }, - ); - clientAuthRefreshOperations.set( +export async function sendClientPhoneLoginCode( + phone: string, + apiBaseUrl: string, +): Promise { + const invoke = requireInvoke(); + const result = await invoke<{ + cooldownSeconds?: number; + expiresInSeconds?: number; + }>('send_client_phone_login_code', { apiBaseUrl, - transitionClientOperation(operation, 'network'), - ); - const performRefresh = () => - requestAuthJson( - '/api/auth/refresh', - { method: 'POST' }, - '刷新登录状态失败', - { skipAuth: true, apiBaseUrl }, - ); - const refreshWithConvergenceRetry = async () => { - try { - return await performRefresh(); - } catch (error) { - if (!isClientAuthAuthorityFailure(error)) throw error; - // 并发轮换收敛:另一个窗口 / 实例可能刚刚轮换过 refresh cookie,用当前 cookie - // 再试一次。重试成功则继续使用新凭据;重试仍被明确拒绝才算登录态权威失效。 - return await performRefresh(); - } + phone: normalizeAuthPhoneInput(phone), + }); + return { + cooldownSeconds: Number(result?.cooldownSeconds ?? 0), + expiresInSeconds: Number(result?.expiresInSeconds ?? 0), }; - const refreshPromise = refreshWithConvergenceRetry() - .then((response) => { - clientAuthRefreshOperations.set( - apiBaseUrl, - transitionClientOperation(operation, 'success'), - ); - setStoredAuthAccessToken(response.token, apiBaseUrl); - return response.token; - }) - .catch((error) => { - clientAuthRefreshOperations.set( - apiBaseUrl, - transitionClientOperation(operation, 'retryable-failure'), - ); - throw error; - }) - .finally(() => { - if (clientAuthRefreshPromises.get(apiBaseUrl) === refreshPromise) { - clientAuthRefreshPromises.delete(apiBaseUrl); - } - }); - clientAuthRefreshPromises.set(apiBaseUrl, refreshPromise); - return refreshPromise; } export async function loginClientWithPassword( phone: string, password: string, - apiBaseUrl = getClientServerBaseUrl(), -) { - const request: AuthEntryRequest = { - ...buildClientAuthPhoneInput(phone), + apiBaseUrl: string, +): Promise { + const invoke = requireInvoke(); + return invoke('login_client_with_password', { + apiBaseUrl, + phone: normalizeAuthPhoneInput(phone), password: password.trim(), - }; - const response = await requestAuthJson( - '/api/auth/entry', - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(request), - }, - '登录失败', - { skipAuth: true, apiBaseUrl }, - ); - setStoredAuthAccessToken(response.token, apiBaseUrl); - return response.user; -} - -export async function sendClientPhoneLoginCode( - phone: string, - apiBaseUrl = getClientServerBaseUrl(), -) { - const request: AuthPhoneSendCodeRequest = { - ...buildClientAuthPhoneInput(phone), - scene: 'login', - }; - return requestAuthJson( - '/api/auth/phone/send-code', - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(request), - }, - '发送验证码失败', - { skipAuth: true, apiBaseUrl }, - ); + }); } export async function loginClientWithPhoneCode( phone: string, code: string, - apiBaseUrl = getClientServerBaseUrl(), -) { - const request: AuthPhoneLoginRequest = { - ...buildClientAuthPhoneInput(phone), + apiBaseUrl: string, +): Promise { + const invoke = requireInvoke(); + return invoke('login_client_with_phone_code', { + apiBaseUrl, + phone: normalizeAuthPhoneInput(phone), code: code.trim(), - }; - const response = await requestAuthJson( - '/api/auth/phone/login', - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(request), - }, - '登录失败', - { skipAuth: true, apiBaseUrl }, - ); - setStoredAuthAccessToken(response.token, apiBaseUrl); - return response.user; + }); } -export async function logoutClientAuthSession( - apiBaseUrl = getClientServerBaseUrl(), -) { - try { - if (!getStoredAuthAccessToken(apiBaseUrl)) { - await refreshClientAuthAccessToken(apiBaseUrl).catch(() => ''); - } - try { - await requestAuthJson( - '/api/auth/logout', - { method: 'POST' }, - '退出登录失败', - { apiBaseUrl }, - ); - } catch { - await refreshClientAuthAccessToken(apiBaseUrl).catch(() => ''); - await requestAuthJson( - '/api/auth/logout', - { method: 'POST' }, - '退出登录失败', - { apiBaseUrl }, - ); - } - } finally { - clearStoredAuthAccessToken(); +/** 登出:Rust 负责服务端撤销、凭据清除与本机运行时会话清理。 */ +export async function logoutClientAuthSession(): Promise { + const invoke = requireInvoke(); + await invoke('logout_client_session'); +} + +export async function refreshClientAuthSession( + expectedUserId?: string, +): Promise { + const invoke = requireInvoke(); + const view = await invoke<{ + status?: string; + user?: AuthUser | null; + authoritative?: boolean; + errorMessage?: string | null; + }>('refresh_client_auth_session', { + expectedUserId: expectedUserId?.trim() || null, + }); + switch (view?.status) { + case 'refreshed': + if (!view.user) { + return { + status: 'failed', + message: '刷新登录状态失败', + authoritative: false, + }; + } + return { status: 'refreshed', user: view.user }; + case 'unauthenticated': + return { status: 'unauthenticated' }; + case 'stale': + return { status: 'stale' }; + default: + return { + status: 'failed', + message: view?.errorMessage?.trim() || '刷新登录状态失败', + authoritative: view?.authoritative === true, + }; } } + +/** 订阅 Rust 认证态事件,返回幂等释放函数。 */ +export function subscribeClientAuthState( + listener: (state: ClientAuthState) => void, +): Promise<() => void> { + return subscribeTauriEvent( + CLIENT_AUTH_STATE_CHANGED_EVENT, + (event) => { + const view = event.payload; + if (view?.status === 'authenticated' && view.user && view.apiBaseUrl) { + listener({ + status: 'authenticated', + user: view.user, + apiBaseUrl: view.apiBaseUrl, + }); + return; + } + listener({ status: 'unauthenticated' }); + }, + ).catch(() => () => {}); +} diff --git a/apps/ai-game-creator-shell/src/services/clientHttp.ts b/apps/ai-game-creator-shell/src/services/clientHttp.ts index c0040edf6..96fc46d0d 100644 --- a/apps/ai-game-creator-shell/src/services/clientHttp.ts +++ b/apps/ai-game-creator-shell/src/services/clientHttp.ts @@ -1,9 +1,7 @@ -import { fetch as tauriHttpFetch } from '@tauri-apps/plugin-http'; +import { resolveTauriInvoke } from '../app/tauri'; export const AGC_DEVELOPMENT_API_BASE_URL = 'https://dev.genarrative.world'; export const AGC_RELEASE_API_BASE_URL = 'https://www.genarrative.world'; -export const AGC_CLIENT_MARKER_HEADER = 'X-Genarrative-Client'; -export const AGC_CLIENT_MARKER_VALUE = 'agc'; export type ClientServerPreset = 'release' | 'dev' | 'custom'; @@ -12,8 +10,6 @@ export type ClientServerSelection = { customBaseUrl: string; }; -const CLIENT_SERVER_SELECTION_STORAGE_KEY = - 'genarrative.client.server-selection.v1'; const CLIENT_PLATFORM_CHANNEL = import.meta.env.VITE_AGC_PLATFORM_CHANNEL?.trim() === 'release' ? 'release' @@ -26,96 +22,24 @@ export function isClientServerSelectionEnabled() { const value = String(import.meta.env.DEV); return value === 'true' || value === '1'; } -/** - * Upper bound for the initial network transaction (DNS/connect/response - * headers). Callers may override this for a request that legitimately needs - * more time; the default prevents auth/bootstrap requests from hanging - * forever when the platform service is unavailable. - */ -export const CLIENT_HTTP_DEFAULT_TIMEOUT_MS = 15_000; - -export class ClientHttpTimeoutError extends Error { - readonly code = 'CLIENT_HTTP_TIMEOUT'; - readonly timeoutMs: number; - readonly url: string; - - constructor(url: string, timeoutMs: number) { - super(`请求超时(${timeoutMs} ms):${url}`); - this.name = 'ClientHttpTimeoutError'; - this.timeoutMs = timeoutMs; - this.url = url; - } -} - -export function isClientHttpTimeoutError( - error: unknown, -): error is ClientHttpTimeoutError { - 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); - } -} function defaultClientServerPreset(): Exclude { if (isClientServerSelectionEnabled()) return 'dev'; return CLIENT_PLATFORM_CHANNEL; } -function isClientServerPreset(value: unknown): value is ClientServerPreset { +export function isClientServerPreset( + value: unknown, +): value is ClientServerPreset { return value === 'release' || value === 'dev' || value === 'custom'; } +/** + * 归一化自定义服务器地址。 + * + * 这里只做登录表单的即时反馈;正式 origin 由 Rust 认证命令再校验一次并持久化,所以放宽过 + * 的输入不会绕过后端门禁。 + */ export function normalizeClientServerBaseUrl(value: string) { const normalized = value.trim().replace(/\/+$/u, ''); let parsed: URL; @@ -143,234 +67,110 @@ export function normalizeClientServerBaseUrl(value: string) { return normalized; } -function readStoredClientServerSelection(): ClientServerSelection { - const fallback: ClientServerSelection = { - preset: defaultClientServerPreset(), - customBaseUrl: '', - }; - if (!isClientServerSelectionEnabled() || typeof window === 'undefined') { - return fallback; - } - try { - const raw = window.localStorage.getItem( - CLIENT_SERVER_SELECTION_STORAGE_KEY, - ); - if (!raw) return fallback; - const parsed = JSON.parse(raw) as { - preset?: unknown; - customBaseUrl?: unknown; - }; - if (!isClientServerPreset(parsed.preset)) return fallback; - const customBaseUrl = - typeof parsed.customBaseUrl === 'string' ? parsed.customBaseUrl : ''; - if (parsed.preset === 'custom') { - normalizeClientServerBaseUrl(customBaseUrl); - } - return { preset: parsed.preset, customBaseUrl }; - } catch { - return fallback; - } +export function channelServerBaseUrl() { + return CLIENT_PLATFORM_CHANNEL === 'release' + ? AGC_RELEASE_API_BASE_URL + : AGC_DEVELOPMENT_API_BASE_URL; } -export function getClientServerSelection() { - return readStoredClientServerSelection(); +/** 渠道默认的服务器选择(不含用户自定义地址)。 */ +export function defaultClientServerSelection(): ClientServerSelection { + return { preset: defaultClientServerPreset(), customBaseUrl: '' }; } -export function setClientServerSelection( +function normalizeSelection( selection: ClientServerSelection, ): ClientServerSelection { - const next: ClientServerSelection = { + return { preset: selection.preset, customBaseUrl: selection.preset === 'custom' ? normalizeClientServerBaseUrl(selection.customBaseUrl) : selection.customBaseUrl.trim(), }; - if (typeof window !== 'undefined') { - window.localStorage.setItem( - CLIENT_SERVER_SELECTION_STORAGE_KEY, - JSON.stringify(next), - ); +} + +/** + * 当前服务器选择的内存投影。 + * + * 正式偏好由 Rust 客户端偏好持有;渲染层只在启动时读一次快照并在用户切换时更新缓存, + * 因此 `getClientServerBaseUrl()` 仍可在渲染期同步使用。 + */ +let cachedClientServerSelection: ClientServerSelection = + defaultClientServerSelection(); + +export function getClientServerSelection() { + if (!isClientServerSelectionEnabled()) return defaultClientServerSelection(); + return cachedClientServerSelection; +} + +/** + * 切换服务器选择。 + * + * 返回值是本地即时投影;Rust 落盘结果由 `persistClientServerSelection` 之后的偏好事件确认。 + */ +export function setClientServerSelection( + selection: ClientServerSelection, +): ClientServerSelection { + const next = normalizeSelection(selection); + if (isClientServerSelectionEnabled()) { + cachedClientServerSelection = next; + } + if (isClientServerSelectionEnabled()) { + void persistClientServerSelection(next); } return next; } -export function resetClientServerSelectionForTests() { - if (typeof window !== 'undefined') { - window.localStorage.removeItem(CLIENT_SERVER_SELECTION_STORAGE_KEY); +async function persistClientServerSelection(selection: ClientServerSelection) { + const invoke = resolveTauriInvoke(); + if (!invoke) return; + try { + await invoke('set_client_server_selection', { + preset: selection.preset, + customBaseUrl: selection.customBaseUrl, + }); + } catch { + // 偏好写入失败不阻断本次登录:本次仍按内存投影请求,下一次启动回到已落盘的值。 } } -function getChannelServerBaseUrl() { - return CLIENT_PLATFORM_CHANNEL === 'release' - ? AGC_RELEASE_API_BASE_URL - : AGC_DEVELOPMENT_API_BASE_URL; +/** 从 Rust 客户端偏好恢复服务器选择;失败时保留渠道默认值。 */ +export async function hydrateClientServerSelection(): Promise { + if (!isClientServerSelectionEnabled()) return defaultClientServerSelection(); + const invoke = resolveTauriInvoke(); + if (!invoke) return cachedClientServerSelection; + try { + const snapshot = await invoke<{ + clientServerSelection?: { + preset?: unknown; + customBaseUrl?: unknown; + } | null; + }>('read_workspace_preferences'); + const stored = snapshot?.clientServerSelection; + if (!stored || !isClientServerPreset(stored.preset)) { + return cachedClientServerSelection; + } + const customBaseUrl = + typeof stored.customBaseUrl === 'string' ? stored.customBaseUrl : ''; + cachedClientServerSelection = normalizeSelection({ + preset: stored.preset, + customBaseUrl, + }); + } catch { + return cachedClientServerSelection; + } + return cachedClientServerSelection; } export function getClientServerBaseUrl(selection?: ClientServerSelection) { const resolved = selection ?? - (isClientServerSelectionEnabled() ? getClientServerSelection() : null); - if (!resolved) return getChannelServerBaseUrl(); + (isClientServerSelectionEnabled() + ? getClientServerSelection() + : defaultClientServerSelection()); + if (!resolved) return channelServerBaseUrl(); if (resolved.preset === 'release') return AGC_RELEASE_API_BASE_URL; if (resolved.preset === 'dev') return AGC_DEVELOPMENT_API_BASE_URL; return normalizeClientServerBaseUrl(resolved.customBaseUrl); } - -type ClientHttpContext = { - isTauri: boolean; - mode?: string; - serverBaseUrl?: string; -}; - -type ClientHttpTarget = { - transport: 'web' | 'tauri-http'; - url: string; -}; - -function withAgcClientMarker(init: RequestInit): RequestInit { - const headers = new Headers(init.headers); - headers.set(AGC_CLIENT_MARKER_HEADER, AGC_CLIENT_MARKER_VALUE); - return { ...init, headers }; -} - -function currentClientHttpContext(): ClientHttpContext { - return { - isTauri: typeof window !== 'undefined' && Boolean(window.__TAURI__), - mode: import.meta.env.MODE, - }; -} - -export function resolveClientHttpTarget( - url: string, - context: ClientHttpContext = currentClientHttpContext(), -): ClientHttpTarget { - // Unit fixtures use relative requests after the same origin validation. - if (context.mode === 'test' && !context.serverBaseUrl) { - return { transport: 'web', url }; - } - const serverBaseUrl = context.serverBaseUrl ?? getClientServerBaseUrl(); - if ( - !isClientServerSelectionEnabled() && - serverBaseUrl !== getChannelServerBaseUrl() - ) { - throw new Error('请求目标不在当前构建渠道的服务器范围内'); - } - const target = new URL(url, `${serverBaseUrl}/`); - if ( - (context.serverBaseUrl && context.serverBaseUrl !== serverBaseUrl) || - target.origin !== serverBaseUrl || - target.username || - target.password - ) { - throw new Error('请求目标不在当前构建渠道的服务器范围内'); - } - - if (context.mode === 'test') { - return { transport: 'web', url }; - } - - if (!context.isTauri) { - return { transport: 'web', url: target.toString() }; - } - return { transport: 'tauri-http', url: target.toString() }; -} - -export async function fetchClientHttp( - url: string, - init: RequestInit, - options: { - serverBaseUrl?: string; - /** Set to null to opt out for a long-running request. */ - timeoutMs?: number | null; - } = {}, -): Promise { - const currentContext = currentClientHttpContext(); - const target = resolveClientHttpTarget(url, { - ...currentContext, - serverBaseUrl: options.serverBaseUrl, - }); - const markedInit = withAgcClientMarker(init); - - // Always use a private controller so an internal timeout cannot mutate a - // caller-owned AbortSignal. The caller's signal is still propagated in - // both directions, preserving normal AbortError behaviour for user aborts. - const timeoutMs = - options.timeoutMs === undefined - ? CLIENT_HTTP_DEFAULT_TIMEOUT_MS - : options.timeoutMs; - if (timeoutMs === null) { - if (target.transport === 'tauri-http') { - return tauriHttpFetch(target.url, markedInit); - } - return fetch(target.url, markedInit); - } - - if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { - throw new RangeError('请求超时时间必须是大于 0 的有限数值'); - } - - const controller = new AbortController(); - let timedOut = false; - const callerSignal = markedInit.signal; - const forwardCallerAbort = () => { - // AbortSignal.reason is available in modern browsers/Tauri WebViews. The - // fallback keeps compatibility with older runtimes and test doubles. - const reason = callerSignal?.reason; - try { - controller.abort(reason); - } catch { - controller.abort(); - } - }; - if (callerSignal) { - if (callerSignal.aborted) { - forwardCallerAbort(); - } else { - callerSignal.addEventListener('abort', forwardCallerAbort, { - once: true, - }); - } - } - - const requestInit = { ...markedInit, signal: controller.signal }; - let request: Promise; - try { - // Keep invocation synchronous so an already-aborted caller signal is - // observed by transports that only subscribe to `abort` events. - const responsePromise = - target.transport === 'tauri-http' - ? tauriHttpFetch(target.url, requestInit) - : fetch(target.url, requestInit); - request = Promise.resolve(responsePromise); - } catch (error) { - callerSignal?.removeEventListener('abort', forwardCallerAbort); - throw error; - } - // A timed-out request is intentionally not awaited after the race settles, - // but transports may still reject when the abort reaches them. Attach a - // sink to avoid an unhandled rejection while keeping the original promise - // in the race for normal errors. - void request.catch(() => undefined); - let timeoutHandle: ReturnType | undefined; - const timeout = new Promise((_, reject) => { - timeoutHandle = setTimeout(() => { - timedOut = true; - controller.abort(); - reject(new ClientHttpTimeoutError(target.url, timeoutMs)); - }, timeoutMs); - }); - try { - return await Promise.race([request, timeout]); - } catch (error) { - // Some transports reject with a generic error after AbortController.abort; - // expose a stable, actionable error to auth/bootstrap callers. - if (timedOut) { - throw new ClientHttpTimeoutError(target.url, timeoutMs); - } - throw error; - } finally { - if (timeoutHandle !== undefined) clearTimeout(timeoutHandle); - callerSignal?.removeEventListener('abort', forwardCallerAbort); - } -} diff --git a/apps/ai-game-creator-shell/src/services/editorAssetHost.ts b/apps/ai-game-creator-shell/src/services/editorAssetHost.ts new file mode 100644 index 000000000..cace3ad37 --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/editorAssetHost.ts @@ -0,0 +1,30 @@ +import { invoke } from '@tauri-apps/api/core'; + +export type EditorAssetLibrarySnapshot = { + folders: Array<{ + folderId: string; + label: string; + }>; + assets: Array<{ + assetId: string; + folderId: string; + label: string; + assetKind?: string | null; + width?: number | null; + height?: number | null; + sizeBytes?: number | null; + previewAvailable: boolean; + }>; +}; + +export type EditorAssetPreview = { + previewUrl: string; +}; + +export function readEditorAssetLibrary() { + return invoke('read_editor_asset_library'); +} + +export function readEditorAssetPreview(assetId: string) { + return invoke('read_editor_asset_preview', { assetId }); +} diff --git a/apps/ai-game-creator-shell/src/services/errorReporting.ts b/apps/ai-game-creator-shell/src/services/errorReporting.ts index ad6a37de3..e7acc5329 100644 --- a/apps/ai-game-creator-shell/src/services/errorReporting.ts +++ b/apps/ai-game-creator-shell/src/services/errorReporting.ts @@ -1,7 +1,5 @@ import { invoke } from '@tauri-apps/api/core'; -import { getStoredAuthAccessToken } from './clientAuth'; -import { fetchClientHttp, getClientServerBaseUrl } from './clientHttp'; import { ackErrorReports, getPendingErrorReports, @@ -23,15 +21,6 @@ export type DiagnosticLogFile = { name: string; content: string }; type WebviewLogLevel = 'debug' | 'info' | 'warn' | 'error' | 'log'; -let fallbackSubmissionSequence = 0; - -function createSubmissionId() { - return ( - globalThis.crypto?.randomUUID?.() ?? - `submission-${Date.now()}-${++fallbackSubmissionSequence}` - ); -} - export function shouldCaptureClientError(error: unknown) { if (!error || typeof error !== 'object') return true; const candidate = error as { status?: unknown; networkError?: unknown }; @@ -199,67 +188,28 @@ export async function readApplicationDiagnosticLogs(): Promise< return invoke('read_diagnostic_logs'); } -export async function submitErrorReportBatch( - payload: { - events: ClientErrorEvent[]; - userDescription?: string; - logs: DiagnosticLogFile[]; - submissionId?: string; - }, - apiBaseUrl = getClientServerBaseUrl(), -) { - const token = getStoredAuthAccessToken(); - if (!token) throw new Error('请先登录后再提交错误报告'); - const response = await fetchClientHttp( - '/api/error-reports', - { - method: 'POST', - credentials: 'same-origin', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - Accept: 'application/json', - }, - body: JSON.stringify({ - schemaVersion: 1, - submissionId: payload.submissionId ?? createSubmissionId(), - events: payload.events, - userDescription: payload.userDescription?.trim() || null, - logs: payload.logs, - }), - }, - { serverBaseUrl: apiBaseUrl }, - ); - if (!response.ok) { - throw new Error(`错误报告提交失败(${response.status})`); - } - const result = (await response.json()) as { data?: unknown }; - return result.data ?? result; -} - -const stableSubmissionIds = new Map(); - -function errorEventKey(events: ClientErrorEvent[]) { - return events - .map((event) => event.eventId) - .sort() - .join('\u0000'); -} - -export function getStableErrorReportSubmissionId(events: ClientErrorEvent[]) { - const key = errorEventKey(events); - const existing = stableSubmissionIds.get(key); - if (existing) return existing; - const submissionId = createSubmissionId(); - stableSubmissionIds.set(key, submissionId); - return submissionId; +/** + * 提交一批脱敏错误报告。 + * + * 平台会话、origin、稳定 submissionId、响应 envelope 与错误分类都由 Rust typed + * command 持有;渲染层只交出脱敏事件、可选说明和诊断日志,不接触 access token。 + */ +export async function submitErrorReportBatch(payload: { + events: ClientErrorEvent[]; + userDescription?: string; + logs: DiagnosticLogFile[]; +}) { + return invoke('submit_error_report', { + events: payload.events, + logs: payload.logs, + userDescription: payload.userDescription?.trim() || null, + }); } export async function markClientErrorEventsSubmitted( events: ClientErrorEvent[], ) { await ackErrorReports(events.map((event) => event.eventId)); - stableSubmissionIds.delete(errorEventKey(events)); } export async function ackClientErrorEventsWithRetry( @@ -283,5 +233,4 @@ export async function ackClientErrorEventsWithRetry( /** 仅供单元测试隔离进程内错误池;生产流程不调用。 */ export function resetClientErrorEventsForTests() { // Rust 队列按进程生命周期管理;测试通过 fake bridge 重建进程内状态。 - stableSubmissionIds.clear(); } diff --git a/apps/ai-game-creator-shell/src/services/gameDistributionPublish.ts b/apps/ai-game-creator-shell/src/services/gameDistributionPublish.ts index 993ccd754..fc67e37cd 100644 --- a/apps/ai-game-creator-shell/src/services/gameDistributionPublish.ts +++ b/apps/ai-game-creator-shell/src/services/gameDistributionPublish.ts @@ -2,17 +2,11 @@ import type { GameCreationAppManifest } from '../../../../packages/shared/src/co import type { GameDistributionCategory, GameDistributionCreateGameRequest, - GameDistributionCreateVersionRequest, GameDistributionInputMode, GameDistributionOrientation, } from '../../../../packages/shared/src/contracts/gameDistribution'; -import type { - GamePackageUploadOutcome, - StagedGamePackage, - TauriInvoke, -} from '../app/types'; -import { getStoredAuthAccessToken, requestClientApi } from './clientApi'; -import { getClientServerBaseUrl } from './clientHttp'; +import { resolveTauriInvoke } from '../app/tauri'; +import type { TauriInvoke } from '../app/types'; export type GameDistributionPublishMetadata = { title: string; @@ -36,11 +30,13 @@ export type GameDistributionPublishMetadata = { /** 游戏截图上限与服务端 `MAX_GAME_SCREENSHOTS` 保持一致。 */ export const MAX_AGC_GAME_SCREENSHOTS = 6; -type FrontendRuntimeConfigResponse = { - imageEditorAgentSidebarEnabled?: boolean; - agcTemplateLibraryEnabled?: boolean; - gameDistributionPublishEnabled?: boolean; -}; +function requireNativeInvoke(invoke?: TauriInvoke) { + const resolved = invoke ?? resolveTauriInvoke(); + if (!resolved) { + throw new Error('需要在 Tauri App 内执行发布操作'); + } + return resolved; +} /** * 读取当前账号的游戏发布灰度。 @@ -49,13 +45,10 @@ type FrontendRuntimeConfigResponse = { * 时对已登录作者默认开放,运营收紧后只有白名单/灰度命中的作者返回 `true`。读取失败按 * “不开放入口”处理——写入口本身还会再拦一次,AGC 面板不作为唯一把关点。 */ -export async function readGamePublishAvailability(): Promise { - const config = await requestClientApi( - '/api/runtime/frontend-config', - { method: 'GET' }, - '读取发布灰度配置失败', - ); - return config?.gameDistributionPublishEnabled === true; +export async function readGamePublishAvailability( + invoke?: TauriInvoke, +): Promise { + return requireNativeInvoke(invoke)('read_game_publish_availability'); } export type GameDistributionPublishResult = { @@ -68,14 +61,6 @@ export type GameDistributionPublishResult = { fileCount: number; }; -type CreatedGame = { id: string; publicationRevision?: number }; -type CreatedVersion = { - gameId: string; - versionId: string; - versionNumber: number; - status: string; -}; - export function createGameDistributionPublishKey() { const randomUuid = typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' @@ -89,43 +74,38 @@ export type GameDistributionPublishMetadataSuggestion = { category: GameDistributionCategory; }; -export async function suggestGameDistributionPublishMetadata(args: { - name: string; - goal?: string | null; - context?: string | null; -}): Promise { - return requestClientApi( - '/api/game-distribution/publish-metadata/suggestions', +export async function suggestGameDistributionPublishMetadata( + args: { + name: string; + goal?: string | null; + context?: string | null; + }, + invoke?: TauriInvoke, +): Promise { + return requireNativeInvoke(invoke)( + 'suggest_game_distribution_publish_metadata', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - name: args.name.trim(), - goal: args.goal?.trim() || null, - context: args.context?.trim() || null, - }), + name: args.name.trim(), + goal: args.goal?.trim() || null, + context: args.context?.trim() || null, }, - '生成发布简介和分类失败', ); } -export async function readGameCoverGenerationPrice(args: { - model: string; - imageSize: string; -}): Promise { - const config = await requestClientApi<{ - models?: Record< - string, - { price?: number; prices?: Record } | undefined - >; - }>( - '/api/editor/generation-pricing', - { method: 'GET' }, - '读取封面生成价格失败', - { skipAuth: true }, +export async function readGameCoverGenerationPrice( + args: { + model: string; + imageSize: string; + }, + invoke?: TauriInvoke, +): Promise { + const price = await requireNativeInvoke(invoke)( + 'read_game_cover_generation_price', + { + model: args.model, + imageSize: args.imageSize, + }, ); - const model = config.models?.[args.model]; - const price = model?.prices?.[args.imageSize] ?? model?.price; if (typeof price !== 'number' || !Number.isFinite(price) || price < 0) { throw new Error('封面生成价格暂不可用'); } @@ -139,117 +119,40 @@ export type GameDistributionCoverGenerationResult = { model: string; }; -type GameDistributionCoverGenerationPayload = { - imageSrc?: string; - assetObjectId?: string | null; - asset?: { assetObjectId?: string | null } | null; - taskId?: string; - model?: string; - queueState?: { - operationId?: string | null; - status?: string | null; - phaseDetail?: string | null; - error?: string | null; - result?: unknown; - } | null; -}; - -const COVER_GENERATION_QUEUE_POLL_INTERVAL_MS = 1_600; -const COVER_GENERATION_QUEUE_TIMEOUT_MS = 20 * 60 * 1000; - -function waitForCoverGenerationQueue() { - return new Promise((resolve) => { - window.setTimeout(resolve, COVER_GENERATION_QUEUE_POLL_INTERVAL_MS); - }); -} - -async function resolveQueuedGameDistributionCover( - payload: GameDistributionCoverGenerationPayload, -): Promise { - const queueState = payload.queueState; - const operationId = queueState?.operationId?.trim(); - if ( - !operationId || - (queueState?.status !== 'queued' && queueState?.status !== 'running') - ) { - return payload; - } - const startedAt = Date.now(); - for (;;) { - if (Date.now() - startedAt > COVER_GENERATION_QUEUE_TIMEOUT_MS) { - throw new Error('生成游戏封面超时,请稍后重试'); - } - await waitForCoverGenerationQueue(); - const status = await requestClientApi<{ - job: { - status: string; - phaseDetail?: string | null; - error?: string | null; - result?: unknown; - }; - }>( - `/api/runtime/external-generation/jobs/${encodeURIComponent(operationId)}`, - { method: 'GET' }, - '读取封面生成任务失败', - ); - if (status.job.status === 'failed') { - throw new Error(status.job.error?.trim() || '生成游戏封面失败'); - } - if (status.job.status === 'completed') { - const result = - status.job.result && typeof status.job.result === 'object' - ? (status.job.result as GameDistributionCoverGenerationPayload) - : {}; - return { ...payload, ...result, queueState: null }; - } - } -} - /** * 基于项目上下文生成发行封面。 * * 这里复用编辑器图片生成 API:服务端按模型定价计费,并在生成成功后返回已经登记的 * 平台素材 ID。调用方不能把返回的预览地址当作发布封面,必须提交 assetObjectId。 */ -export async function generateGameDistributionCover(args: { - prompt: string; - model: string; - aspectRatio?: string; - imageSize?: string; - assetLabel?: string; -}): Promise { - const initialPayload = - await requestClientApi( - '/api/editor/images/generations', - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - prompt: args.prompt.trim(), - kind: 'publication-material', - assetKind: 'publication-material', - model: args.model, - aspectRatio: args.aspectRatio ?? '16:9', - imageSize: args.imageSize ?? '2K', - assetLabel: args.assetLabel?.trim() || '游戏封面', - }), - }, - '生成游戏封面失败', - ); - const payload = await resolveQueuedGameDistributionCover(initialPayload); - const assetObjectId = ( - payload.assetObjectId ?? - payload.asset?.assetObjectId ?? - '' - ).trim(); +export async function generateGameDistributionCover( + args: { + prompt: string; + model: string; + aspectRatio?: string; + imageSize?: string; + assetLabel?: string; + }, + invoke?: TauriInvoke, +): Promise { + const payload = await requireNativeInvoke( + invoke, + )('generate_game_distribution_cover', { + prompt: args.prompt.trim(), + model: args.model, + aspectRatio: args.aspectRatio ?? '16:9', + imageSize: args.imageSize ?? '2K', + assetLabel: args.assetLabel?.trim() || '游戏封面', + }); + const assetObjectId = String(payload.assetObjectId ?? '').trim(); if (!assetObjectId) { throw new Error('生成游戏封面未返回平台素材 ID'); } return { assetObjectId, - previewUrl: payload.imageSrc?.trim() || '', - taskId: payload.taskId?.trim() || '', - model: payload.model?.trim() || args.model, + previewUrl: String(payload.previewUrl ?? '').trim(), + taskId: String(payload.taskId ?? '').trim(), + model: String(payload.model ?? args.model).trim(), }; } @@ -330,21 +233,6 @@ export async function publishLocalProjectGame(args: { if (!projectPath || !packageRelativePath) { throw new Error('发布需要绑定本地项目和试玩包'); } - // 整包字节只留在原生进程:这里拿到的是归一化后的摘要与内容寻址暂存路径, - // 上传由原生侧按服务端分片大小完成,中断后同一暂存文件可直接续传。 - const staged = await args.invoke( - 'prepare_local_project_game_package', - { projectPath, packageRelativePath }, - ); - if ( - !staged.stagingPath.trim() || - staged.packageSha256.length !== 64 || - staged.packageSizeBytes <= 0 || - staged.packageFileCount <= 0 - ) { - throw new Error('本地发行包摘要无效,请重新导出试玩包'); - } - const metadata = normalizeMetadata(args.manifest, args.metadata); const localProjectId = args.manifest.projectId.trim(); if (!localProjectId) { @@ -353,84 +241,15 @@ export async function publishLocalProjectGame(args: { const gameMetadata = toCreateGameRequest(metadata, localProjectId); const rootKey = args.idempotencyKey?.trim() || createGameDistributionPublishKey(); - const game = await requestClientApi( - '/api/game-distribution/games', + // 发布编排、认证、origin、分片续传与送审都在 Rust 完成;渲染进程只传递 + // 已归一化的资料和本地项目相对包路径,不接触 token、base URL 或整包字节。 + return args.invoke( + 'publish_local_project_game', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Idempotency-Key': `${rootKey}:game`, - }, - body: JSON.stringify(gameMetadata), - }, - '创建平台游戏失败', - ); - if (!game?.id?.trim()) { - throw new Error('创建平台游戏未返回游戏 ID'); - } - - const versionRequest: GameDistributionCreateVersionRequest = { - localProjectId, - packageSha256: staged.packageSha256, - packageBytes: staged.packageSizeBytes, - packageFileCount: staged.packageFileCount, - packageEntryPath: 'index.html', - gameMetadata, - }; - const version = await requestClientApi( - `/api/game-distribution/games/${encodeURIComponent(game.id)}/versions`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Idempotency-Key': `${rootKey}:version`, - }, - body: JSON.stringify(versionRequest), - }, - '创建游戏发行版本失败', - ); - if (!version?.versionId?.trim()) { - throw new Error('创建发行版本未返回版本 ID'); - } - - const accessToken = getStoredAuthAccessToken(); - if (!accessToken) { - throw new Error('陶泥儿登录凭据缺失,请重新登录'); - } - const uploaded = await args.invoke( - 'upload_local_project_game_package', - { - stagingPath: staged.stagingPath, - versionId: version.versionId, - apiBaseUrl: getClientServerBaseUrl(), - accessToken, - idempotencyKey: `${rootKey}:upload`, + projectPath, + packageRelativePath, + metadata: gameMetadata, + idempotencyKey: rootKey, }, ); - const submitted = await requestClientApi<{ - game?: { publicationRevision?: number }; - version?: { status?: string }; - }>( - `/api/game-distribution/versions/${encodeURIComponent(version.versionId)}/submit`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Idempotency-Key': `${rootKey}:submit`, - }, - body: JSON.stringify({ - expectedPublicationRevision: game.publicationRevision ?? 0, - }), - }, - '提交审核失败', - ); - return { - gameId: game.id, - versionId: version.versionId, - versionNumber: version.versionNumber, - status: submitted?.version?.status ?? uploaded?.status ?? 'pending_review', - packageSha256: staged.packageSha256, - packageSizeBytes: staged.packageSizeBytes, - fileCount: staged.packageFileCount, - }; } diff --git a/apps/ai-game-creator-shell/src/services/llmModelCatalog.ts b/apps/ai-game-creator-shell/src/services/llmModelCatalog.ts index 9601ff2a3..18494f766 100644 --- a/apps/ai-game-creator-shell/src/services/llmModelCatalog.ts +++ b/apps/ai-game-creator-shell/src/services/llmModelCatalog.ts @@ -1,6 +1,16 @@ import { resolveTauriInvoke } from '../app/tauri'; import type { GameCreatorAppConfigView } from '../app/types'; -import { type ClientLlmModelCatalog, loadClientLlmModels } from './clientApi'; + +export type ClientLlmModel = { + displayName: string; + id: string; +}; + +export type ClientLlmModelCatalog = { + defaultModelId: string; + models: ClientLlmModel[]; + revision: number; +}; let cached: ClientLlmModelCatalog | null = null; let inFlight: Promise | null = null; @@ -20,11 +30,14 @@ export function notifyLlmConfigChanged() { async function loadEffectiveCatalog(epoch: number) { const invoke = resolveTauriInvoke(); + if (!invoke) { + throw new LlmModelCatalogConfigError('本地模型服务不可用'); + } let config: GameCreatorAppConfigView | undefined; try { - config = invoke - ? await invoke('read_game_creator_app_config') - : undefined; + config = await invoke( + 'read_game_creator_app_config', + ); } catch (error) { if (epoch === generation) cached = null; throw new LlmModelCatalogConfigError('读取客户端配置失败'); @@ -47,7 +60,8 @@ async function loadEffectiveCatalog(epoch: number) { revision: --localRevision, }; } - return loadClientLlmModels(); + // 官方模型目录的凭据、请求和响应解析归 Rust;渲染层只接收目录快照。 + return invoke('load_game_creator_llm_models'); } /** 最近一次成功读取的模型目录,用于首屏渲染与刷新失败时兜底。 */ diff --git a/apps/ai-game-creator-shell/src/services/platformSession.ts b/apps/ai-game-creator-shell/src/services/platformSession.ts index 144c83f79..372aa7ca9 100644 --- a/apps/ai-game-creator-shell/src/services/platformSession.ts +++ b/apps/ai-game-creator-shell/src/services/platformSession.ts @@ -1,41 +1,16 @@ import type { AuthUser } from '../../../../packages/shared/src/contracts/auth'; -import { resolveTauriInvoke } from '../app/tauri'; import { - clearStoredAuthAccessToken, - getCurrentClientAuthUser, - getStoredAuthAccessToken, - isClientAuthAuthorityFailure, - refreshClientAuthAccessToken, - setStoredAuthAccessToken, + refreshClientAuthSession, + subscribeClientAuthState, } from './clientAuth'; -import { getClientServerBaseUrl } from './clientHttp'; -import { - type ClientOperation, - createClientOperation, - transitionClientOperation, -} from './clientOperation'; - -function readStoredAccessTokenOrThrow(apiBaseUrl: string) { - const accessToken = getStoredAuthAccessToken(apiBaseUrl); - if (!accessToken) { - throw new Error('陶泥儿登录凭据缺失,请重新登录'); - } - return accessToken; -} - -type CommittedPlatformSession = { - user: AuthUser; - accessToken: string; - apiBaseUrl: string; - generation: number; -}; - -/** 原生写入:身份代次表达主体归属,revision 只表达写入顺序。 */ -type PlatformNativeSessionWrite = { - identityGeneration: number; - revision: number; -}; +/** + * 平台会话投影。 + * + * 凭据、续期请求与本机运行时会话安装都由 Rust 持有;渲染层只维护「身份代次」这一个派生 + * 计数,用来让异步操作识别换号/登出后到达的迟到结果。代次只在身份真的变化时推进:同一账号 + * 的凭据轮换不推进它。 + */ export type PlatformSessionRefreshResult = | { status: 'refreshed'; user: AuthUser; generation: number } | { status: 'stale' } @@ -44,8 +19,7 @@ export type PlatformSessionRefreshResult = error: unknown; /** * 只有服务端明确否认当前身份(401/403,且收敛重试后仍失败)才为 true。 - * 网络错误、5xx、网关错误和响应契约异常必须保留既有会话与 access token, - * 调用方不得据此把用户登出。 + * 网络错误、5xx、网关错误和响应契约异常必须保留既有会话,调用方不得据此登出。 */ authoritative: boolean; }; @@ -57,60 +31,15 @@ type PlatformSessionRefreshListener = ( type PlatformSessionGenerationListener = (generation: number) => void; let platformAuthGeneration = 0; -/** 原生写入 revision:每次安装 / 清除都推进,用于拒绝迟到写入。 */ -let platformNativeRevision = 0; -/** 原生身份代次:只在登录、切号、登出或新 authority epoch 推进,续期保持不变。 */ -let platformNativeIdentityGeneration = 0; -let platformNativeSessionFloorPromise: Promise<{ - identityGeneration: number; - revision: number; -}> | null = null; -let committedPlatformSession: CommittedPlatformSession | null = null; -let desiredPlatformSession: CommittedPlatformSession | null = null; let platformSessionRefreshPromise: Promise | null = null; -let platformSessionOperation: ClientOperation< - 'auth-transition', - { userId: string | null } -> | null = null; -/** - * 本地会话写入的排队闸门。 - * - * Rust 侧 `install_platform_session_in` / `clear_platform_session_in` 按 generation 单调校验: - * 更旧的 install 与更旧的 clear 都会被拒绝。因此渲染层必须保证的只有"新 generation 不被旧调用 - * 无限挡住",而不需要让队列永远等下去。这里给闸门加一个上限:底层 invoke 迟迟不返回(例如 - * Runner 卡住、IPC 不回调)时,后续登录/退出仍能继续推进,迟到的旧写入由 Rust 按代次拒绝。 - */ -const PLATFORM_SESSION_NATIVE_MUTATION_ABANDONMENT_MS = 60_000; -let platformSessionNativeMutationGate: Promise = Promise.resolve(); +let cachedUserId: string | null = null; +let cachedApiBaseUrl: string | null = null; const platformSessionRefreshListeners = new Set(); const platformSessionGenerationListeners = new Set(); -export function getPlatformSessionOperation() { - return platformSessionOperation; -} - -function restoreCommittedAccessToken() { - if (committedPlatformSession?.accessToken) { - setStoredAuthAccessToken( - committedPlatformSession.accessToken, - committedPlatformSession.apiBaseUrl, - ); - return; - } - clearStoredAuthAccessToken(); -} - -function restoreCurrentRendererAccessToken() { - if (!desiredPlatformSession) { - clearStoredAuthAccessToken(); - return; - } - restoreCommittedAccessToken(); -} - function notifyPlatformSessionRefresh(result: PlatformSessionRefreshResult) { for (const listener of platformSessionRefreshListeners) { listener(result); @@ -123,453 +52,28 @@ function notifyPlatformSessionGeneration() { } } -async function installNativePlatformSession( - session: CommittedPlatformSession, - write: PlatformNativeSessionWrite, -) { - const invoke = resolveTauriInvoke(); - if (!invoke) return; - await invoke('install_platform_account_session', { - userId: session.user.id, - accessToken: session.accessToken, - apiBaseUrl: session.apiBaseUrl, - identityGeneration: write.identityGeneration, - revision: write.revision, - }); -} - -async function clearNativePlatformSession(write: PlatformNativeSessionWrite) { - const invoke = resolveTauriInvoke(); - if (!invoke) return; - await invoke('clear_platform_account_session', { - identityGeneration: write.identityGeneration, - revision: write.revision, - }); -} - -function waitForNativeMutationAbandonment( - settled: Promise, - timeoutMs: number, -) { - let timerId: number | undefined; - const abandoned = new Promise((resolve) => { - timerId = window.setTimeout(resolve, timeoutMs); - }); - return Promise.race([settled, abandoned]).finally(() => { - if (timerId !== undefined) { - window.clearTimeout(timerId); - } - }); -} - -function enqueuePlatformSessionNativeMutation( - operation: () => Promise, - abandonmentMs = PLATFORM_SESSION_NATIVE_MUTATION_ABANDONMENT_MS, -): Promise { - const pending = platformSessionNativeMutationGate - .catch(() => undefined) - .then(operation); - platformSessionNativeMutationGate = waitForNativeMutationAbandonment( - pending.then( - () => undefined, - () => undefined, - ), - abandonmentMs, - ); - return pending; -} - -async function readNativePlatformSessionGenerationFloor() { - const invoke = resolveTauriInvoke(); - if (!invoke) return { identityGeneration: 0, revision: 0 }; - const state = await invoke<{ - identityGeneration?: unknown; - revision?: unknown; - } | null>('read_platform_account_session_state'); - // Browser/unit-test adapters commonly expose a no-op invoke that returns null - // for native-only read commands. They have no surviving Rust generation floor. - if (state === null || state === undefined) { - return { identityGeneration: 0, revision: 0 }; - } - const identityGeneration = Number(state.identityGeneration ?? 0); - const revision = Number(state.revision ?? 0); - if ( - !Number.isSafeInteger(identityGeneration) || - identityGeneration < 0 || - !Number.isSafeInteger(revision) || - revision < 0 - ) { - throw new Error('本地运行时登录态写入下限无效,请重启客户端后重试'); - } - return { identityGeneration, revision }; -} - -async function reserveNativePlatformSessionWrite(options: { - identityChange: boolean; -}): Promise { - platformNativeSessionFloorPromise ??= - readNativePlatformSessionGenerationFloor(); - let floor: { identityGeneration: number; revision: number }; - try { - floor = await platformNativeSessionFloorPromise; - } catch (error) { - // 一次瞬时失败(IPC 抖动、Runner 刚重启)不能被缓存成"永久失败":否则本次渲染进程 - // 内的后续登录/退出都会在同一个已 reject 的 promise 上失败,用户重试也不会重新读取。 - platformNativeSessionFloorPromise = null; - throw error; - } - platformNativeRevision = Math.max( - platformNativeRevision + 1, - platformAuthGeneration, - floor.revision + 1, - ); - // 同一账号的凭据续期必须复用当前身份代次;只有登录、切号、登出或新 authority epoch - // 才允许推进它,否则在途生成 operation 会被自己的续期判成"旧账号请求"。 - platformNativeIdentityGeneration = options.identityChange - ? Math.max( - platformNativeIdentityGeneration + 1, - floor.identityGeneration + 1, - ) - : Math.max(platformNativeIdentityGeneration, floor.identityGeneration); - return { - identityGeneration: platformNativeIdentityGeneration, - revision: platformNativeRevision, - }; -} - -async function reconcileNativePlatformSessionToCurrentAuthority() { - for (;;) { - const authoritativeGeneration = platformAuthGeneration; - const authoritativeSession = desiredPlatformSession - ? { ...desiredPlatformSession } - : null; - // 只有权威会话与上一次已提交会话不是同一身份时才推进身份代次:同账号续期后的对账 - // 仍然算同一身份,不得让在途 operation 失效。 - const identityChange = - !committedPlatformSession || - !authoritativeSession || - committedPlatformSession.user.id !== authoritativeSession.user.id || - committedPlatformSession.apiBaseUrl !== authoritativeSession.apiBaseUrl; - const write = await reserveNativePlatformSessionWrite({ identityChange }); - restoreCurrentRendererAccessToken(); - try { - if (authoritativeSession) { - await installNativePlatformSession(authoritativeSession, write); - } else { - await clearNativePlatformSession(write); - } - } catch (error) { - if (platformAuthGeneration === authoritativeGeneration) { - committedPlatformSession = null; - desiredPlatformSession = null; - restoreCommittedAccessToken(); - notifyPlatformSessionGeneration(); - } - throw error; - } - if (platformAuthGeneration !== authoritativeGeneration) { - continue; - } - committedPlatformSession = authoritativeSession - ? { - ...authoritativeSession, - generation: authoritativeGeneration, - } - : null; - desiredPlatformSession = committedPlatformSession - ? { ...committedPlatformSession } - : null; - restoreCommittedAccessToken(); - notifyPlatformSessionGeneration(); - return; - } -} - -function resolvePlatformApiBaseUrl() { - return getClientServerBaseUrl(); -} - -async function commitNativePlatformSession( - candidate: CommittedPlatformSession, - authorityGeneration: number, - options: { identityChange: boolean }, -): Promise { - const write = await reserveNativePlatformSessionWrite({ - identityChange: options.identityChange, - }); - try { - await installNativePlatformSession(candidate, write); - } catch (error) { - if (platformAuthGeneration === authorityGeneration) { - desiredPlatformSession = committedPlatformSession - ? { ...committedPlatformSession } - : null; - } - await reconcileNativePlatformSessionToCurrentAuthority(); - if (platformAuthGeneration !== authorityGeneration) return null; - throw error; - } - if (platformAuthGeneration !== authorityGeneration) { - await reconcileNativePlatformSessionToCurrentAuthority(); - return null; - } - committedPlatformSession = candidate; - desiredPlatformSession = { ...candidate }; - restoreCommittedAccessToken(); - if (options.identityChange) { - notifyPlatformSessionGeneration(); - } - return candidate; -} - -async function commitPlatformSession( - user: AuthUser, - accessToken: string, - apiBaseUrl: string, - expectedGeneration: number, -): Promise { - if (platformAuthGeneration !== expectedGeneration) { - restoreCurrentRendererAccessToken(); - return null; - } - const candidate: CommittedPlatformSession = { - user, - accessToken, - apiBaseUrl, - generation: expectedGeneration + 1, - }; - // Reserve a new generation so older refresh/login work becomes stale, but keep the previous - // committed identity authoritative until Rust and Runner have accepted the candidate. - platformAuthGeneration = candidate.generation; - desiredPlatformSession = { ...candidate }; - notifyPlatformSessionGeneration(); - restoreCommittedAccessToken(); - return commitNativePlatformSession(candidate, candidate.generation, { - identityChange: true, - }); -} - /** - * 同一身份的凭据续期:只替换 access token 与 native 写入 revision,保持身份代次不变, - * 因此在途生成、编辑、上传、确认和下载 operation 不会被自己的续期判成旧账号请求。 + * 采纳一个身份:只有主体或 origin 真的变化时才推进代次。 + * + * 同一账号的续期、同一身份重复登录(例如 StrictMode 双次 hydrate)都必须保持代次不变, + * 否则在途生成会被自己的续期判成旧账号请求。 */ -async function commitPlatformCredentialRefresh( - user: AuthUser, - accessToken: string, - apiBaseUrl: string, - expectedGeneration: number, -): Promise { - if (platformAuthGeneration !== expectedGeneration) { - restoreCurrentRendererAccessToken(); - return null; +function adoptIdentity(user: AuthUser | null, apiBaseUrl: string | null) { + const nextUserId = user?.id ?? null; + if (cachedUserId === nextUserId && cachedApiBaseUrl === apiBaseUrl) { + return platformAuthGeneration; } - const current = committedPlatformSession; - if (!current) { - restoreCurrentRendererAccessToken(); - return null; - } - if (current.user.id !== user.id || current.apiBaseUrl !== apiBaseUrl) { - // 身份已经变化:按换号路径重新提交,不能复用旧身份代次。 - return commitPlatformSession( - user, - accessToken, - apiBaseUrl, - expectedGeneration, - ); - } - const candidate: CommittedPlatformSession = { - user, - accessToken, - apiBaseUrl, - generation: current.generation, - }; - desiredPlatformSession = { ...candidate }; - restoreCurrentRendererAccessToken(); - return commitNativePlatformSession(candidate, expectedGeneration, { - identityChange: false, - }); + cachedUserId = nextUserId; + cachedApiBaseUrl = apiBaseUrl; + platformAuthGeneration += 1; + notifyPlatformSessionGeneration(); + return platformAuthGeneration; } export function currentPlatformSessionGeneration() { return platformAuthGeneration; } -export function currentPlatformSessionApiBaseUrl() { - return committedPlatformSession?.apiBaseUrl || resolvePlatformApiBaseUrl(); -} - -/** 仅供测试断言:同一账号续期不得推进这个身份代次。 */ -export function currentPlatformNativeIdentityGenerationForTests() { - return platformNativeIdentityGeneration; -} - -export function beginPlatformSessionTransition() { - platformAuthGeneration += 1; - desiredPlatformSession = committedPlatformSession - ? { ...committedPlatformSession } - : null; - notifyPlatformSessionGeneration(); - return platformAuthGeneration; -} - -export function beginPlatformSessionClearTransition() { - platformAuthGeneration += 1; - desiredPlatformSession = null; - notifyPlatformSessionGeneration(); - return platformAuthGeneration; -} - -export async function commitAuthenticatedPlatformSession( - user: AuthUser, - expectedGeneration: number, - apiBaseUrl = resolvePlatformApiBaseUrl(), -) { - const accessToken = readStoredAccessTokenOrThrow(apiBaseUrl); - const operation = createClientOperation( - 'auth-transition', - { userId: user.id }, - { - scope: { apiBaseUrl, sessionGeneration: expectedGeneration }, - deadlineMs: 45_000, - cancellable: false, - }, - ); - platformSessionOperation = transitionClientOperation(operation, 'runner'); - return enqueuePlatformSessionNativeMutation(async () => { - try { - const session = await commitPlatformSession( - user, - accessToken, - apiBaseUrl, - expectedGeneration, - ); - if (!session) { - platformSessionOperation = transitionClientOperation( - operation, - 'unknown', - ); - return null; - } - platformSessionOperation = transitionClientOperation( - operation, - 'success', - { scope: { sessionGeneration: session.generation } }, - ); - return session.generation; - } catch (error) { - platformSessionOperation = transitionClientOperation( - operation, - 'retryable-failure', - ); - throw error; - } - }); -} - -export async function refreshPlatformSessionForGeneration( - expectedGeneration: number, - apiBaseUrl = resolvePlatformApiBaseUrl(), -) { - const token = await refreshClientAuthAccessToken(apiBaseUrl); - if (platformAuthGeneration !== expectedGeneration) { - restoreCurrentRendererAccessToken(); - return null; - } - return token; -} - -export function requestPlatformSessionRefresh(expectedUserId?: string) { - if (platformSessionRefreshPromise) return platformSessionRefreshPromise; - - const expectedGeneration = platformAuthGeneration; - const apiBaseUrl = - committedPlatformSession?.apiBaseUrl || resolvePlatformApiBaseUrl(); - const expectedSessionUserId = - expectedUserId?.trim() || committedPlatformSession?.user.id || ''; - platformSessionRefreshPromise = - (async (): Promise => { - try { - const refreshed = await refreshPlatformSessionForGeneration( - expectedGeneration, - apiBaseUrl, - ); - if (!refreshed) return { status: 'stale' }; - const user = await getCurrentClientAuthUser(apiBaseUrl); - if ( - platformAuthGeneration !== expectedGeneration || - !user || - (expectedSessionUserId && user.id !== expectedSessionUserId) - ) { - restoreCurrentRendererAccessToken(); - return { status: 'stale' }; - } - // 同一账号的续期只更新凭据:身份代次保持不变,因此在途生成 operation 不会被 - // 自己的续期判成旧账号请求。 - const committed = await enqueuePlatformSessionNativeMutation(() => - commitPlatformCredentialRefresh( - user, - readStoredAccessTokenOrThrow(apiBaseUrl), - apiBaseUrl, - expectedGeneration, - ), - ); - if (committed === null) return { status: 'stale' }; - return { - status: 'refreshed', - user, - generation: committed.generation, - }; - } catch (error) { - if (platformAuthGeneration !== expectedGeneration) { - restoreCurrentRendererAccessToken(); - return { status: 'stale' }; - } - let failure = error; - const currentOwnerUserId = committedPlatformSession?.user.id || ''; - if ( - currentOwnerUserId && - currentOwnerUserId !== expectedSessionUserId - ) { - restoreCurrentRendererAccessToken(); - return { status: 'stale' }; - } - // 只有服务端明确否认当前身份才算权威失效。网络错误、5xx、网关错误和响应契约 - // 异常必须保留既有会话与 access token,否则一次后台保活抖动就会把用户登出。 - const authoritative = isClientAuthAuthorityFailure(error); - if ( - authoritative && - (!currentOwnerUserId || currentOwnerUserId === expectedSessionUserId) - ) { - const clearGeneration = beginPlatformSessionClearTransition(); - try { - await clearCommittedPlatformSession(clearGeneration); - } catch (clearError) { - failure = clearError; - } - return { status: 'failed', error: failure, authoritative: true }; - } - restoreCurrentRendererAccessToken(); - return { status: 'failed', error: failure, authoritative: false }; - } - })().then((result) => { - notifyPlatformSessionRefresh(result); - return result; - }); - platformSessionRefreshPromise.finally(() => { - platformSessionRefreshPromise = null; - }); - return platformSessionRefreshPromise; -} - -export function subscribePlatformSessionRefresh( - listener: PlatformSessionRefreshListener, -) { - platformSessionRefreshListeners.add(listener); - return () => { - platformSessionRefreshListeners.delete(listener); - }; -} - export function subscribePlatformSessionGeneration( listener: PlatformSessionGenerationListener, ) { @@ -579,80 +83,124 @@ export function subscribePlatformSessionGeneration( }; } -export async function clearCommittedPlatformSession(generation: number) { - const operation = createClientOperation( - 'auth-transition', - { userId: null }, - { - scope: { sessionGeneration: generation }, - deadlineMs: 45_000, - cancellable: false, - }, - ); - platformSessionOperation = transitionClientOperation(operation, 'runner'); - return enqueuePlatformSessionNativeMutation(async () => { - try { - if (platformAuthGeneration !== generation) { - await reconcileNativePlatformSessionToCurrentAuthority(); - platformSessionOperation = transitionClientOperation( - operation, - 'unknown', - ); - return; - } - desiredPlatformSession = null; - const write = await reserveNativePlatformSessionWrite({ - identityChange: true, - }); - try { - await clearNativePlatformSession(write); - } catch { - if (platformAuthGeneration === generation) { - desiredPlatformSession = null; - } - await reconcileNativePlatformSessionToCurrentAuthority(); - platformSessionOperation = transitionClientOperation( - operation, - 'retryable-failure', - ); - return; - } - if (platformAuthGeneration !== generation) { - await reconcileNativePlatformSessionToCurrentAuthority(); - platformSessionOperation = transitionClientOperation( - operation, - 'unknown', - ); - return; - } - committedPlatformSession = null; - desiredPlatformSession = null; - restoreCommittedAccessToken(); - notifyPlatformSessionGeneration(); - platformSessionOperation = transitionClientOperation( - operation, - 'success', - ); - } catch (error) { - platformSessionOperation = transitionClientOperation( - operation, - 'retryable-failure', - ); - throw error; - } - }); +export function subscribePlatformSessionRefresh( + listener: PlatformSessionRefreshListener, +) { + platformSessionRefreshListeners.add(listener); + return () => { + platformSessionRefreshListeners.delete(listener); + }; } -export function resetPlatformSessionStateForTests() { +/** 开始一次登录/切换:旧身份立即失效,防止旧账号的在途结果落地。 */ +export function beginPlatformSessionTransition() { + cachedUserId = null; + cachedApiBaseUrl = null; + platformAuthGeneration += 1; + notifyPlatformSessionGeneration(); + return platformAuthGeneration; +} + +/** 开始一次登出:语义与登录切换一致。 */ +export function beginPlatformSessionClearTransition() { + return beginPlatformSessionTransition(); +} + +/** + * 确认一次登录已在 Rust 落地并采纳该身份。 + * + * 真正的凭据持久化、本机运行时会话安装与 Runner 传播都发生在 Rust 登录命令内;这里只做 + * 代次收口。`expectedGeneration` 不再匹配说明期间发生了更新的登录/登出,调用方应放弃。 + */ +export async function commitAuthenticatedPlatformSession( + user: AuthUser, + expectedGeneration: number, + apiBaseUrl: string, +) { + if (platformAuthGeneration !== expectedGeneration) { + return null; + } + adoptIdentity(user, apiBaseUrl); + return platformAuthGeneration; +} + +/** + * 续期平台会话(单飞)。 + * + * Rust 侧同时只做一次续期,因此同一渲染进程内的并发调用也会合并到同一次请求。 + */ +export function requestPlatformSessionRefresh(expectedUserId?: string) { + if (platformSessionRefreshPromise) return platformSessionRefreshPromise; + const expectedGeneration = platformAuthGeneration; + const expectedSessionUserId = expectedUserId?.trim() || cachedUserId || ''; + platformSessionRefreshPromise = + (async (): Promise => { + let result: Awaited>; + try { + result = await refreshClientAuthSession( + expectedSessionUserId || undefined, + ); + } catch (error) { + if (platformAuthGeneration !== expectedGeneration) { + return { status: 'stale' }; + } + return { status: 'failed', error, authoritative: false }; + } + if (platformAuthGeneration !== expectedGeneration) { + return { status: 'stale' }; + } + if (result.status === 'refreshed') { + if (expectedSessionUserId && result.user.id !== expectedSessionUserId) { + return { status: 'stale' }; + } + // 同一账号续期只更新凭据:身份代次保持不变。 + return { + status: 'refreshed', + user: result.user, + generation: platformAuthGeneration, + }; + } + if (result.status === 'unauthenticated') { + adoptIdentity(null, null); + return { + status: 'failed', + error: new Error('登录已失效,请重新登录'), + authoritative: true, + }; + } + if (result.status === 'stale') { + return { status: 'stale' }; + } + return { + status: 'failed', + error: new Error(result.message), + authoritative: result.authoritative, + }; + })().then((result) => { + notifyPlatformSessionRefresh(result); + return result; + }); + platformSessionRefreshPromise.finally(() => { + platformSessionRefreshPromise = null; + }); + return platformSessionRefreshPromise; +} + +// Rust 是身份事实源:另一个窗口登录/登出或 Rust 内部续期换号时,投影跟着更新。 +void subscribeClientAuthState((state) => { + if (state.status === 'authenticated') { + adoptIdentity(state.user, state.apiBaseUrl); + return; + } + adoptIdentity(null, null); +}); + +/** 仅供测试隔离模块级计数。 */ +export function resetPlatformSessionForTests() { platformAuthGeneration = 0; - platformNativeRevision = 0; - platformNativeIdentityGeneration = 0; - platformNativeSessionFloorPromise = null; - committedPlatformSession = null; - desiredPlatformSession = null; platformSessionRefreshPromise = null; - platformSessionOperation = null; - platformSessionNativeMutationGate = Promise.resolve(); + cachedUserId = null; + cachedApiBaseUrl = null; platformSessionRefreshListeners.clear(); platformSessionGenerationListeners.clear(); } diff --git a/apps/ai-game-creator-shell/src/services/platformSessionRetry.ts b/apps/ai-game-creator-shell/src/services/platformSessionRetry.ts new file mode 100644 index 000000000..baf76765d --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/platformSessionRetry.ts @@ -0,0 +1,48 @@ +import { + currentPlatformSessionGeneration, + requestPlatformSessionRefresh, +} from './platformSession'; + +/** + * 服务端明确否认当前身份时的稳定分类。 + * + * Rust typed command 与 Direct Codex app-server 都会把登录失效投影成 + * `authentication-required`(或 provider 侧的 unauthorized 分类);只有这类错误才值得 + * 续期后重试一次,网络抖动、5xx 和业务拒绝必须原样抛出。 + */ +export function isPlatformAuthenticationRequiredError(error: unknown) { + const message = error instanceof Error ? error.message : String(error); + return ( + message.includes('authentication-required') || + message.includes('codex-app-server-error:unauthorized') || + /kind=codex-app-server-unauthorized(?=\s|$)/.test(message) || + message.includes('登录已失效') + ); +} + +/** + * 平台会话续期后重试一次。 + * + * 刷新期间账号代际变化(换号、登出)时必须放弃重试:带着旧身份的请求重放会把上一个 + * 账号的副作用打进新账号。只有 `authentication-required` 才触发续期,其它错误直接抛出。 + */ +export async function withPlatformSessionRefreshRetry( + operation: () => Promise, +): Promise { + const generation = currentPlatformSessionGeneration(); + try { + return await operation(); + } catch (error) { + if (!isPlatformAuthenticationRequiredError(error)) throw error; + if (currentPlatformSessionGeneration() !== generation) throw error; + const refresh = await requestPlatformSessionRefresh(); + if (refresh.status === 'failed') throw error; + if ( + refresh.status !== 'refreshed' || + currentPlatformSessionGeneration() !== refresh.generation + ) { + throw new Error('登录账号已变化,原请求已停止'); + } + return operation(); + } +} diff --git a/apps/ai-game-creator-shell/src/services/pluginHost.ts b/apps/ai-game-creator-shell/src/services/pluginHost.ts index 898a219df..12b7795a0 100644 --- a/apps/ai-game-creator-shell/src/services/pluginHost.ts +++ b/apps/ai-game-creator-shell/src/services/pluginHost.ts @@ -5,6 +5,9 @@ import type { AgcPluginSummary, } from '../app/types'; +export const AGC_PLUGIN_STATE_CHANGED_EVENT = + 'game-creator-plugin-state-changed'; + export type PluginRpcParams = Record | unknown[] | null; function invokeOrThrow() { diff --git a/apps/ai-game-creator-shell/src/stores/useWalletStore.ts b/apps/ai-game-creator-shell/src/stores/useWalletStore.ts index 9f31d6e04..4e71d8f29 100644 --- a/apps/ai-game-creator-shell/src/stores/useWalletStore.ts +++ b/apps/ai-game-creator-shell/src/stores/useWalletStore.ts @@ -1,5 +1,5 @@ import { createProfileWalletStore } from '../../../../packages/shared/src'; -import { getClientProfileRechargeCenter } from '../services/clientApi'; +import { getClientProfileRechargeCenter } from '../services/accountHost'; export const useWalletStore = createProfileWalletStore({ getRechargeCenter: getClientProfileRechargeCenter, diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index a08b2edde..2d337b01e 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -6948,10 +6948,20 @@ iframe.preview-frame { right: 14px; bottom: 58px; z-index: 40; + /* + * 高度夹紧必须写在**定位锚点**这一层:百分比 max-height 相对定位祖先 + * (`.game-resource-book-manager`,position: relative 且有确定高度)解析,写在这里才会生效。 + * 之前写在内层 `.game-resource-filter-panel__card` 上时,它的包含块是高度 auto 的这层, + * 百分比按 none 处理——标签多起来面板会一路顶出管理区,被管理区的 `overflow: hidden` + * 裁掉顶部且滚不动,现场「标签面板无法查看更多标签」即此。 + */ + max-height: calc(100% - 72px); + overflow-y: auto; } .game-resource-filter-panel__card { - max-height: calc(100% - 72px); + /* 夹紧与滚动已上移到定位锚点,内层不再承担(也承担不了)这个百分比。 */ + max-height: none; overflow-y: auto; } @@ -9259,7 +9269,7 @@ iframe.preview-frame { .game-workbench-chat .project-chat-message-list { height: 100%; min-height: 96px; - max-height: none; + max-height: calc(100% - 72px); overflow-y: auto; border: 1px solid var(--platform-subpanel-border); background: var(--platform-input-fill); @@ -12020,6 +12030,69 @@ button.design-workspace-tree__entry:hover, cursor: not-allowed; } +/* 运行诊断详情:状态栏下方一行入口,展开后是定高可滚动的脱敏正文。 */ +.game-workbench-chat + .project-chat-surface.is-direct-codex + .project-chat-runtime-detail { + display: flex; + flex: 0 0 auto; + flex-direction: column; + gap: 6px; + padding: 8px 16px 0; +} + +.game-workbench-chat + .project-chat-surface.is-direct-codex + .project-chat-runtime-detail-toggle { + align-self: flex-start; + height: 24px; + padding: 0 10px; + border: 1px solid var(--platform-button-primary-border); + border-radius: 999px; + background: transparent; + color: var(--platform-text-soft); + font: inherit; + font-size: 12px; + font-weight: 700; + cursor: pointer; +} + +.game-workbench-chat + .project-chat-surface.is-direct-codex + .project-chat-runtime-detail-toggle:hover:not(:disabled), +.game-workbench-chat + .project-chat-surface.is-direct-codex + .project-chat-runtime-detail-toggle:focus-visible { + background: var(--platform-button-ghost-fill); + color: var(--platform-text-strong); + outline: none; +} + +.game-workbench-chat + .project-chat-surface.is-direct-codex + .project-chat-runtime-detail-toggle:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.game-workbench-chat + .project-chat-surface.is-direct-codex + .project-chat-runtime-detail-body { + max-height: 220px; + margin: 0; + padding: 10px 12px; + overflow: auto; + border: 1px solid var(--platform-line-soft); + border-radius: 10px; + background: var(--platform-neutral-bg); + color: var(--platform-text-base); + font-family: var(--platform-font-mono, ui-monospace, SFMono-Regular, monospace); + font-size: 12px; + line-height: 1.55; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + .game-workbench-layout--design .project-chat-topbar-status > span:last-child, .game-workbench-chat .project-chat-surface.is-direct-codex 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 index 2787dab0c..0340b32bb 100644 --- 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 @@ -1,6 +1,7 @@ import type { UIEventHandler } from 'react'; import type { Ref } from 'react'; import { + useCallback, useEffect, useImperativeHandle, useMemo, @@ -10,9 +11,11 @@ import { import { AGENT_CHAT_SCROLL_BOTTOM_THRESHOLD } from '../../../app/constants'; import { claimInitialTurnForPage } from '../../../app/initialTurnClaims'; +import { resolveTauriInvoke } from '../../../app/tauri'; import type { PendingUiConfirmation } from '../../../app/types'; import { projectNameFromPath } from '../../../features/agent-runtime'; import { directCodexUserItemFromContent } from '../../../features/project-workspace/resourceReferences'; +import { readAgentRuntimeErrorDetailByRef } from '../../../services/agentRuntimeErrorDetail'; import { type ApprovalMode, approvalModeLabel } from '../approvalMode'; import { ApprovalModeDialog } from '../ApprovalModeDialog'; import { DirectProjectChatHeader } from './components/DirectProjectChatHeader/DirectProjectChatHeader'; @@ -117,16 +120,35 @@ export function DirectProjectChatView({ const messagesRef = useRef(null); const shouldFollowLatestRef = useRef(true); const [runtimeNotice, setRuntimeNotice] = useState(''); + const [runtimeDetailRef, setRuntimeDetailRef] = useState(null); + const [runtimeDetail, setRuntimeDetail] = useState(''); + const [runtimeDetailOpen, setRuntimeDetailOpen] = useState(false); + const [runtimeDetailLoading, setRuntimeDetailLoading] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); const [approvalOpen, setApprovalOpen] = useState(false); const [approvalMode, setApprovalMode] = useState('strict'); const [approvalNotice, setApprovalNotice] = useState(''); + /** + * 运行态提示出口:提示文案与它指向的诊断引用分开持有。 + * + * 提示换一条(或被清空)就丢掉上一条的正文与展开态:诊断正文永远属于当前提示。 + */ + const handleRuntimeError = useCallback( + (message: string, detailRef?: string | null) => { + setRuntimeNotice(message); + setRuntimeDetailRef(detailRef ?? null); + setRuntimeDetail(''); + setRuntimeDetailOpen(false); + setRuntimeDetailLoading(false); + }, + [], + ); const chat = useDirectProjectChatController({ assets, enabled: Boolean(projectPath), ensureConversationReadAllowed, ensureConversationWriteAllowed, - onRuntimeError: setRuntimeNotice, + onRuntimeError: handleRuntimeError, projectId, projectPath, refreshManifest: refresh, @@ -232,6 +254,37 @@ export function DirectProjectChatView({ } }; + /** + * 展开/收起运行诊断正文。 + * + * 正文按需读取:读不到(sidecar 被删、权限拒绝、命令缺失)时只显示这句话,提示文案本身 + * 不受影响——诊断是附加信息,读取失败不能升级成新的用户可见错误。 + */ + const toggleRuntimeDetail = useCallback(async () => { + if (runtimeDetailOpen) { + setRuntimeDetailOpen(false); + return; + } + setRuntimeDetailOpen(true); + if (runtimeDetail || !runtimeDetailRef) return; + const invoke = resolveTauriInvoke(); + if (!invoke || !projectPath) { + setRuntimeDetail('诊断详情需要在客户端内、且在项目打开时读取。'); + return; + } + setRuntimeDetailLoading(true); + try { + const body = await readAgentRuntimeErrorDetailByRef( + invoke, + projectPath, + runtimeDetailRef, + ); + setRuntimeDetail(body || '这条失败没有可读取的诊断正文。'); + } finally { + setRuntimeDetailLoading(false); + } + }, [projectPath, runtimeDetail, runtimeDetailOpen, runtimeDetailRef]); + return (
setSettingsOpen(true)} onRequestGamePublish={onRequestGamePublish} /> + {runtimeNotice && runtimeDetailRef ? ( +
+ + {runtimeDetailOpen ? ( +
+                {runtimeDetailLoading
+                  ? '正在读取诊断…'
+                  : runtimeDetail || '这条失败没有可读取的诊断正文。'}
+              
+ ) : null} +
+ ) : null} void; + /** + * 运行态提示出口。 + * + * 第二个参数是这个提示指向的项目诊断引用(没有则为 `null`):面板据此渲染 + * 「查看详情」入口按需读取脱敏正文,react 侧不把正文拼进提示文案。 + */ + onRuntimeError: (message: string, detailRef?: string | null) => void; /** 项目身份:只有 manifest projectId 就绪时才允许发起回合。 */ projectId: string | null; projectPath: string | null; @@ -217,15 +218,8 @@ export function useDirectProjectChatController({ historyOldestItemIdRef.current = null; }, [projectPath]); - useEffect(() => { - if (!enabled || (!turnBusy && !currentTurnRunning)) return; - const timer = window.setInterval(() => { - void requestPlatformSessionRefresh().catch(() => { - // 回合本身会用原始鉴权错误收尾;保活不能把它替换成无关的后台错误。 - }); - }, DIRECT_CODEX_SESSION_KEEPALIVE_MS); - return () => window.clearInterval(timer); - }, [enabled, turnBusy, currentTurnRunning]); + // 回合期间的平台会话保活由 Rust 持有:Direct 回合登记时会启动保活任务,回合结束即停止。 + // 渲染层不再按固定间隔触发续期,只在收到 401 时用 `withDirectCodexSessionRefresh` 补一次。 useEffect(() => { if (!enabled) { @@ -609,27 +603,21 @@ export function useDirectProjectChatController({ ); void captureAgentRuntimeError(error, DIRECT_CODEX_AGENT_ID); const message = error instanceof Error ? error.message : String(error); - let persistedDetail = ''; - const detailRef = message.match( - /详情:(\.agent\/runtime\/errors\/[^\s;]+)/, - )?.[1]; - if (detailRef) { - try { - persistedDetail = await invoke( - 'read_agent_runtime_error_detail', - { projectPath: nextProjectPath, detailRef }, - ); - } catch { - persistedDetail = ''; - } - } + /** + * 失败文案只展示安全 `publicText`。 + * + * Native 在文案末尾给了 `;详情:`:正文由聊天面板的「查看详情」入口 + * 按需读取(`read_agent_runtime_error_detail` 会再做一次目录校验与脱敏),这里不再 + * 预读正文,历史因此只留可安全展示的部分与引用。 + */ const visibleMessage = projectRuntimeVisibleError( - persistedDetail ? `${message}\n\n${persistedDetail}` : message, + message, '陶泥儿智能创作', true, ); + const detailRef = extractAgentRuntimeErrorDetailRef(message); if (projectPathRef.current !== nextProjectPath) return; - onRuntimeError(visibleMessage); + onRuntimeError(visibleMessage, detailRef); appendLocalMessage({ role: 'assistant', text: visibleMessage, diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectThreadChatSubscription.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectThreadChatSubscription.ts index 463954657..3e9f0020d 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectThreadChatSubscription.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectThreadChatSubscription.ts @@ -92,6 +92,17 @@ export function useDirectThreadChatSubscription({ let consumeAgain = false; let notifyBeforeSubscription = false; + function releaseSubscription(nextSubscriptionId: string | null) { + const normalized = nextSubscriptionId?.trim(); + if (!normalized) return; + void invoke('unsubscribe_direct_project_thread', { + subscriptionId: normalized, + }).catch(() => { + // cleanup 失败不能重试或改写业务终态,但必须留在诊断日志里。 + console.warn('[AGC] 取消 DirectProject 线程订阅失败', normalized); + }); + } + async function consume() { if (!subscriptionId || disposed) return; if (consuming) { @@ -136,6 +147,7 @@ export function useDirectThreadChatSubscription({ { projectPath }, ); if (disposed) { + releaseSubscription(result.subscriptionId); anchorGate.settle(null); return; } @@ -173,6 +185,7 @@ export function useDirectThreadChatSubscription({ return () => { disposed = true; cleanup?.(); + releaseSubscription(subscriptionId); anchorGate.settle(null); }; }, [enabled, projectPath]); diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexSession.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexSession.ts index 458a39e41..773f4c054 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexSession.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexSession.ts @@ -2,23 +2,16 @@ import { currentPlatformSessionGeneration, requestPlatformSessionRefresh, } from '../../../../services/platformSession'; +import { isPlatformAuthenticationRequiredError } from '../../../../services/platformSessionRetry'; /** - * 平台 access token 很短命。DirectProject 一个回合可能横跨图片生成、构建和浏览器验证, - * 所以回合运行期间由客户端保持原生会话新鲜;`platformSession.ts` 的 singleflight - * 会把这里的刷新与 401 触发的刷新合并成同一次请求。 + * 平台 access token 很短命,DirectProject 一个回合可能横跨图片生成、构建和浏览器验证。 + * + * 回合期间的会话保活由 Rust 持有:Direct 回合登记时启动保活任务(按签发时间自助续期), + * 回合结束即停止。渲染层只保留下面这条「遇到 401 补一次再重试」的路径。 */ -export const DIRECT_CODEX_SESSION_KEEPALIVE_MS = 5 * 60 * 1000; - -function isDirectCodexAuthenticationRequired(error: unknown) { - const message = error instanceof Error ? error.message : String(error); - return ( - message.includes('authentication-required') || - message.includes('codex-app-server-error:unauthorized') || - /kind=codex-app-server-unauthorized(?=\s|$)/.test(message) || - message.includes('登录已失效') - ); -} +// 登录失效的稳定分类只有一份:`platformSessionRetry` 同时服务 Rust typed command 与 +// Direct Codex app-server 错误,避免两处判定口径漂移。 /** * 跑一轮 DirectProject 请求:只有 401/登录失效才刷新会话重试一次,其它错误原样抛出。 @@ -33,7 +26,7 @@ export async function withDirectCodexSessionRefresh( try { return await operation(); } catch (error) { - if (!isDirectCodexAuthenticationRequired(error)) throw error; + if (!isPlatformAuthenticationRequiredError(error)) throw error; if (currentPlatformSessionGeneration() !== generation) throw error; const refresh = await requestPlatformSessionRefresh(); if (refresh.status === 'failed') throw error; 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 3b03ac6b0..dc80c56bc 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 @@ -153,6 +153,7 @@ import { import { createResourceCanvasAssetGenerationQueue, mergeResourceCanvasAssetGenerationTasksWithRecords, + RESOURCE_CANVAS_ASSET_GENERATION_CHANGED_EVENT, type ResourceCanvasAssetGenerationQueue, type ResourceCanvasAssetGenerationSettlement, } from '../../features/resource-canvas/resourceCanvasAssetGenerationQueue'; @@ -284,6 +285,10 @@ import { requestPlatformSessionRefresh, subscribePlatformSessionGeneration, } from '../../services/platformSession'; +import { + canSubscribeTauriEvents, + subscribeTauriEvent, +} from '../../services/tauriEventSubscription'; import UiEditorPage from '../ui-editor'; import type { UiEditorStepId } from '../ui-editor/model'; import type { ApprovalMode } from './approvalMode'; @@ -321,6 +326,7 @@ import { resourceBookShowsAllCategories, type ResourceBookState, type ResourceBookTarget, + sameResourceIdSelection, } from './resourceBookModel'; import { DEFAULT_RESOURCE_BOOK_VIEWPORT, @@ -1669,6 +1675,12 @@ function ResourceBookScene({ }; return (
{ + const next = selectLayersInsideMarquee({ marquee, currentPoint, layers: marqueeHitTestLayersRef.current, viewport: resourceCanvasSceneViewportRef.current, - }), - ); + }); + return sameResourceIdSelection(current, next) ? current : next; + }); return; } const pan = resourceCanvasPanRef.current; @@ -8828,6 +8843,20 @@ export default function ProjectDevelopmentView({ projectPath: () => resourceAssetGenerationContextRef.current.projectPath, refreshPlatformSession: withPlatformSessionRefresh, + subscribe: canSubscribeTauriEvents() + ? async (projectPath, taskId, onChanged) => + subscribeTauriEvent<{ projectPath?: string; taskId?: string }>( + RESOURCE_CANVAS_ASSET_GENERATION_CHANGED_EVENT, + (event) => { + if ( + event.payload?.projectPath === projectPath && + event.payload?.taskId === taskId + ) { + onChanged(); + } + }, + ) + : undefined, listTasks: () => resourceAssetGenerationTasksRef.current.filter( (task) => diff --git a/apps/ai-game-creator-shell/src/view/project-development/resourceBookModel.ts b/apps/ai-game-creator-shell/src/view/project-development/resourceBookModel.ts index 1cc393f0c..59e6b8497 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/resourceBookModel.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/resourceBookModel.ts @@ -144,3 +144,25 @@ export function resourceBookCategoryCardPresentation( export function resourceBookShowsExitingCards(state: ResourceBookState) { return state.view === 'main' && state.phase === 'returning-main'; } + +/** + * 两份选中集合是否等价(忽略顺序)。 + * + * 框选(marquee)在每次 pointermove 都会重算命中集合,但指针在同一片空白里移动时集合往往 + * 没变。此时必须让调用方复用**上一份数组身份**:`selectedResourceIds` 参与若干 memo 与 + * 面板 props 的判据,每帧换一个新数组会让这些计算跟着每帧重跑,卡片多时表现为「多选后 + * 卡顿」。 + */ +export function sameResourceIdSelection( + current: readonly string[], + next: readonly string[], +) { + if (current === next) return true; + if (current.length !== next.length) return false; + const currentIds = new Set(current); + if (currentIds.size !== current.length) { + // 传入集合本身有重复项时退回逐项比较,避免把「重复但长度相同」误判成等价。 + return current.every((resourceId, index) => resourceId === next[index]); + } + return next.every((resourceId) => currentIds.has(resourceId)); +} diff --git a/apps/ai-game-creator-shell/tests/ErrorReportDialog.test.tsx b/apps/ai-game-creator-shell/tests/ErrorReportDialog.test.tsx index cf8e05694..25776761c 100644 --- a/apps/ai-game-creator-shell/tests/ErrorReportDialog.test.tsx +++ b/apps/ai-game-creator-shell/tests/ErrorReportDialog.test.tsx @@ -12,7 +12,6 @@ const mockState = vi.hoisted(() => ({ vi.mock('../src/services/errorReporting', () => ({ ackClientErrorEventsWithRetry: vi.fn(), getPendingClientErrorEvents: mockState.getPending, - getStableErrorReportSubmissionId: vi.fn(), readApplicationDiagnosticLogs: mockState.readLogs, submitErrorReportBatch: vi.fn(), })); diff --git a/apps/ai-game-creator-shell/tests/accountHost.test.ts b/apps/ai-game-creator-shell/tests/accountHost.test.ts new file mode 100644 index 000000000..42f92ba60 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/accountHost.test.ts @@ -0,0 +1,103 @@ +// @vitest-environment jsdom +/** + * 账户与钱包 typed façade 的边界。 + * + * 渲染层只提交结构化业务参数;平台 origin、Bearer、envelope 与错误分类由 Rust 持有。 + * 这里验证 IPC 载荷、登录失效时「续期后重试一次」,以及业务失败不重放。 + */ +import { beforeEach, expect, test, vi } from 'vitest'; + +const invoke = vi.fn(); +const platformSession = vi.hoisted(() => ({ + generation: 1, + refresh: vi.fn(), +})); + +// 与仓库其它 App 内调用方同口径:通过 `window.__TAURI__.core.invoke` 解析原生桥。 +window.__TAURI__ = { + core: { invoke: (...args: unknown[]) => invoke(...args) }, +} as never; +vi.mock('../src/services/platformSession', () => ({ + currentPlatformSessionGeneration: () => platformSession.generation, + requestPlatformSessionRefresh: (...args: unknown[]) => + platformSession.refresh(...args), +})); + +import { + confirmClientWechatProfileRechargeOrder, + createClientProfileRechargeOrder, + getClientProfileRechargeCenter, + getClientProfileWalletLedger, + redeemClientProfileRewardCode, +} from '../src/services/accountHost'; + +beforeEach(() => { + invoke.mockReset(); + platformSession.generation = 1; + platformSession.refresh.mockReset(); + platformSession.refresh.mockResolvedValue({ + status: 'refreshed', + generation: 1, + user: { id: 'user-1' }, + }); +}); + +test('账户读取只提交结构化参数,不携带 token 或远端地址', async () => { + invoke.mockResolvedValue({ walletBalance: 1 }); + + await getClientProfileRechargeCenter(); + await getClientProfileWalletLedger(); + await createClientProfileRechargeOrder('product-1'); + await confirmClientWechatProfileRechargeOrder('order-1'); + await redeemClientProfileRewardCode('CODE-1'); + + expect(invoke.mock.calls).toEqual([ + ['read_profile_recharge_center', undefined], + ['read_profile_wallet_ledger', undefined], + ['create_profile_recharge_order', { productId: 'product-1' }], + ['confirm_wechat_profile_recharge_order', { orderId: 'order-1' }], + ['redeem_profile_reward_code', { code: 'CODE-1' }], + ]); + const serialized = JSON.stringify(invoke.mock.calls); + expect(serialized).not.toContain('token'); + expect(serialized).not.toContain('Authorization'); + expect(serialized).not.toContain('http'); +}); + +test('登录失效时续期一次并重放,其它业务失败原样抛出', async () => { + invoke + .mockRejectedValueOnce(new Error('authentication-required: 请先登录')) + .mockResolvedValueOnce({ entries: [] }); + + await expect(getClientProfileWalletLedger()).resolves.toEqual({ + entries: [], + }); + expect(platformSession.refresh).toHaveBeenCalledTimes(1); + expect(invoke).toHaveBeenCalledTimes(2); + + invoke.mockReset(); + platformSession.refresh.mockClear(); + invoke.mockRejectedValueOnce(new Error('读取泥点账单失败:余额服务不可用')); + + await expect(getClientProfileWalletLedger()).rejects.toThrow( + '读取泥点账单失败:余额服务不可用', + ); + expect(platformSession.refresh).not.toHaveBeenCalled(); + expect(invoke).toHaveBeenCalledTimes(1); +}); + +test('续期期间换号时不重放旧身份的付费请求', async () => { + invoke.mockRejectedValueOnce( + new Error('authentication-required: 登录已过期'), + ); + // 续期在飞期间另一个账号完成登录:代际已经推进,旧身份的续期结果不算数。 + platformSession.refresh.mockImplementationOnce(async () => { + platformSession.generation = 2; + return { status: 'refreshed', generation: 1, user: { id: 'user-1' } }; + }); + + await expect(createClientProfileRechargeOrder('product-1')).rejects.toThrow( + '登录账号已变化,原请求已停止', + ); + expect(invoke).toHaveBeenCalledTimes(1); +}); diff --git a/apps/ai-game-creator-shell/tests/agentRuntimeErrorDetail.test.ts b/apps/ai-game-creator-shell/tests/agentRuntimeErrorDetail.test.ts new file mode 100644 index 000000000..72ceb533e --- /dev/null +++ b/apps/ai-game-creator-shell/tests/agentRuntimeErrorDetail.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + AGENT_RUNTIME_ERROR_DETAIL_MARKER, + extractAgentRuntimeErrorDetailRef, + readAgentRuntimeErrorDetailByRef, +} from '../src/services/agentRuntimeErrorDetail'; + +/** + * Rust 侧 `record_direct_codex_turn_failure` 现产出的失败文案形状。 + * + * 这里的形状由 `direct_codex_failure_text_keeps_the_detail_ref_marker_for_the_renderer` + * 在同一份文案上钉住;两边同时改才算一次协议变更。 + */ +const RUST_FAILURE_TEXT = + 'direct-codex-failure:v2 stage=code-generation code=runtime-failure retryable=false ' + + 'summary=构建失败:入口模块语法错误;建议:修复 game/src 下的语法错误后重新发送需求;' + + '已保存脱敏项目诊断;详情:.agent/runtime/errors/0192f0a1-7f4c-7c31-9d5c-4a51c6b4b9e2.json'; + +describe('统一错误诊断引用解析', () => { + it('从真实失败文案里取出诊断引用', () => { + expect(extractAgentRuntimeErrorDetailRef(RUST_FAILURE_TEXT)).toBe( + '.agent/runtime/errors/0192f0a1-7f4c-7c31-9d5c-4a51c6b4b9e2.json', + ); + }); + + it('标记与引用必须同时成立,缺一不解析', () => { + expect(AGENT_RUNTIME_ERROR_DETAIL_MARKER).toBe('详情:'); + expect( + extractAgentRuntimeErrorDetailRef( + 'summary=失败;建议:重试;详情:.agent/runtime/errors/turn-1', + ), + ).toBeNull(); + expect( + extractAgentRuntimeErrorDetailRef( + 'summary=构建失败;建议:修复后重试;已保存脱敏项目诊断', + ), + ).toBeNull(); + expect( + extractAgentRuntimeErrorDetailRef( + '详情:.agent/runtime/errors/turn-1.json;建议:重试', + ), + ).toBe('.agent/runtime/errors/turn-1.json'); + }); + + it('读诊断失败只回落安全文案,不抛出', async () => { + const declined = vi.fn(async () => { + throw new Error('错误诊断引用格式无效'); + }); + await expect( + readAgentRuntimeErrorDetailByRef( + declined, + 'F:/project', + '.agent/runtime/errors/error-1700000000000-1.json', + ), + ).resolves.toBe(''); + + const unavailable = vi.fn(async () => { + throw new Error('命令未注册'); + }); + // 没有引用时连命令都不发:这里用空串代表面板拿不到引用的情形。 + await expect( + readAgentRuntimeErrorDetailByRef(unavailable, 'F:/project', ''), + ).resolves.toBe(''); + expect(unavailable).not.toHaveBeenCalled(); + }); + + it('按项目路径与引用调用只读命令', async () => { + const invoke = vi.fn(async () => '{"code":"runtime-failure"}'); + await expect( + readAgentRuntimeErrorDetailByRef( + invoke, + 'F:/project', + extractAgentRuntimeErrorDetailRef(RUST_FAILURE_TEXT) ?? '', + ), + ).resolves.toBe('{"code":"runtime-failure"}'); + expect(invoke).toHaveBeenCalledWith('read_agent_runtime_error_detail', { + projectPath: 'F:/project', + detailRef: + '.agent/runtime/errors/0192f0a1-7f4c-7c31-9d5c-4a51c6b4b9e2.json', + }); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts b/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts index ea2a7e96b..a372ce293 100644 --- a/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts +++ b/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts @@ -431,6 +431,36 @@ describe('Agent Runtime Provider 状态投影', () => { ); }); + test('直连智能创作的 v2 失败文案保留阶段诊断并忽略末尾诊断引用', () => { + // 当前 Native 运行时下发 v2:在 v1 之上多了 `code=`,末尾拼上诊断引用。 + expect( + projectRuntimeVisibleError( + 'direct-codex-failure:v2 stage=code-generation code=runtime-failure retryable=false summary=入口模块语法错误;建议:修复 game/src 下的语法错误后重新发送需求;已保存脱敏项目诊断;详情:.agent/runtime/errors/error-1700000000000-1.json', + '陶泥儿智能创作', + true, + ), + ).toBe( + '陶泥儿智能创作:代码生成失败:入口模块语法错误。修复 game/src 下的语法错误后重新发送需求', + ); + expect( + projectRuntimeVisibleError( + 'direct-codex-failure:v2 stage=browser-validation code=idle-timeout retryable=true summary=真实试玩等待超时;建议:稍后重试或缩小任务范围;未能保存项目诊断', + '陶泥儿智能创作', + true, + ), + ).toBe( + '陶泥儿智能创作:真实试玩未通过:真实试玩等待超时。稍后重试或缩小任务范围(可直接重试)', + ); + // 脱敏口径与 v1 相同:带未脱敏凭据的 v2 文案仍回落通用兜底,不把私密内容透出来。 + expect( + projectRuntimeVisibleError( + 'direct-codex-failure:v2 stage=request code=runtime-failure retryable=true summary=请求失败 authorization=Bearer secret;建议:请检查已隐藏配置并稍后重试;已保存脱敏项目诊断;详情:.agent/runtime/errors/error-1-1.json', + '陶泥儿智能创作', + true, + ), + ).toBe('陶泥儿智能创作 执行失败,请稍后重试'); + }); + test('直连平台错误保留 HTTP 诊断字段但只显示已脱敏的敏感值', () => { const safe = projectRuntimeVisibleError( '陶泥儿美术包生成失败(规范图):请求平台图片生成失败:HTTP 401;code=invalid-token;field=authorization;message=token=[redacted-secret];detail=登录态已失效', diff --git a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts index 940647757..a12fc9b44 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts @@ -1,16 +1,9 @@ import { afterEach } from 'vitest'; -import { setStoredAuthAccessToken } from '../../src/services/clientAuth'; import { AGC_DEVELOPMENT_API_BASE_URL } from '../../src/services/clientHttp'; import { - beginPlatformSessionClearTransition, - beginPlatformSessionTransition, - clearCommittedPlatformSession, - commitAuthenticatedPlatformSession, - currentPlatformNativeIdentityGenerationForTests, currentPlatformSessionGeneration, - requestPlatformSessionRefresh, - resetPlatformSessionStateForTests, + resetPlatformSessionForTests, } from '../../src/services/platformSession'; import { act, @@ -26,1576 +19,347 @@ import { waitFor, } from './harness'; +type AuthEventBridge = { + listeners: Map void>; + emit: (event: string, payload: unknown) => void; +}; + +/** + * 注入 Tauri 事件替身。 + * + * jsdom 没有 `__TAURI_INTERNALS__`,`subscribeTauriEvent` 会退回 + * `window.__TAURI__.event.listen`,所以这里按同一入口登记回调。 + */ +function installTauriBridge( + invoke: (command: string, args?: Record) => Promise, + options: { withEvents?: boolean } = {}, +): AuthEventBridge { + const listeners = new Map void>(); + window.__TAURI__ = { + core: { invoke }, + ...(options.withEvents + ? { + event: { + listen: async ( + event: string, + handler: (payload: { payload: unknown }) => void, + ) => { + listeners.set(event, handler); + return () => listeners.delete(event); + }, + }, + } + : {}), + } as never; + return { + listeners, + emit: (event, payload) => { + const handler = listeners.get(event); + if (handler) handler({ payload }); + }, + }; +} + +function renderAuthSurface( + invoke: (command: string, args?: Record) => Promise, + options: { withEvents?: boolean } = {}, +) { + const bridge = installTauriBridge(invoke, options); + render( + React.createElement(AuthenticatedClient, null, ({ user, logout }) => + React.createElement( + 'main', + { 'aria-label': '已登录' }, + React.createElement('span', null, user.displayName), + React.createElement( + 'button', + { type: 'button', onClick: logout }, + '退出', + ), + ), + ), + ); + return bridge; +} + +async function loginWithCode() { + await screen.findByRole('main', { name: '登录' }); + fireEvent.change(screen.getByLabelText('手机号'), { + target: { value: '13800000000' }, + }); + fireEvent.change(screen.getByLabelText('验证码'), { + target: { value: '123456' }, + }); + fireEvent.click(screen.getByRole('button', { name: '登录' })); +} + export function registerAuthTests() { afterEach(() => { - resetPlatformSessionStateForTests(); + resetPlatformSessionForTests(); delete window.__TAURI__; }); - it('leaves startup loading with an actionable retry after auth service timeout', async () => { - vi.useFakeTimers(); - let releaseRefresh: (() => void) | null = null; - vi.spyOn(globalThis, 'fetch').mockImplementation( - async (input: RequestInfo | URL) => { - if (String(input) === '/api/auth/refresh') { - return new Promise((resolve) => { - releaseRefresh = () => resolve(new Response('', { status: 401 })); - }); - } - throw new Error(`unexpected fetch ${String(input)}`); - }, - ); - - render( - React.createElement(AuthenticatedClient, null, () => - React.createElement('main', { 'aria-label': '已登录' }), - ), - ); - expect(screen.getByRole('main', { name: '登录状态检查' })).not.toBeNull(); - - await act(async () => { - await vi.advanceTimersByTimeAsync(15_000); + it('leaves startup loading with an actionable retry after auth check timeout', async () => { + renderAuthSurface(async (command: string) => { + if (command === 'read_client_auth_state') { + return await new Promise(() => {}); + } + return null; }); - expect(screen.getByRole('main', { name: '登录' })).not.toBeNull(); - expect(screen.getByRole('alert')).not.toBeNull(); + expect( + await screen.findByText( + '检查登录状态超时,请检查服务器地址和网络后重试', + undefined, + { + timeout: 20_000, + }, + ), + ).not.toBeNull(); expect( screen.getByRole('button', { name: '重试登录状态检查' }), - ).not.toBeNull(); - releaseRefresh?.(); - await Promise.resolve(); - vi.useRealTimers(); - }); + ).toHaveProperty('disabled', false); + }, 30_000); it('renders the unauthenticated client with the shared light platform theme and product image', async () => { - vi.spyOn(globalThis, 'fetch').mockImplementation( - async (input: RequestInfo | URL) => { - if (String(input) === '/api/auth/refresh') { - return new Response('', { status: 401 }); - } - throw new Error(`unexpected fetch ${String(input)}`); - }, - ); + renderAuthSurface(async () => ({ status: 'unauthenticated' })); - render( - React.createElement(AuthenticatedClient, null, () => - React.createElement('main', { 'aria-label': '已登录' }), - ), - ); - - const login = await screen.findByRole('main', { name: '登录' }); - expect(login.className).toContain('platform-theme'); - expect(login.className).toContain('platform-theme--light'); - expect(screen.getByRole('img', { name: '陶泥儿' })).not.toBeNull(); - expect( - screen.getByRole('heading', { name: '登录陶泥儿 GameAgent' }), - ).not.toBeNull(); + const loginPanel = await screen.findByRole('main', { name: '登录' }); + expect(loginPanel.className).toContain('platform-theme--light'); + const logo = loginPanel.querySelector('img'); + expect(logo?.getAttribute('alt')).toBe('陶泥儿'); + expect(logo?.getAttribute('src')).toContain('taonier-product-ip.png'); }); - it('keeps the client at login when the native platform session cannot be installed', async () => { - window.__TAURI__ = { - core: { - invoke: vi.fn(async (command: string) => { - if (command === 'install_platform_account_session') { - throw new Error('runner unavailable'); - } - return null; - }), - }, - }; - vi.spyOn(globalThis, 'fetch').mockImplementation( - async (input: RequestInfo | URL) => { - const url = String(input); - if (url === '/api/auth/refresh') { - return new Response('', { status: 401 }); - } - if (url === '/api/auth/phone/login') { - return new Response( - JSON.stringify({ - token: 'phone-token', - user: { ...testAuthUser, loginMethod: 'phone' }, - created: false, - referral: null, - }), - { status: 200 }, - ); - } - throw new Error(`unexpected fetch ${url}`); - }, - ); - - render( - React.createElement(AuthenticatedClient, null, ({ user }) => - React.createElement('main', { 'aria-label': '已登录' }, user.id), - ), - ); - await screen.findByRole('main', { name: '登录' }); - fireEvent.change(screen.getByLabelText('手机号'), { - target: { value: '13800000000' }, - }); - fireEvent.change(screen.getByLabelText('验证码'), { - target: { value: '123456' }, - }); - fireEvent.click(screen.getByRole('button', { name: '登录' })); - - expect(await screen.findByText('runner unavailable')).not.toBeNull(); - expect(screen.queryByLabelText('已登录')).toBeNull(); - expect( - window.localStorage.getItem('genarrative.auth.access-token.v1'), - ).toBe(null); - }); - - it('keeps login HTTP and native commit bound to the origin frozen before the request', async () => { - const invoke = vi.fn(async () => null); - window.__TAURI__ = { core: { invoke } }; - let resolveLogin: ((response: Response) => void) | null = null; - vi.spyOn(globalThis, 'fetch').mockImplementation( - async (input: RequestInfo | URL) => { - const url = String(input); - if (url === '/api/auth/refresh') { - return new Response('', { status: 401 }); - } - if (url === '/api/auth/phone/login') { - return await new Promise((resolve) => { - resolveLogin = resolve; - }); - } - throw new Error(`unexpected fetch ${url}`); - }, - ); - - render( - React.createElement(AuthenticatedClient, null, ({ user }) => - React.createElement('main', { 'aria-label': '已登录' }, user.id), - ), - ); - await screen.findByRole('main', { name: '登录' }); - fireEvent.change(screen.getByLabelText('手机号'), { - target: { value: '13800000000' }, - }); - fireEvent.change(screen.getByLabelText('验证码'), { - target: { value: '123456' }, - }); - fireEvent.click(screen.getByRole('button', { name: '登录' })); - await waitFor(() => expect(resolveLogin).not.toBeNull()); - expect(screen.getByLabelText('服务器')).not.toBeNull(); - - window.localStorage.setItem( - 'genarrative.client.server-selection.v1', - JSON.stringify({ preset: 'release', customBaseUrl: '' }), - ); - resolveLogin?.( - new Response( - JSON.stringify({ - token: 'origin-a-token', - user: { ...testAuthUser, loginMethod: 'phone' }, - created: false, - referral: null, - }), - { status: 200 }, - ), - ); - - expect(await screen.findByRole('main', { name: '已登录' })).not.toBeNull(); - expect(invoke).toHaveBeenLastCalledWith( - 'install_platform_account_session', - expect.objectContaining({ - accessToken: 'origin-a-token', - apiBaseUrl: AGC_DEVELOPMENT_API_BASE_URL, - }), - ); - expect(invoke).not.toHaveBeenCalledWith( - 'install_platform_account_session', - expect.objectContaining({ apiBaseUrl: 'https://www.genarrative.world' }), - ); - }); - - it('reserves install and clear writes above the native floor after renderer state resets', async () => { - let nativeFloor = { identityGeneration: 57, revision: 57 }; - const mutations: Array<{ - command: string; - identityGeneration: number; - revision: number; - }> = []; - const invoke = vi.fn(async (command: string, payload?: unknown) => { - if (command === 'read_platform_account_session_state') { - return nativeFloor; - } - if ( - command === 'install_platform_account_session' || - command === 'clear_platform_account_session' - ) { - const write = payload as - | { identityGeneration?: number; revision?: number } - | undefined; - if ( - write?.identityGeneration === undefined || - write?.revision === undefined - ) { - throw new Error('missing native session write identity'); - } - mutations.push({ - command, - identityGeneration: write.identityGeneration, - revision: write.revision, - }); - nativeFloor = { - identityGeneration: write.identityGeneration, - revision: write.revision, - }; - } - return null; - }); - window.__TAURI__ = { core: { invoke } }; - - resetPlatformSessionStateForTests(); - const installFloor = nativeFloor.revision; - const installIdentityFloor = nativeFloor.identityGeneration; - const loginGeneration = beginPlatformSessionTransition(); - setStoredAuthAccessToken('renderer-reload-token'); - await commitAuthenticatedPlatformSession(testAuthUser, loginGeneration); - expect(mutations[0]?.command).toBe('install_platform_account_session'); - expect(mutations[0]?.identityGeneration).toBeGreaterThan( - installIdentityFloor, - ); - expect(mutations[0]?.revision).toBeGreaterThan(installFloor); - - resetPlatformSessionStateForTests(); - const clearFloor = nativeFloor.revision; - const clearIdentityFloor = nativeFloor.identityGeneration; - const logoutGeneration = beginPlatformSessionClearTransition(); - await clearCommittedPlatformSession(logoutGeneration); - expect(mutations[1]?.command).toBe('clear_platform_account_session'); - expect(mutations[1]?.revision).toBeGreaterThan(clearFloor); - expect(mutations[1]?.identityGeneration).toBeGreaterThan( - clearIdentityFloor, - ); - expect( - invoke.mock.calls.filter( - ([command]) => command === 'read_platform_account_session_state', - ), - ).toHaveLength(2); - }); - - it('retries the native session write floor read after a transient failure', async () => { - let floorReads = 0; - const invoke = vi.fn(async (command: string, payload?: unknown) => { - if (command === 'read_platform_account_session_state') { - floorReads += 1; - if (floorReads === 1) { - throw new Error('runner not ready'); - } - return { identityGeneration: 12, revision: 12 }; - } - if ( - command === 'install_platform_account_session' || - command === 'clear_platform_account_session' - ) { - expect(payload).toEqual( - expect.objectContaining({ - identityGeneration: expect.any(Number), - revision: expect.any(Number), - }), - ); - } - return null; - }); - window.__TAURI__ = { core: { invoke } }; - const firstGeneration = beginPlatformSessionTransition(); - setStoredAuthAccessToken('retry-floor-token'); - - await expect( - commitAuthenticatedPlatformSession(testAuthUser, firstGeneration), - ).rejects.toThrow('runner not ready'); - - // 瞬时读取失败不能被缓存成永久失败:第二次登录必须重新读取并成功。 - const secondGeneration = beginPlatformSessionTransition(); - setStoredAuthAccessToken('retry-floor-token'); - await expect( - commitAuthenticatedPlatformSession(testAuthUser, secondGeneration), - ).resolves.toEqual(expect.any(Number)); - expect(floorReads).toBeGreaterThan(1); - expect(invoke).toHaveBeenLastCalledWith( - 'install_platform_account_session', - expect.objectContaining({ - userId: testAuthUser.id, - accessToken: 'retry-floor-token', - }), - ); - }); - - it('does not let a stalled native install wedge the next login', async () => { - vi.useFakeTimers(); - const installedTokens: string[] = []; - let releaseStalledInstall: (() => void) | null = null; - const invoke = vi.fn(async (command: string, payload?: unknown) => { - if (command === 'install_platform_account_session') { - installedTokens.push( - String((payload as { accessToken?: string })?.accessToken), - ); - if (installedTokens.length === 1) { - await new Promise((resolve) => { - releaseStalledInstall = resolve; - }); - } - } - return null; - }); - window.__TAURI__ = { core: { invoke } }; - - const stalledGeneration = beginPlatformSessionTransition(); - setStoredAuthAccessToken('stalled-token'); - const stalled = commitAuthenticatedPlatformSession( - testAuthUser, - stalledGeneration, - ); - await vi.advanceTimersByTimeAsync(0); - expect(installedTokens).toEqual(['stalled-token']); - - const retryGeneration = beginPlatformSessionTransition(); - setStoredAuthAccessToken('retry-token'); - const retry = commitAuthenticatedPlatformSession( - testAuthUser, - retryGeneration, - ); - await vi.advanceTimersByTimeAsync(60_000); - - expect(installedTokens).toEqual(['stalled-token', 'retry-token']); - await expect(retry).resolves.toEqual(expect.any(Number)); - expect( - window.localStorage.getItem('genarrative.auth.access-token.v1'), - ).toBe('retry-token'); - - // 迟到的旧 install 只影响它自己:Rust 按 generation 拒绝过期写入, - // 渲染层保持新会话为准。 - releaseStalledInstall?.(); - await expect(stalled).resolves.toBeNull(); - expect( - window.localStorage.getItem('genarrative.auth.access-token.v1'), - ).toBe('retry-token'); - vi.useRealTimers(); - }); - - it('adopts a login whose native session install finishes after the UI fence', async () => { - vi.useFakeTimers(); - let releaseInstall: (() => void) | null = null; + it('logs in with a phone code and never persists a token in the renderer', async () => { const invoke = vi.fn(async (command: string) => { - if (command === 'install_platform_account_session') { - await new Promise((resolve) => { - releaseInstall = resolve; - }); + if (command === 'read_client_auth_state') { + return { status: 'unauthenticated' }; + } + if (command === 'login_client_with_phone_code') { + return { ...testAuthUser, loginMethod: 'phone' }; } return null; }); - window.__TAURI__ = { core: { invoke } }; - vi.spyOn(globalThis, 'fetch').mockImplementation( - async (input: RequestInfo | URL) => { - const url = String(input); - if (url === '/api/auth/refresh') { - return new Response('', { status: 401 }); - } - if (url === '/api/auth/phone/login') { - return new Response( - JSON.stringify({ - token: 'late-install-token', - user: { ...testAuthUser, loginMethod: 'phone' }, - created: false, - referral: null, - }), - { status: 200 }, - ); - } - throw new Error(`unexpected fetch ${url}`); - }, - ); + renderAuthSurface(invoke); + await loginWithCode(); - render( - React.createElement(AuthenticatedClient, null, ({ user }) => - React.createElement('main', { 'aria-label': '已登录' }, user.id), - ), - ); - for (let i = 0; i < 5; i += 1) { - await act(async () => { - await vi.advanceTimersByTimeAsync(0); - }); - } - fireEvent.change(screen.getByLabelText('手机号'), { - target: { value: '13800000000' }, + expect(await screen.findByLabelText('已登录')).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('login_client_with_phone_code', { + apiBaseUrl: AGC_DEVELOPMENT_API_BASE_URL, + phone: '13800000000', + code: '123456', }); - fireEvent.change(screen.getByLabelText('验证码'), { - target: { value: '123456' }, - }); - fireEvent.click(screen.getByRole('button', { name: '登录' })); - - await act(async () => { - await vi.advanceTimersByTimeAsync(45_000); - }); - expect( - screen.getByText('连接本地运行时超时,请重试或重启客户端'), - ).not.toBeNull(); - expect(screen.queryByLabelText('已登录')).toBeNull(); - - // 围栏只放弃等待:本地运行时确实装好会话后,界面必须跟着进工作区。 - releaseInstall?.(); - await act(async () => { - await vi.advanceTimersByTimeAsync(0); - }); - expect(screen.queryByLabelText('已登录')).not.toBeNull(); - expect( - window.localStorage.getItem('genarrative.auth.access-token.v1'), - ).toBe('late-install-token'); - vi.useRealTimers(); - }); - - it('keeps the previous renderer session authoritative when replacement install is rejected', async () => { - const invoke = vi.fn(async (_command: string, payload?: unknown) => { - const userId = (payload as { userId?: string } | undefined)?.userId; - if (userId === 'user-b') { - throw new Error('replacement rejected'); - } - return null; - }); - window.__TAURI__ = { core: { invoke } }; - const accountAGeneration = beginPlatformSessionTransition(); - setStoredAuthAccessToken('account-a-token'); - await commitAuthenticatedPlatformSession(testAuthUser, accountAGeneration); - const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' }; - const accountBGeneration = beginPlatformSessionTransition(); - setStoredAuthAccessToken('account-b-token'); - - await expect( - commitAuthenticatedPlatformSession(accountB, accountBGeneration), - ).rejects.toThrow('replacement rejected'); - expect( - window.localStorage.getItem('genarrative.auth.access-token.v1'), - ).toBe('account-a-token'); - }); - - it('reconciles a stale native install back to the last committed renderer authority', async () => { - let resolveAccountBInstall: (() => void) | null = null; - const invoke = vi.fn(async (command: string, payload?: unknown) => { - const userId = (payload as { userId?: string } | undefined)?.userId; - if ( - command === 'install_platform_account_session' && - userId === 'user-b' - ) { - await new Promise((resolve) => { - resolveAccountBInstall = resolve; - }); - } - return null; - }); - window.__TAURI__ = { core: { invoke } }; - const accountAGeneration = beginPlatformSessionTransition(); - setStoredAuthAccessToken('account-a-token'); - await commitAuthenticatedPlatformSession(testAuthUser, accountAGeneration); - const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' }; - const accountBGeneration = beginPlatformSessionTransition(); - setStoredAuthAccessToken('account-b-token'); - const accountBCommit = commitAuthenticatedPlatformSession( - accountB, - accountBGeneration, - ); - await waitFor(() => expect(resolveAccountBInstall).not.toBeNull()); - - beginPlatformSessionTransition(); - resolveAccountBInstall?.(); - - await expect(accountBCommit).resolves.toBeNull(); - expect(invoke).toHaveBeenLastCalledWith( - 'install_platform_account_session', - expect.objectContaining({ - userId: testAuthUser.id, - accessToken: 'account-a-token', - }), - ); - expect( - window.localStorage.getItem('genarrative.auth.access-token.v1'), - ).toBe('account-a-token'); - }); - - it('keeps a queued logout authoritative after an older native install completes', async () => { - let resolveAccountBInstall: (() => void) | null = null; - const invoke = vi.fn(async (command: string, payload?: unknown) => { - const userId = (payload as { userId?: string } | undefined)?.userId; - if ( - command === 'install_platform_account_session' && - userId === 'user-b' - ) { - await new Promise((resolve) => { - resolveAccountBInstall = resolve; - }); - } - return null; - }); - window.__TAURI__ = { core: { invoke } }; - const accountAGeneration = beginPlatformSessionTransition(); - setStoredAuthAccessToken('account-a-token'); - await commitAuthenticatedPlatformSession(testAuthUser, accountAGeneration); - const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' }; - const accountBGeneration = beginPlatformSessionTransition(); - setStoredAuthAccessToken('account-b-token'); - const accountBCommit = commitAuthenticatedPlatformSession( - accountB, - accountBGeneration, - ); - await waitFor(() => expect(resolveAccountBInstall).not.toBeNull()); - - const logoutGeneration = beginPlatformSessionClearTransition(); - const clear = clearCommittedPlatformSession(logoutGeneration); - resolveAccountBInstall?.(); - - await expect(accountBCommit).resolves.toBeNull(); - await expect(clear).resolves.toBeUndefined(); - expect(invoke).toHaveBeenLastCalledWith( - 'clear_platform_account_session', - expect.objectContaining({ - identityGeneration: expect.any(Number), - revision: expect.any(Number), - }), - ); + // 渲染层不再持久化任何凭据。 + expect(window.localStorage.length).toBe(0); expect( window.localStorage.getItem('genarrative.auth.access-token.v1'), ).toBe(null); }); - it('keeps a newer queued login valid after reconciling an older native install', async () => { - let resolveAccountBInstall: (() => void) | null = null; - const invoke = vi.fn(async (command: string, payload?: unknown) => { - const userId = (payload as { userId?: string } | undefined)?.userId; - if ( - command === 'install_platform_account_session' && - userId === 'user-b' - ) { - await new Promise((resolve) => { - resolveAccountBInstall = resolve; - }); + it('logs in with the password contract on the frozen server origin', async () => { + const invoke = vi.fn(async (command: string) => { + if (command === 'read_client_auth_state') { + return { status: 'unauthenticated' }; + } + if (command === 'login_client_with_password') { + return { ...testAuthUser, loginMethod: 'password' }; } return null; }); - window.__TAURI__ = { core: { invoke } }; - const accountAGeneration = beginPlatformSessionTransition(); - setStoredAuthAccessToken('account-a-token'); - await commitAuthenticatedPlatformSession(testAuthUser, accountAGeneration); - - const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' }; - const accountBGeneration = beginPlatformSessionTransition(); - setStoredAuthAccessToken('account-b-token'); - const accountBCommit = commitAuthenticatedPlatformSession( - accountB, - accountBGeneration, - ); - await waitFor(() => expect(resolveAccountBInstall).not.toBeNull()); - - const accountC = { ...testAuthUser, id: 'user-c', displayName: '用户 C' }; - const accountCGeneration = beginPlatformSessionTransition(); - setStoredAuthAccessToken('account-c-token'); - const accountCCommit = commitAuthenticatedPlatformSession( - accountC, - accountCGeneration, - ); - resolveAccountBInstall?.(); - - await expect(accountBCommit).resolves.toBeNull(); - await expect(accountCCommit).resolves.not.toBeNull(); - expect(invoke).toHaveBeenLastCalledWith( - 'install_platform_account_session', - expect.objectContaining({ - userId: 'user-c', - accessToken: 'account-c-token', - }), - ); - expect( - window.localStorage.getItem('genarrative.auth.access-token.v1'), - ).toBe('account-c-token'); - }); - - it('restores renderer authority when a queued login is stale before it starts', async () => { - const invoke = vi.fn(async () => null); - window.__TAURI__ = { core: { invoke } }; - const accountAGeneration = beginPlatformSessionTransition(); - setStoredAuthAccessToken('account-a-token'); - await commitAuthenticatedPlatformSession(testAuthUser, accountAGeneration); - - const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' }; - const accountBGeneration = beginPlatformSessionTransition(); - setStoredAuthAccessToken('account-b-token'); - const accountBCommit = commitAuthenticatedPlatformSession( - accountB, - accountBGeneration, - ); - beginPlatformSessionTransition(); - - await expect(accountBCommit).resolves.toBeNull(); - expect( - window.localStorage.getItem('genarrative.auth.access-token.v1'), - ).toBe('account-a-token'); - expect( - invoke.mock.calls.filter( - ([command]) => command === 'install_platform_account_session', - ), - ).toHaveLength(1); - }); - - it('singleflights 401 refresh, installs the new token, and rejects a late old-account refresh', async () => { - const invoke = vi.fn(async () => null); - window.__TAURI__ = { core: { invoke } }; - const initialGeneration = beginPlatformSessionTransition(); - setStoredAuthAccessToken('account-a-token'); - await commitAuthenticatedPlatformSession(testAuthUser, initialGeneration); - - let resolveRefresh: ((response: Response) => void) | null = null; - let refreshCalls = 0; - vi.spyOn(globalThis, 'fetch').mockImplementation( - async (input: RequestInfo | URL) => { - const url = String(input); - if (url === '/api/auth/refresh') { - refreshCalls += 1; - return await new Promise((resolve) => { - resolveRefresh = resolve; - }); - } - if (url === '/api/auth/me') { - return new Response( - JSON.stringify({ - user: testAuthUser, - availableLoginMethods: ['password'], - }), - { status: 200 }, - ); - } - throw new Error(`unexpected fetch ${url}`); - }, - ); - - const first = requestPlatformSessionRefresh(testAuthUser.id); - const second = requestPlatformSessionRefresh(testAuthUser.id); - expect(first).toBe(second); - expect(refreshCalls).toBe(1); - - const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' }; - const accountBGeneration = beginPlatformSessionTransition(); - setStoredAuthAccessToken('account-b-token'); - await commitAuthenticatedPlatformSession(accountB, accountBGeneration); - resolveRefresh?.( - new Response(JSON.stringify({ token: 'late-account-a-token' }), { - status: 200, - }), - ); - - await expect(first).resolves.toEqual({ status: 'stale' }); - expect( - window.localStorage.getItem('genarrative.auth.access-token.v1'), - ).toBe('account-b-token'); - expect(invoke).toHaveBeenLastCalledWith( - 'install_platform_account_session', - expect.objectContaining({ - userId: 'user-b', - accessToken: 'account-b-token', - identityGeneration: expect.any(Number), - revision: expect.any(Number), - }), - ); - // 换号必须推进身份代次:旧账号在途 operation 不能拿到新账号凭据。 - const lastInstall = invoke.mock.calls.at(-1)?.[1] as - | { identityGeneration?: number } - | undefined; - expect(lastInstall?.identityGeneration).toBe( - currentPlatformNativeIdentityGenerationForTests(), - ); - }); - - it('treats a late old-account refresh failure as stale after switching accounts', async () => { - const invoke = vi.fn(async () => null); - window.__TAURI__ = { core: { invoke } }; - const initialGeneration = beginPlatformSessionTransition(); - setStoredAuthAccessToken('account-a-token'); - await commitAuthenticatedPlatformSession(testAuthUser, initialGeneration); - - let rejectRefresh: ((error: Error) => void) | null = null; - vi.spyOn(globalThis, 'fetch').mockImplementation( - async (input: RequestInfo | URL) => { - if (String(input) === '/api/auth/refresh') { - return await new Promise((_resolve, reject) => { - rejectRefresh = reject; - }); - } - throw new Error(`unexpected fetch ${String(input)}`); - }, - ); - const staleRefresh = requestPlatformSessionRefresh(testAuthUser.id); - const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' }; - const accountBGeneration = beginPlatformSessionTransition(); - setStoredAuthAccessToken('account-b-token'); - await commitAuthenticatedPlatformSession(accountB, accountBGeneration); - rejectRefresh?.(new Error('late account A refresh failed')); - - await expect(staleRefresh).resolves.toEqual({ status: 'stale' }); - expect( - window.localStorage.getItem('genarrative.auth.access-token.v1'), - ).toBe('account-b-token'); - }); - - it('refreshes the current account once and synchronizes the replacement token', async () => { - const invoke = vi.fn(async () => null); - window.__TAURI__ = { core: { invoke } }; - const generation = beginPlatformSessionTransition(); - setStoredAuthAccessToken('expired-token'); - await commitAuthenticatedPlatformSession(testAuthUser, generation); - let refreshCalls = 0; - vi.spyOn(globalThis, 'fetch').mockImplementation( - async (input: RequestInfo | URL) => { - const url = String(input); - if (url === '/api/auth/refresh') { - refreshCalls += 1; - return new Response(JSON.stringify({ token: 'replacement-token' }), { - status: 200, - }); - } - if (url === '/api/auth/me') { - return new Response( - JSON.stringify({ - user: testAuthUser, - availableLoginMethods: ['password'], - }), - { status: 200 }, - ); - } - throw new Error(`unexpected fetch ${url}`); - }, - ); - - const result = await requestPlatformSessionRefresh(testAuthUser.id); - expect(result).toEqual( - expect.objectContaining({ status: 'refreshed', user: testAuthUser }), - ); - expect(refreshCalls).toBe(1); - await waitFor(() => { - expect(invoke).toHaveBeenLastCalledWith( - 'install_platform_account_session', - expect.objectContaining({ - userId: testAuthUser.id, - accessToken: 'replacement-token', - }), - ); - }); - }); - - it('keeps the identity generation stable when the same account renews its credential', async () => { - const installs: Array<{ identityGeneration?: number; revision?: number }> = - []; - const invoke = vi.fn(async (command: string, payload?: unknown) => { - if (command === 'install_platform_account_session') { - installs.push( - payload as { identityGeneration?: number; revision?: number }, - ); - } - return null; - }); - window.__TAURI__ = { core: { invoke } }; - const generation = beginPlatformSessionTransition(); - setStoredAuthAccessToken('expired-token'); - await commitAuthenticatedPlatformSession(testAuthUser, generation); - const identityGenerationAfterLogin = - currentPlatformNativeIdentityGenerationForTests(); - const sessionGenerationAfterLogin = currentPlatformSessionGeneration(); - - vi.spyOn(globalThis, 'fetch').mockImplementation( - async (input: RequestInfo | URL) => { - const url = String(input); - if (url === '/api/auth/refresh') { - return new Response(JSON.stringify({ token: 'renewed-token' }), { - status: 200, - }); - } - if (url === '/api/auth/me') { - return new Response( - JSON.stringify({ - user: testAuthUser, - availableLoginMethods: ['password'], - }), - { status: 200 }, - ); - } - throw new Error(`unexpected fetch ${url}`); - }, - ); - - const result = await requestPlatformSessionRefresh(testAuthUser.id); - expect(result).toEqual( - expect.objectContaining({ status: 'refreshed', user: testAuthUser }), - ); - // 续期只换凭据:身份代次与平台会话代次都不推进,在途生成 operation 不会被判成 - // 旧账号请求;native 写入仍然用更高的 revision 拒绝迟到写入。 - expect(currentPlatformNativeIdentityGenerationForTests()).toBe( - identityGenerationAfterLogin, - ); - expect(currentPlatformSessionGeneration()).toBe( - sessionGenerationAfterLogin, - ); - expect(installs).toHaveLength(2); - expect(installs[1]?.identityGeneration).toBe( - installs[0]?.identityGeneration, - ); - expect(installs[1]?.revision).toBeGreaterThan(installs[0]?.revision ?? 0); - }); - - it('keeps the session when a refresh fails for a transient reason', async () => { - const invoke = vi.fn(async () => null); - window.__TAURI__ = { core: { invoke } }; - const generation = beginPlatformSessionTransition(); - setStoredAuthAccessToken('still-valid-token'); - await commitAuthenticatedPlatformSession(testAuthUser, generation); - const sessionGeneration = currentPlatformSessionGeneration(); - - vi.spyOn(globalThis, 'fetch').mockImplementation( - async (input: RequestInfo | URL) => { - const url = String(input); - if (url === '/api/auth/refresh') { - return new Response('', { status: 503 }); - } - throw new Error(`unexpected fetch ${url}`); - }, - ); - - const result = await requestPlatformSessionRefresh(testAuthUser.id); - // 刷新暂时不可用不等于登录态权威失效:保留会话与 access token,只让本次动作失败。 - expect(result).toMatchObject({ status: 'failed', authoritative: false }); - expect( - window.localStorage.getItem('genarrative.auth.access-token.v1'), - ).toBe('still-valid-token'); - expect(currentPlatformSessionGeneration()).toBe(sessionGeneration); - expect( - invoke.mock.calls.filter( - ([command]) => command === 'clear_platform_account_session', - ), - ).toHaveLength(0); - }); - - it('keeps refresh, current-user lookup, and native commit on the frozen origin', async () => { - const invoke = vi.fn(async () => null); - window.__TAURI__ = { core: { invoke } }; - const generation = beginPlatformSessionTransition(); - setStoredAuthAccessToken('expired-token'); - await commitAuthenticatedPlatformSession( - testAuthUser, - generation, - AGC_DEVELOPMENT_API_BASE_URL, - ); - - let resolveRefresh: ((response: Response) => void) | null = null; - vi.spyOn(globalThis, 'fetch').mockImplementation( - async (input: RequestInfo | URL) => { - const url = String(input); - if (url === '/api/auth/refresh') { - return await new Promise((resolve) => { - resolveRefresh = resolve; - }); - } - if (url === '/api/auth/me') { - return new Response( - JSON.stringify({ - user: testAuthUser, - availableLoginMethods: ['password'], - }), - { status: 200 }, - ); - } - throw new Error(`unexpected fetch ${url}`); - }, - ); - const refresh = requestPlatformSessionRefresh(testAuthUser.id); - window.localStorage.setItem( - 'genarrative.client.server-selection.v1', - JSON.stringify({ preset: 'release', customBaseUrl: '' }), - ); - resolveRefresh?.( - new Response(JSON.stringify({ token: 'replacement-token' }), { - status: 200, - }), - ); - - await expect(refresh).resolves.toEqual( - expect.objectContaining({ status: 'refreshed' }), - ); - expect(invoke).toHaveBeenLastCalledWith( - 'install_platform_account_session', - expect.objectContaining({ - accessToken: 'replacement-token', - apiBaseUrl: AGC_DEVELOPMENT_API_BASE_URL, - }), - ); - }); - - it('deduplicates startup auth refresh when React StrictMode hydrates twice', async () => { - const fetchSpy = vi - .spyOn(globalThis, 'fetch') - .mockImplementation(async (input: RequestInfo | URL) => { - const url = String(input); - if (url === '/api/auth/refresh') { - return new Response(JSON.stringify({ token: 'fresh-token' }), { - status: 200, - }); - } - if (url === '/api/auth/me') { - return new Response( - JSON.stringify({ - user: testAuthUser, - availableLoginMethods: ['password'], - }), - { status: 200 }, - ); - } - throw new Error(`unexpected fetch ${url}`); - }); - - render( - React.createElement( - React.StrictMode, - null, - React.createElement(AuthenticatedClient, null, ({ user }) => - React.createElement( - 'main', - { 'aria-label': '已登录' }, - user.displayName, - ), - ), - ), - ); - - expect(await screen.findByLabelText('已登录')).not.toBeNull(); - expect( - fetchSpy.mock.calls.filter( - ([input]) => String(input) === '/api/auth/refresh', - ), - ).toHaveLength(1); - expect( - window.localStorage.getItem('genarrative.auth.access-token.v1'), - ).toBe('fresh-token'); - }); - - it('shows phone code login before entering the workspace', async () => { - vi.spyOn(globalThis, 'fetch').mockImplementation( - async (input: RequestInfo | URL) => { - const url = String(input); - if (url === '/api/auth/refresh') { - return new Response('', { status: 401 }); - } - throw new Error(`unexpected fetch ${url}`); - }, - ); - - render( - React.createElement(AuthenticatedClient, null, () => - React.createElement('main', { 'aria-label': '已登录' }, 'ready'), - ), - ); - - expect(await screen.findByRole('main', { name: '登录' })).not.toBeNull(); - expect(screen.getByRole('button', { name: '验证码登录' })).not.toBeNull(); - expect(screen.getByRole('button', { name: '密码登录' })).not.toBeNull(); - expect(screen.getByLabelText('手机号')).not.toBeNull(); - expect(screen.getByLabelText('验证码')).not.toBeNull(); - expect(screen.queryByLabelText('已登录')).toBeNull(); - }); - - it('shows debug server selection and keeps custom address collapsed by default', async () => { - vi.spyOn(globalThis, 'fetch').mockImplementation( - async (input: RequestInfo | URL) => { - if (String(input) === '/api/auth/refresh') { - return new Response('', { status: 401 }); - } - throw new Error(`unexpected fetch ${String(input)}`); - }, - ); - - render( - React.createElement(AuthenticatedClient, null, () => - React.createElement('main', { 'aria-label': '已登录' }, 'ready'), - ), - ); - - await screen.findByRole('main', { name: '登录' }); - expect(screen.getByRole('combobox', { name: '服务器' })).not.toBeNull(); - expect(screen.queryByLabelText('自定义服务器地址')).toBeNull(); - }); - - it.each(['release', 'dev'])( - 'restores the selected server without forwarding a bare credential (%s)', - async (preset) => { - window.localStorage.setItem( - 'genarrative.auth.access-token.v1', - 'release-token', - ); - window.localStorage.setItem( - 'genarrative.client.server-selection.v1', - JSON.stringify({ preset, customBaseUrl: '' }), - ); - const invoke = vi.fn(async () => null); - window.__TAURI__ = { core: { invoke } }; - const fetchSpy = vi - .spyOn(globalThis, 'fetch') - .mockImplementation( - async (input: RequestInfo | URL, init?: RequestInit) => { - const url = String(input); - expect(new Headers(init?.headers).get('Authorization')).not.toBe( - 'Bearer release-token', - ); - if (url === '/api/auth/refresh') { - expect( - new Headers(init?.headers).get('Authorization'), - ).toBeNull(); - return new Response(JSON.stringify({ token: 'dev-token' }), { - status: 200, - }); - } - if (url === '/api/auth/me') { - expect(new Headers(init?.headers).get('Authorization')).toBe( - 'Bearer dev-token', - ); - return new Response(JSON.stringify({ user: testAuthUser }), { - status: 200, - }); - } - throw new Error(`unexpected fetch ${url}`); - }, - ); - - render( - React.createElement(AuthenticatedClient, null, () => - React.createElement('main', { 'aria-label': '已登录' }), - ), - ); - - expect( - await screen.findByRole('main', { name: '已登录' }), - ).not.toBeNull(); - expect(fetchSpy.mock.calls.map(([url]) => String(url))).toEqual([ - '/api/auth/refresh', - '/api/auth/me', - ]); - expect(invoke).toHaveBeenLastCalledWith( - 'install_platform_account_session', - expect.objectContaining({ - accessToken: 'dev-token', - apiBaseUrl: - preset === 'release' - ? 'https://www.genarrative.world' - : AGC_DEVELOPMENT_API_BASE_URL, - }), - ); - }, - ); - - it('logs in with a phone code and stores the returned token', async () => { - const fetchSpy = vi - .spyOn(globalThis, 'fetch') - .mockImplementation( - async (input: RequestInfo | URL, init?: RequestInit) => { - const url = String(input); - if (url === '/api/auth/refresh') { - return new Response('', { status: 401 }); - } - if (url === '/api/auth/phone/send-code') { - expect(JSON.parse(String(init?.body))).toMatchObject({ - countryCode: '86', - purePhoneNumber: '13800000000', - scene: 'login', - }); - return new Response( - JSON.stringify({ - ok: true, - cooldownSeconds: 60, - expiresInSeconds: 300, - providerRequestId: 'sms-1', - }), - { status: 200 }, - ); - } - if (url === '/api/auth/phone/login') { - expect(JSON.parse(String(init?.body))).toMatchObject({ - countryCode: '86', - purePhoneNumber: '13800000000', - code: '123456', - }); - return new Response( - JSON.stringify({ - token: 'phone-token', - user: { ...testAuthUser, loginMethod: 'phone' }, - created: false, - referral: null, - }), - { status: 200 }, - ); - } - throw new Error(`unexpected fetch ${url}`); - }, - ); - - render( - React.createElement(AuthenticatedClient, null, ({ user }) => - React.createElement( - 'main', - { 'aria-label': '已登录' }, - user.loginMethod, - ), - ), - ); - - await screen.findByRole('main', { name: '登录' }); - fireEvent.change(screen.getByLabelText('手机号'), { - target: { value: '+86 138 0000 0000' }, - }); - fireEvent.click(screen.getByRole('button', { name: '获取验证码' })); - expect( - await screen.findByText('验证码已发送,300 秒内有效'), - ).not.toBeNull(); - - fireEvent.change(screen.getByLabelText('验证码'), { - target: { value: '123456' }, - }); - fireEvent.click(screen.getByRole('button', { name: '登录' })); - - expect(await screen.findByLabelText('已登录')).not.toBeNull(); - expect(screen.getByLabelText('已登录').textContent).toBe('phone'); - expect( - window.localStorage.getItem('genarrative.auth.access-token.v1'), - ).toBe('phone-token'); - expect( - fetchSpy.mock.calls.filter( - ([input]) => String(input) === '/api/auth/phone/send-code', - ), - ).toHaveLength(1); - expect( - fetchSpy.mock.calls.filter( - ([input]) => String(input) === '/api/auth/phone/login', - ), - ).toHaveLength(1); - }); - - it('logs in with the current password phone contract', async () => { - vi.spyOn(globalThis, 'fetch').mockImplementation( - async (input: RequestInfo | URL, init?: RequestInit) => { - const url = String(input); - if (url === '/api/auth/refresh') { - return new Response('', { status: 401 }); - } - if (url === '/api/auth/entry') { - expect(JSON.parse(String(init?.body))).toEqual({ - countryCode: '86', - purePhoneNumber: '13800000000', - password: 'secret123', - }); - return new Response( - JSON.stringify({ - token: 'password-token', - user: { ...testAuthUser, loginMethod: 'password' }, - }), - { status: 200 }, - ); - } - throw new Error(`unexpected fetch ${url}`); - }, - ); - - render( - React.createElement(AuthenticatedClient, null, ({ user }) => - React.createElement( - 'main', - { 'aria-label': '已登录' }, - user.loginMethod, - ), - ), - ); - + renderAuthSurface(invoke); await screen.findByRole('main', { name: '登录' }); fireEvent.click(screen.getByRole('button', { name: '密码登录' })); fireEvent.change(screen.getByLabelText('手机号'), { - target: { value: '+86 138 0000 0000' }, + target: { value: '138 0000 0000' }, }); fireEvent.change(screen.getByLabelText('密码'), { - target: { value: ' secret123 ' }, + target: { value: ' secret ' }, }); fireEvent.click(screen.getByRole('button', { name: '登录' })); expect(await screen.findByLabelText('已登录')).not.toBeNull(); - expect( - window.localStorage.getItem('genarrative.auth.access-token.v1'), - ).toBe('password-token'); + expect(invoke).toHaveBeenCalledWith('login_client_with_password', { + apiBaseUrl: AGC_DEVELOPMENT_API_BASE_URL, + phone: '13800000000', + password: 'secret', + }); }); - it('falls back to the localized action error for non-JSON auth failures', async () => { - vi.spyOn(globalThis, 'fetch').mockImplementation( - async (input: RequestInfo | URL) => { - const url = String(input); - if (url === '/api/auth/refresh') { - return new Response('', { status: 401 }); - } - if (url === '/api/auth/phone/login') { - return new Response( - 'Failed to deserialize the JSON body into the target type', - { status: 422, headers: { 'Content-Type': 'text/plain' } }, - ); - } - throw new Error(`unexpected fetch ${url}`); - }, - ); - - render( - React.createElement(AuthenticatedClient, null, () => - React.createElement('main', { 'aria-label': '已登录' }, 'ready'), - ), - ); - + it('shows the debug server selection and uses the custom origin for login', async () => { + const invoke = vi.fn(async (command: string) => { + if (command === 'read_workspace_preferences') { + return { + projectCreationDirectory: null, + recentWorkspaces: [], + chatPromptPolishReminderDisabled: false, + clientServerSelection: null, + }; + } + if (command === 'read_client_auth_state') { + return { status: 'unauthenticated' }; + } + if (command === 'login_client_with_phone_code') { + return testAuthUser; + } + return null; + }); + renderAuthSurface(invoke); await screen.findByRole('main', { name: '登录' }); - fireEvent.change(screen.getByLabelText('手机号'), { - target: { value: '13800000000' }, - }); - fireEvent.change(screen.getByLabelText('验证码'), { - target: { value: '123456' }, - }); - fireEvent.click(screen.getByRole('button', { name: '登录' })); - expect(await screen.findByText('登录失败')).not.toBeNull(); - expect(screen.queryByText(/Unexpected|Failed to deserialize/u)).toBeNull(); + fireEvent.change(screen.getByLabelText('服务器'), { + target: { value: 'custom' }, + }); + fireEvent.change(screen.getByLabelText('自定义服务器地址'), { + target: { value: 'http://127.0.0.1:10001' }, + }); + fireEvent.blur(screen.getByLabelText('自定义服务器地址')); + await loginWithCode(); + + expect(await screen.findByLabelText('已登录')).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('login_client_with_phone_code', { + apiBaseUrl: 'http://127.0.0.1:10001', + phone: '13800000000', + code: '123456', + }); + expect(invoke).toHaveBeenCalledWith('set_client_server_selection', { + preset: 'custom', + customBaseUrl: 'http://127.0.0.1:10001', + }); }); it('shows the backend reason when the password login is rejected', async () => { - vi.spyOn(globalThis, 'fetch').mockImplementation( - async (input: RequestInfo | URL) => { - const url = String(input); - if (url === '/api/auth/refresh') { - return new Response('', { status: 401 }); - } - if (url === '/api/auth/entry') { - return new Response( - JSON.stringify({ - ok: false, - data: null, - error: { code: 'unauthorized', message: '手机号或密码错误' }, - meta: { apiVersion: '2026-06-16', routeVersion: 'v1' }, - }), - { - status: 401, - headers: { 'Content-Type': 'application/json' }, - }, - ); - } - throw new Error(`unexpected fetch ${url}`); - }, - ); - - render( - React.createElement(AuthenticatedClient, null, () => - React.createElement('main', { 'aria-label': '已登录' }, 'ready'), - ), - ); - + const invoke = vi.fn(async (command: string) => { + if (command === 'read_client_auth_state') { + return { status: 'unauthenticated' }; + } + if (command === 'login_client_with_password') { + throw new Error('手机号或密码错误'); + } + return null; + }); + renderAuthSurface(invoke); await screen.findByRole('main', { name: '登录' }); fireEvent.click(screen.getByRole('button', { name: '密码登录' })); fireEvent.change(screen.getByLabelText('手机号'), { - target: { value: '15801783533' }, + target: { value: '13800000000' }, }); fireEvent.change(screen.getByLabelText('密码'), { - target: { value: 'wrong-password' }, + target: { value: 'wrong' }, }); fireEvent.click(screen.getByRole('button', { name: '登录' })); expect(await screen.findByText('手机号或密码错误')).not.toBeNull(); - expect(screen.queryByText('登录失败')).toBeNull(); + expect(screen.queryByLabelText('已登录')).toBeNull(); }); - it('keeps the backend reason when the error body carries no envelope', async () => { - vi.spyOn(globalThis, 'fetch').mockImplementation( - async (input: RequestInfo | URL) => { - const url = String(input); - if (url === '/api/auth/refresh') { - return new Response('', { status: 401 }); - } - if (url === '/api/auth/entry') { - return new Response( - JSON.stringify({ - error: { code: 'unauthorized', message: '手机号或密码错误' }, - meta: { apiVersion: '2026-06-16', routeVersion: 'v1' }, - }), - { - status: 401, - headers: { 'Content-Type': 'application/json' }, - }, - ); - } - throw new Error(`unexpected fetch ${url}`); - }, - ); - - render( - React.createElement(AuthenticatedClient, null, () => - React.createElement('main', { 'aria-label': '已登录' }, 'ready'), - ), - ); - - await screen.findByRole('main', { name: '登录' }); - fireEvent.click(screen.getByRole('button', { name: '密码登录' })); - fireEvent.change(screen.getByLabelText('手机号'), { - target: { value: '15801783533' }, + it('keeps transport details out of the login failure copy', async () => { + const invoke = vi.fn(async (command: string) => { + if (command === 'read_client_auth_state') { + return { status: 'unauthenticated' }; + } + if (command === 'login_client_with_phone_code') { + throw new Error( + 'network-error: 无法连接登录服务,请确认配套后端或 API 代理已启动后重试', + ); + } + return null; }); - fireEvent.change(screen.getByLabelText('密码'), { - target: { value: 'wrong-password' }, - }); - fireEvent.click(screen.getByRole('button', { name: '登录' })); - - expect(await screen.findByText('手机号或密码错误')).not.toBeNull(); - expect(screen.queryByText('登录失败')).toBeNull(); - }); - - it('shows a clear login service error instead of raw Load failed', async () => { - vi.spyOn(globalThis, 'fetch').mockImplementation( - async (input: RequestInfo | URL) => { - const url = String(input); - if (url === '/api/auth/refresh') { - return new Response('', { status: 401 }); - } - if (url === '/api/auth/phone/login') { - throw new TypeError('Load failed'); - } - throw new Error(`unexpected fetch ${url}`); - }, - ); - - render( - React.createElement(AuthenticatedClient, null, () => - React.createElement('main', { 'aria-label': '已登录' }, 'ready'), - ), - ); - - await screen.findByRole('main', { name: '登录' }); - fireEvent.change(screen.getByLabelText('手机号'), { - target: { value: '13800000000' }, - }); - fireEvent.change(screen.getByLabelText('验证码'), { - target: { value: '123456' }, - }); - fireEvent.click(screen.getByRole('button', { name: '登录' })); + renderAuthSurface(invoke); + await loginWithCode(); expect( await screen.findByText( - '无法连接登录服务,请确认配套后端或 API 代理已启动后重试', + 'network-error: 无法连接登录服务,请确认配套后端或 API 代理已启动后重试', ), ).not.toBeNull(); - expect(screen.queryByText(/Load failed/u)).toBeNull(); - expect(screen.queryByLabelText('已登录')).toBeNull(); + expect(document.body.textContent).not.toContain('ECONNREFUSED'); }); - it('explains a refused login connection without exposing transport details', async () => { - vi.spyOn(globalThis, 'fetch').mockImplementation( - async (input: RequestInfo | URL) => { - const url = String(input); - if (url === '/api/auth/refresh') { - return new Response('', { status: 401 }); - } - if (url === '/api/auth/phone/login') { - throw new TypeError('connect ECONNREFUSED 127.0.0.1:8082'); - } - throw new Error(`unexpected fetch ${url}`); - }, - ); + it('keeps a retry when the startup check reports the service unavailable', async () => { + let reads = 0; + const invoke = vi.fn(async (command: string) => { + if (command === 'read_client_auth_state') { + reads += 1; + return reads === 1 + ? { + status: 'unavailable', + errorMessage: 'network-error: 无法连接登录服务', + } + : { status: 'unauthenticated' }; + } + return null; + }); + renderAuthSurface(invoke); - render( - React.createElement(AuthenticatedClient, null, () => - React.createElement('main', { 'aria-label': '已登录' }, 'ready'), - ), - ); + expect( + await screen.findByText('network-error: 无法连接登录服务'), + ).not.toBeNull(); + expect(screen.queryByLabelText('已登录')).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '重试登录状态检查' })); + await waitFor(() => expect(reads).toBe(2)); await screen.findByRole('main', { name: '登录' }); - fireEvent.change(screen.getByLabelText('手机号'), { - target: { value: '13800000000' }, + }); + + it('starts every login attempt from a fresh identity generation', async () => { + const invoke = vi.fn(async (command: string) => { + if (command === 'read_client_auth_state') { + return { status: 'unauthenticated' }; + } + if (command === 'login_client_with_phone_code') { + return testAuthUser; + } + return null; }); - fireEvent.change(screen.getByLabelText('验证码'), { - target: { value: '123456' }, + renderAuthSurface(invoke); + await screen.findByRole('main', { name: '登录' }); + const generationBeforeLogin = currentPlatformSessionGeneration(); + await loginWithCode(); + await screen.findByLabelText('已登录'); + + expect(currentPlatformSessionGeneration()).toBeGreaterThan( + generationBeforeLogin, + ); + }); + + it('calls the Rust logout command and returns to the login surface', async () => { + const invoke = vi.fn(async (command: string) => { + if (command === 'read_client_auth_state') { + return { + status: 'authenticated', + user: testAuthUser, + apiBaseUrl: AGC_DEVELOPMENT_API_BASE_URL, + }; + } + if (command === 'logout_client_session') return null; + return null; }); - fireEvent.click(screen.getByRole('button', { name: '登录' })); - - expect( - await screen.findByText( - '无法连接登录服务:服务器拒绝连接,请确认服务已启动并检查端口', - ), - ).not.toBeNull(); - expect(screen.queryByText(/ECONNREFUSED|127\.0\.0\.1:8082/u)).toBeNull(); - expect(screen.queryByLabelText('已登录')).toBeNull(); - }); - - it('keeps the stored token when startup auth check cannot reach the service', async () => { - setStoredAuthAccessToken('existing-token'); - const fetchSpy = vi - .spyOn(globalThis, 'fetch') - .mockImplementation(async (input: RequestInfo | URL) => { - const url = String(input); - if (url === '/api/auth/me') { - throw new TypeError('Load failed'); - } - throw new Error(`unexpected fetch ${url}`); - }); - - render( - React.createElement(AuthenticatedClient, null, ({ user }) => - React.createElement( - 'main', - { 'aria-label': '已登录' }, - user.displayName, - ), - ), - ); - - expect(await screen.findByRole('main', { name: '登录' })).not.toBeNull(); - expect( - screen.getByText( - '无法连接登录服务,请确认配套后端或 API 代理已启动后重试', - ), - ).not.toBeNull(); - expect( - window.localStorage.getItem('genarrative.auth.access-token.v1'), - ).toBe('existing-token'); - expect(screen.queryByLabelText('已登录')).toBeNull(); - expect( - fetchSpy.mock.calls.filter(([input]) => String(input) === '/api/auth/me'), - ).toHaveLength(1); - }); - - it('shows the HTTP maintenance error when startup auth receives a 503', async () => { - setStoredAuthAccessToken('existing-token'); - vi.spyOn(globalThis, 'fetch').mockImplementation( - async (input: RequestInfo | URL) => { - if (String(input) === '/api/auth/me') { - return new Response( - '503 Service Unavailable', - { status: 503, headers: { 'Content-Type': 'text/html' } }, - ); - } - throw new Error(`unexpected fetch ${String(input)}`); - }, - ); - - render( - React.createElement(AuthenticatedClient, null, () => - React.createElement('main', { 'aria-label': '已登录' }), - ), - ); - - expect(await screen.findByRole('main', { name: '登录' })).not.toBeNull(); - expect( - screen.getByText( - '登录服务暂不可用(HTTP 503),服务器可能正在维护,请稍后重试', - ), - ).not.toBeNull(); - }); - - it('still calls logout when token refresh fails during logout retry', async () => { - setStoredAuthAccessToken('existing-token'); - let logoutCalls = 0; - const fetchSpy = vi - .spyOn(globalThis, 'fetch') - .mockImplementation(async (input: RequestInfo | URL) => { - const url = String(input); - if (url === '/api/auth/me') { - return new Response( - JSON.stringify({ - user: testAuthUser, - availableLoginMethods: ['password'], - }), - { status: 200 }, - ); - } - if (url === '/api/auth/logout') { - logoutCalls += 1; - return new Response('', { status: logoutCalls === 1 ? 500 : 200 }); - } - if (url === '/api/auth/refresh') { - return new Response('', { status: 401 }); - } - throw new Error(`unexpected fetch ${url}`); - }); - - render( - React.createElement(AuthenticatedClient, null, ({ user, logout }) => - React.createElement( - 'main', - { 'aria-label': '已登录' }, - React.createElement('span', null, user.displayName), - React.createElement( - 'button', - { type: 'button', onClick: logout }, - '退出', - ), - ), - ), - ); - + renderAuthSurface(invoke); expect(await screen.findByLabelText('已登录')).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '退出' })); expect(await screen.findByRole('main', { name: '登录' })).not.toBeNull(); expect(screen.getByText('已退出登录')).not.toBeNull(); - expect( - window.localStorage.getItem('genarrative.auth.access-token.v1'), - ).toBe(null); - expect( - fetchSpy.mock.calls.filter( - ([input]) => String(input) === '/api/auth/logout', - ), - ).toHaveLength(2); - expect( - fetchSpy.mock.calls.filter( - ([input]) => String(input) === '/api/auth/refresh', - ), - // 401 刷新先用当前 cookie 收敛重试一次,重试仍被拒绝才算权威失效。 - ).toHaveLength(2); + expect(invoke).toHaveBeenCalledWith('logout_client_session'); }); - it('fails the renderer closed when native session clear is rejected during logout', async () => { - setStoredAuthAccessToken('existing-token'); - window.__TAURI__ = { - core: { - invoke: vi.fn(async (command: string) => { - if (command === 'clear_platform_account_session') { - throw new Error('runner clear rejected'); - } - return null; - }), - }, - }; - vi.spyOn(globalThis, 'fetch').mockImplementation( - async (input: RequestInfo | URL) => { - const url = String(input); - if (url === '/api/auth/me') { - return new Response( - JSON.stringify({ - user: testAuthUser, - availableLoginMethods: ['password'], - }), - { status: 200 }, - ); - } - if (url === '/api/auth/logout') { - return new Response('', { status: 200 }); - } - throw new Error(`unexpected fetch ${url}`); - }, - ); - - render( - React.createElement(AuthenticatedClient, null, ({ user, logout }) => - React.createElement( - 'main', - { 'aria-label': '已登录' }, - React.createElement('span', null, user.displayName), - React.createElement( - 'button', - { type: 'button', onClick: logout }, - '退出', - ), - ), - ), - ); + it('still leaves the workspace when the local runtime clear fails during logout', async () => { + const invoke = vi.fn(async (command: string) => { + if (command === 'read_client_auth_state') { + return { + status: 'authenticated', + user: testAuthUser, + apiBaseUrl: AGC_DEVELOPMENT_API_BASE_URL, + }; + } + if (command === 'logout_client_session') { + throw new Error('runner clear rejected'); + } + return null; + }); + renderAuthSurface(invoke); expect(await screen.findByLabelText('已登录')).not.toBeNull(); fireEvent.click(screen.getByRole('button', { name: '退出' })); @@ -1606,8 +370,30 @@ export function registerAuthTests() { '已退出登录;本地运行时登录态同步失败,请重启客户端后再登录', ), ).not.toBeNull(); - expect( - window.localStorage.getItem('genarrative.auth.access-token.v1'), - ).toBe(null); + }); + + it('returns to the login surface when Rust reports the session ended', async () => { + const invoke = vi.fn(async (command: string) => { + if (command === 'read_client_auth_state') { + return { + status: 'authenticated', + user: testAuthUser, + apiBaseUrl: AGC_DEVELOPMENT_API_BASE_URL, + }; + } + return null; + }); + const bridge = renderAuthSurface(invoke, { withEvents: true }); + expect(await screen.findByLabelText('已登录')).not.toBeNull(); + + await act(async () => { + bridge.emit('agc-client-auth-state-changed', { + status: 'unauthenticated', + }); + await Promise.resolve(); + }); + + expect(await screen.findByRole('main', { name: '登录' })).not.toBeNull(); + expect(screen.getByText('登录已失效,请重新登录')).not.toBeNull(); }); } diff --git a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts index 205bd0fca..77b3901d1 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts @@ -818,4 +818,51 @@ export function registerChatComposerControlTests() { fake.restore(); } }); + + it('失败提示保留可执行原因,诊断正文只在「查看详情」时读取', async () => { + const failure = + 'direct-codex-failure:v2 stage=code-generation code=runtime-failure retryable=false ' + + 'summary=入口模块语法错误;建议:修复 game/src 下的语法错误后重新发送需求;已保存脱敏项目诊断;' + + '详情:.agent/runtime/errors/error-1700000000000-1.json'; + const detailBody = + '{"code":"runtime-failure","detail":"入口模块 "}'; + const { invoke, surface } = await openDirectCodexSurface({ + chat_with_game_creator_direct_codex: () => { + throw new Error(failure); + }, + read_agent_runtime_error_detail: () => detailBody, + }); + + const composer = within(surface).getByLabelText('陶泥儿对话内容'); + await submitDirectTurn(surface, composer, '继续制作'); + + // v2 失败文案映射成可执行原因,而不是「执行失败,请稍后重试」的通用兜底。 + await waitFor(() => { + expect( + within(surface).getAllByText(/代码生成失败:入口模块语法错误/).length, + ).toBeGreaterThan(0); + }); + // 正文没有预读、也没有拼进提示或历史消息。 + expect(within(surface).queryByText(/redacted-path/)).toBeNull(); + expect( + invoke.mock.calls.filter( + ([command]) => command === 'read_agent_runtime_error_detail', + ), + ).toHaveLength(0); + + fireEvent.click(within(surface).getByRole('button', { name: '查看详情' })); + await waitFor(() => { + expect( + within(surface).getByLabelText('运行诊断详情').textContent, + ).toContain('redacted-path'); + }); + expect(invoke).toHaveBeenCalledWith('read_agent_runtime_error_detail', { + projectPath: DYNAMIC_GAME_PROJECT_PATH, + detailRef: '.agent/runtime/errors/error-1700000000000-1.json', + }); + + // 再点一次收起,正文从界面上消失(查询与状态都回到未展开)。 + fireEvent.click(within(surface).getByRole('button', { name: '收起详情' })); + expect(within(surface).queryByLabelText('运行诊断详情')).toBeNull(); + }); } diff --git a/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts index 71baf3f5b..95906dc24 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts @@ -509,11 +509,12 @@ export function registerDesignAgentSurfaceTests() { expect( screen.getByRole('button', { name: '发送' }).hasAttribute('disabled'), ).toBe(false); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 100)); - }); + // 动画每 50ms 揭示一批字:等到确实揭示了正文再采样,不用固定 sleep 赌时机。 + await waitFor(() => + expect(bubble.textContent?.length).toBeGreaterThan(0), + ); const prefix = bubble.textContent; - expect(prefix?.length).toBeGreaterThan(0); + expect(bubble.textContent).not.toBe(reply); act(() => { emit({ kind: 'state', view: terminal }); emit({ kind: 'state', view: terminal }); @@ -614,6 +615,12 @@ export function registerDesignAgentSurfaceTests() { await screen.findByLabelText('策划 Agent 实时回复'); await waitFor(() => expect(messageList.scrollTop).toBe(1000)); const replyBeforeTool = screen.getByLabelText('策划 Agent 实时回复'); + // 正文由 50ms 动画逐字揭示:先等这一轮揭示到齐,再验证工具状态事件不会改写它。 + // 直接读 textContent 会把「动画还没揭完」误判成「工具事件改写了正文」——并行/慢机上 + // 曾捕获到空串随后变成首字。 + await waitFor(() => + expect(replyBeforeTool.textContent).toBe('正在补充关卡节奏'), + ); const prefixBeforeTool = replyBeforeTool.textContent; act(() => harness.emitDesignAgentEvent({ @@ -704,6 +711,13 @@ export function registerDesignAgentSurfaceTests() { const currentText = screen.getByText('当前补充需求'); expect(currentText.closest('.message')?.querySelector('time')).toBeNull(); }); + // 策划历史由 Rust 的 DesignView/会话事实源持有;React 只保留当前投影, + // 不得再通过通用浏览器 conversation writer 追加项目对话。 + expect( + harness.invoke.mock.calls.filter( + ([command]) => command === 'append_local_conversation_message', + ), + ).toHaveLength(0); expect( screen .getByText('历史策划需求') diff --git a/apps/ai-game-creator-shell/tests/appSurface/harness.ts b/apps/ai-game-creator-shell/tests/appSurface/harness.ts index 9322d1f5f..8d92d9bea 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/harness.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/harness.ts @@ -13,7 +13,6 @@ import { import React from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import * as clientApi from '../../src/services/clientApi'; import { resetLlmModelCatalogCacheForTest } from '../../src/services/llmModelCatalog'; import { useLauncherHomeDraftStore } from '../../src/view/home/useHomeDraftStore'; @@ -375,239 +374,6 @@ function mockRoleAgentReply() { return roleAgentMockReply; } -function planningResponseStream({ - runId, - sequence, - accumulatedText, - status = 'streaming', - appliedSteerCursor = 0, - responseRevision = 0, - loopIteration = 1, - overrides = {}, -}: { - runId: string; - sequence: number; - accumulatedText: string; - status?: 'streaming' | 'ready' | 'committed' | 'discarded' | 'failed'; - appliedSteerCursor?: number; - responseRevision?: number; - loopIteration?: number; - overrides?: Record; -}) { - return { - schemaVersion: 'game-creator-runtime-response-stream.v1', - agentId: 'planning-agent-v2', - taskId: 'planning-agent-v2', - sessionId: 'planning-session-active', - runId, - requestKind: 'final-reply', - requestSlot: `final-reply-loop-${loopIteration}-revision-${responseRevision}`, - appliedSteerCursor, - responseRevision, - sequence, - status, - accumulatedText, - finishReason: status === 'ready' || status === 'committed' ? 'stop' : null, - startedAt: 6000, - updatedAt: 6000 + sequence, - ...overrides, - }; -} - -function agentRuntimeUserInputRequest({ - agentId, - sessionId, - runId, - requestId = 'request-user-input-1', - actionId = 'action-user-input-1', - responseId = null, - status = 'pending', -}: { - agentId: string; - sessionId: string; - runId: string; - requestId?: string; - actionId?: string; - responseId?: string | null; - status?: 'pending' | 'answer-prepared'; -}) { - return { - schemaVersion: 'game-creator-runtime-user-input.v1', - requestId, - agentId, - taskId: agentId, - sessionId, - runId, - actionId, - status, - questions: [ - { - id: 'visual_direction', - header: '美术方向', - question: '首版角色规范图采用哪种美术方向?', - options: [ - { - label: '像素风', - description: '优先验证轮廓与动作可读性。', - }, - { - label: '手绘风', - description: '优先验证角色气质与细节。', - }, - ], - }, - ], - allowFreeform: true, - responseId, - requestedAt: 6000, - updatedAt: 6000, - }; -} - -function createPlanGddStateView( - overrides: Partial = {}, -): PlanGddStateViewV1 { - const gddRef = { - gddId: 'gdd-plan-0001', - version: 1, - fingerprint: 'sha256-serde-json-v2:1111111111111111', - }; - return { - schemaVersion: 'plan-gdd-state-view.v1', - projectId: 'local-project-draft', - gddId: gddRef.gddId, - state: 'ready_for_approval', - session: { - sessionId: 'plan-session-0001', - sessionRevision: 3, - sessionFingerprint: 'sha256-serde-json-v2:2222222222222222', - phase: 'awaiting_gdd_approval', - clarificationRound: 2, - repairDepth: 0, - accumulatedAgentMillis: 42_000, - activeRunId: null, - awaitingAnswerFor: null, - decisionStateCounts: { - confirmed: 2, - defaultPending: 1, - prototypePending: 0, - }, - }, - versions: [ - { - gddRef, - status: 'ready_for_approval', - approvalRequestId: 'gdd-approval-0001', - createdAtUtc: '2026-08-18T00:00:00Z', - decision: null, - }, - ], - displayGdd: { - schemaVersion: 'plan-gdd.v1', - projectId: 'local-project-draft', - gddId: gddRef.gddId, - version: gddRef.version, - submissionId: 'action-0123456789abcdef01234567', - approvalRequestId: 'gdd-approval-0001', - actionFingerprint: 'a'.repeat(64), - agentId: 'project-planning', - source: 'agent-delegate', - runProfile: 'standard', - runProfileBindingFingerprint: 'b'.repeat(64), - rootAgentId: 'planning-agent-v2', - rootRunId: 'run-plan-root-0001', - delegationId: 'delegation-0001', - sessionId: 'plan-session-0001', - sourceSessionRevision: 2, - sourceSessionFingerprint: 'sha256-serde-json-v2:3333333333333333', - createdByRunId: 'run-plan-child-0001', - createdAtUtc: '2026-08-18T00:00:00Z', - fingerprint: gddRef.fingerprint, - game: { - title: '灯塔守夜人', - oneLiner: '在潮汐涨落之间调度光束,护送迷航的船只回港。', - genre: { primary: '策略', fusion: null }, - artStyle: { - visualType: '像素', - keywords: ['夜色', '海雾'], - moodAndColor: '冷蓝为主,暖黄光束作为唯一高光。', - mvpArtBoundary: '只做灯塔与三类船只的静帧。', - }, - pillars: [ - { - name: '光束调度', - playerFeel: '在有限视野里做取舍。', - mechanism: '每回合只能照亮一个扇区。', - decisionState: 'confirmed', - basis: null, - }, - ], - coreLoop: ['观察潮汐', '分配光束', '结算返港'], - targetUsers: { - coreUsers: '喜欢短局策略的玩家', - preferences: '偏好可预测的规则', - sessionLength: '单局 5 分钟', - referenceGames: ['灯塔物语'], - }, - platformFacts: { - runtime: 'web', - viewports: ['desktop', 'mobile'], - inputs: ['pointer'], - preview: '本地预览', - }, - mvpSystems: [ - { - system: '潮汐时钟', - minimalFunction: '固定三段潮汐循环。', - whyRequired: '没有它就没有节奏压力。', - verifyMethod: '观察一局内三段是否各触发一次。', - decisionState: 'confirmed', - basis: null, - }, - ], - outOfScope: ['多人对战'], - creatorTips: { - doFirst: '先做潮汐时钟。', - deferForNow: '暂缓天气系统。', - howToVerify: '单局跑满三段潮汐。', - expandWhen: '核心循环稳定后再加船种。', - }, - }, - decisions: [ - { - id: 'decision-0001', - topic: '光束是否可分裂', - state: 'confirmed', - answerSource: 'user_option', - round: 1, - answerSummary: '不可分裂,保持取舍压力。', - basis: null, - }, - ], - prototypeValidationItems: [ - { - id: 'proto-0001', - question: '单扇区照明是否足够做出取舍?', - microPrototype: '纸面推演三回合。', - observation: '玩家是否出现犹豫。', - passCriterion: '三回合内至少一次改变计划。', - }, - ], - }, - pendingApproval: { - gddRef, - pendingActionId: 'action-0123456789abcdef01234567', - actionFingerprint: 'a'.repeat(64), - approvalRequestId: 'gdd-approval-0001', - sessionId: 'plan-session-0001', - runId: 'run-plan-root-0001', - }, - approvedGddRef: null, - recoveryPending: false, - ...overrides, - }; -} - /** * 运行态事件里的条目身份:只有 `item.started` / `item.completed` 带条目。 * @@ -805,9 +571,20 @@ function createProjectChatRuntimeHarness({ if (command === 'preflight_web_game_creation') return { status: 'ready' }; if ( command === 'read_game_creator_app_config' || + command === 'load_game_creator_llm_models' || command === 'select_game_creator_model' || command === 'select_game_creator_reasoning_effort' ) { + if (command === 'load_game_creator_llm_models') { + return { + defaultModelId: 'quality', + models: [ + { id: 'quality', displayName: '高质量' }, + { id: 'fast', displayName: '快速' }, + ], + revision: 1, + }; + } if (command === 'select_game_creator_model') { selectedModelId = String(args?.modelId); selectedModelIsDefault = args?.isDefault === true; @@ -1416,14 +1193,7 @@ export function installResizeObserverStub() { beforeEach(() => { resetLlmModelCatalogCacheForTest(); - vi.spyOn(clientApi, 'loadClientLlmModels').mockResolvedValue({ - defaultModelId: 'quality', - models: [ - { id: 'quality', displayName: '高质量' }, - { id: 'fast', displayName: '快速' }, - ], - revision: 1, - }); + Object.defineProperty(window, 'PointerEvent', { configurable: true, value: TestPointerEvent, @@ -1525,7 +1295,6 @@ export { composerValue, createGameCreationAppManifest, createGameCreationAppSeedTasks, - createPlanGddStateView, createProjectChatRuntimeHarness, deriveAgentStatusCards, describe, @@ -1540,7 +1309,6 @@ export { nativeClipboardMock, openResourceFilterPanel, pickProjectFromLauncher, - planningResponseStream, ProjectDevelopmentView, queryResourceSelectButton, React, diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index e081afb8b..040d6c32d 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -20,6 +20,7 @@ import { renderLauncherProjectsAt, screen, setComposerText, + TEST_LOCAL_PROJECT_PATH, testAuthUser, vi, waitFor, @@ -98,6 +99,16 @@ export function registerClientHomeTests() { if (command === 'read_game_creator_app_config') { return { config: { selectedModelId: 'quality' } }; } + if (command === 'load_game_creator_llm_models') { + return { + defaultModelId: 'quality', + models: [ + { id: 'quality', displayName: '高质量' }, + { id: 'fast', displayName: '快速' }, + ], + revision: 1, + }; + } if (command === 'select_game_creator_model') { return { config: { selectedModelId: (args as { modelId: string }).modelId }, @@ -744,60 +755,36 @@ export function registerClientHomeTests() { }); it('starts from the client home and opens a project in the same window', async () => { - const fetchSpy = vi - .spyOn(globalThis, 'fetch') - .mockImplementation(async (input: RequestInfo | URL) => { - const url = String(input); - if (url === '/api/profile/dashboard') { - return new Response( - JSON.stringify({ - walletBalance: 40, - totalPlayTimeMs: 0, - playedWorldCount: 0, - updatedAt: '2026-07-17T00:00:00.000Z', - }), - { status: 200 }, - ); - } - if (url === '/api/profile/recharge-center') { - return new Response( - JSON.stringify({ - walletBalance: 40, - mudPointBalance: { - totalPoints: 40, - permanentPoints: 20, - limitedPoints: 0, - limitedExpiresAt: null, - dailyFreePoints: 20, - dailyFreeResetPoints: 20, - dailyFreeResetsAt: '2026-07-18T00:00:00.000Z', - }, - products: [], - membership: null, - }), - { status: 200 }, - ); - } - if (url === '/api/profile/wallet-ledger') { - return new Response( - JSON.stringify({ - entries: [ - { - id: 'ledger-test-1', - sourceType: 'daily_task_reward', - amountDelta: 12, - balanceAfter: 40, - createdAt: '2026-07-17T00:00:00.000Z', - }, - ], - }), - { status: 200 }, - ); - } - throw new Error(`unexpected fetch ${url}`); - }); + // 账户与钱包的远端真相在 Rust typed command:渲染层只消费投影,不再自己发请求。 + const rechargeCenter = { + walletBalance: 40, + mudPointBalance: { + totalPoints: 40, + permanentPoints: 20, + limitedPoints: 0, + limitedExpiresAt: null, + dailyFreePoints: 20, + dailyFreeResetPoints: 20, + dailyFreeResetsAt: '2026-07-18T00:00:00.000Z', + }, + products: [], + membership: null, + }; + const walletLedger = { + entries: [ + { + id: 'ledger-test-1', + sourceType: 'daily_task_reward', + amountDelta: 12, + balanceAfter: 40, + createdAt: '2026-07-17T00:00:00.000Z', + }, + ], + }; const invoke = vi.fn( async (command: string, args?: Record) => { + if (command === 'read_profile_recharge_center') return rechargeCenter; + if (command === 'read_profile_wallet_ledger') return walletLedger; if (command === 'get_design_agent_runtime_mode') return null; if (command === 'inspect_local_project_directory') { return { @@ -857,8 +844,8 @@ export function registerClientHomeTests() { await within(ledgerDialog).findByText('每日任务奖励'), ).not.toBeNull(); expect( - fetchSpy.mock.calls.filter( - ([input]) => String(input) === '/api/profile/wallet-ledger', + invoke.mock.calls.filter( + ([command]) => command === 'read_profile_wallet_ledger', ), ).toHaveLength(1); fireEvent.click( @@ -938,95 +925,87 @@ export function registerClientHomeTests() { let rechargeOrderRequestCount = 0; let rechargePaid = false; let releaseLateRechargeOrder: (() => void) | null = null; - const fetchSpy = vi - .spyOn(globalThis, 'fetch') - .mockImplementation(async (input: RequestInfo | URL) => { - const url = String(input); - if (url === '/api/profile/recharge-center') { - return new Response( - JSON.stringify( - rechargePaid - ? { - ...rechargeCenter, - walletBalance: 180, - mudPointBalance: { - ...rechargeCenter.mudPointBalance, - totalPoints: 180, - permanentPoints: 160, - }, - } - : rechargeCenter, - ), - { status: 200 }, - ); + const rechargeCenterResponse = () => + rechargePaid + ? { + ...rechargeCenter, + walletBalance: 180, + mudPointBalance: { + ...rechargeCenter.mudPointBalance, + totalPoints: 180, + permanentPoints: 160, + }, + } + : rechargeCenter; + // 账户与钱包的远端真相在 Rust typed command:渲染层只提交商品/订单标识。 + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_profile_recharge_center') { + return rechargeCenterResponse(); } - if (url === '/api/profile/recharge/orders') { + if (command === 'create_profile_recharge_order') { rechargeOrderRequestCount += 1; + expect(args).toEqual({ productId: 'points_60' }); if (rechargeOrderRequestCount === 2) { await new Promise((resolve) => { releaseLateRechargeOrder = resolve; }); } - return new Response( - JSON.stringify({ - order: { - orderId: 'order-native-1', - productId: 'points_60', - productTitle: '60泥点', - kind: 'points', - amountCents: 600, - status: 'pending', - paymentChannel: 'wechat_native', - paidAt: null, - providerTransactionId: null, - createdAt: '2026-07-17T00:00:00Z', - pointsDelta: 0, - membershipExpiresAt: null, - }, - center: rechargeCenter, - wechatNativePayment: { - codeUrl: 'weixin://pay.weixin.qq.com/native-test', - expiresAt: '2099-01-01T00:05:00Z', - }, - }), - { status: 200 }, - ); + return { + order: { + orderId: 'order-native-1', + productId: 'points_60', + productTitle: '60泥点', + kind: 'points', + amountCents: 600, + status: 'pending', + paymentChannel: 'wechat_native', + paidAt: null, + providerTransactionId: null, + createdAt: '2026-07-17T00:00:00Z', + pointsDelta: 0, + membershipExpiresAt: null, + }, + center: rechargeCenter, + wechatNativePayment: { + codeUrl: 'weixin://pay.weixin.qq.com/native-test', + expiresAt: '2099-01-01T00:05:00Z', + }, + }; } - if ( - url === '/api/profile/recharge/orders/order-native-1/wechat/confirm' - ) { + if (command === 'confirm_wechat_profile_recharge_order') { + expect(args).toEqual({ orderId: 'order-native-1' }); rechargePaid = true; - return new Response( - JSON.stringify({ - order: { - orderId: 'order-native-1', - productId: 'points_60', - productTitle: '60泥点', - kind: 'points', - amountCents: 600, - status: 'paid', - paymentChannel: 'wechat_native', - paidAt: '2026-07-17T00:01:00Z', - providerTransactionId: 'wechat-transaction-1', - createdAt: '2026-07-17T00:00:00Z', - pointsDelta: 60, - membershipExpiresAt: null, + return { + order: { + orderId: 'order-native-1', + productId: 'points_60', + productTitle: '60泥点', + kind: 'points', + amountCents: 600, + status: 'paid', + paymentChannel: 'wechat_native', + paidAt: '2026-07-17T00:01:00Z', + providerTransactionId: 'wechat-transaction-1', + createdAt: '2026-07-17T00:00:00Z', + pointsDelta: 60, + membershipExpiresAt: null, + }, + center: { + ...rechargeCenter, + walletBalance: 180, + mudPointBalance: { + ...rechargeCenter.mudPointBalance, + totalPoints: 180, + permanentPoints: 160, }, - center: { - ...rechargeCenter, - walletBalance: 180, - mudPointBalance: { - ...rechargeCenter.mudPointBalance, - totalPoints: 180, - permanentPoints: 160, - }, - }, - }), - { status: 200 }, - ); + }, + }; } - throw new Error(`unexpected fetch ${url}`); - }); + return undefined; + }, + ); + window.__TAURI__ = { core: { invoke } }; renderLauncherAt('/?launcher'); fireEvent.click(screen.getByRole('button', { name: '充值' })); @@ -1043,24 +1022,17 @@ export function registerClientHomeTests() { expect( await screen.findByRole('dialog', { name: '购买更多泥点' }), ).not.toBeNull(); - expect(fetchSpy).toHaveBeenCalledWith( - '/api/profile/recharge-center', - expect.objectContaining({ method: 'GET' }), + expect(invoke).toHaveBeenCalledWith( + 'read_profile_recharge_center', + undefined, ); fireEvent.click(screen.getByRole('button', { name: /60泥点.*购买/ })); expect( await screen.findByRole('dialog', { name: '微信扫码支付' }), ).not.toBeNull(); - expect(fetchSpy).toHaveBeenCalledWith( - '/api/profile/recharge/orders', - expect.objectContaining({ - method: 'POST', - body: JSON.stringify({ - productId: 'points_60', - paymentChannel: 'wechat_native', - }), - }), - ); + expect(invoke).toHaveBeenCalledWith('create_profile_recharge_order', { + productId: 'points_60', + }); fireEvent.click(screen.getByRole('button', { name: '我已支付' })); await waitFor(() => { expect(screen.queryByRole('dialog', { name: '微信扫码支付' })).toBeNull(); @@ -1111,6 +1083,87 @@ export function registerClientHomeTests() { } export function registerHomeProjectCreationTests() { + it('scopes the 策划补全 option to the game entry and clears it on creation-type switch', () => { + window.__TAURI__ = { core: { invoke: vi.fn(async () => null) } }; + renderLauncherAt('/?launcher', 'home', true); + + const creationTypes = screen.getByRole('group', { name: '创作类型' }); + const gameType = within(creationTypes).getByRole('button', { + name: '做游戏', + }); + const documentType = within(creationTypes).getByRole('button', { + name: '做方案', + }); + const planningCompletion = () => + screen.queryByRole('checkbox', { + name: '策划补全', + }) as HTMLInputElement | null; + + // 只属于「做游戏」:做方案不显示这一项。 + expect(planningCompletion()).not.toBeNull(); + expect(planningCompletion()?.checked).toBe(false); + fireEvent.click(documentType); + expect(planningCompletion()).toBeNull(); + + // 切换创作类型清除游戏专属勾选:首页表面在换档时会整体重挂,切回来必须是未勾选, + // 不能带着上一档的选择继续提交。这条契约由本用例钉住(实现侧不再额外重置状态)。 + fireEvent.click(gameType); + const firstCheckbox = planningCompletion()!; + fireEvent.click(firstCheckbox); + expect(planningCompletion()?.checked).toBe(true); + fireEvent.click(documentType); + fireEvent.click(gameType); + expect(document.contains(firstCheckbox)).toBe(false); + expect(planningCompletion()?.checked).toBe(false); + }); + + it('submits the game entry with planning when 策划补全 is checked', async () => { + const manifest = createGameCreationAppManifest( + 'home-planning-completion', + '策划补全项目', + ); + const chatHarness = createProjectChatRuntimeHarness({ + projectPath: TEST_LOCAL_PROJECT_PATH, + initialSessionExists: false, + }); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'create_automatic_local_game_project') { + return { + projectPath: TEST_LOCAL_PROJECT_PATH, + manifestPath: `${TEST_LOCAL_PROJECT_PATH}\\.agent\\manifest.json`, + manifest, + }; + } + if (command === 'get_local_game_manifest') { + return manifest; + } + return chatHarness.invoke(command, args); + }, + ); + window.__TAURI__ = { + core: { invoke }, + event: { listen: chatHarness.listen }, + }; + renderLauncherAt('/?launcher', 'home', true); + + fireEvent.click(screen.getByRole('checkbox', { name: '策划补全' })); + const promptInput = screen.getByLabelText('创作想法'); + nativeClipboardMock.text = '做一个带策划的像素游戏'; + fireEvent.paste(promptInput); + await waitFor(() => { + expect(promptInput.textContent).toContain('做一个带策划的像素游戏'); + }); + fireEvent.keyDown(promptInput, { key: 'Enter', code: 'Enter' }); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith( + 'create_automatic_local_game_project', + { name: null, planning: true }, + ); + }); + }); + it('refreshes recent project status before entering the project chat surface', async () => { let inspectCount = 0; const invoke = vi.fn( @@ -1148,7 +1201,19 @@ export function registerHomeProjectCreationTests() { renderLauncherProjectsAt('/?launcher'); expect(await screen.findByText('authorized-game')).not.toBeNull(); - fireEvent.click(screen.getByText('authorized-game')); + // 最近项目卡在状态检查完成前是禁用的:先等状态落地,再按用户路径打开。 + await waitFor(() => { + expect( + ( + screen.getByRole('button', { + name: '打开项目 authorized-game', + }) as HTMLButtonElement + ).disabled, + ).toBe(false); + }); + fireEvent.click( + screen.getByRole('button', { name: '打开项目 authorized-game' }), + ); expect(await screen.findByLabelText('陶泥儿项目对话')).not.toBeNull(); expect(screen.getByLabelText('项目开发工作台')).not.toBeNull(); @@ -1455,7 +1520,6 @@ export function registerHomeProjectCreationTests() { expect(invoke).toHaveBeenCalledWith('create_automatic_local_game_project', { name: null, planning: false, - projectsRoot: null, }); expect(invoke).toHaveBeenCalledWith('chat_with_game_creator_direct_codex', { projectPath: automaticProjectPath, @@ -1567,7 +1631,6 @@ export function registerHomeProjectCreationTests() { expect(invoke).toHaveBeenCalledWith('create_automatic_local_game_project', { name: '角色参考游戏', planning: false, - projectsRoot: null, }); expect(invoke).toHaveBeenCalledWith('upload_local_asset', { projectPath: automaticProjectPath, @@ -2618,20 +2681,37 @@ export function registerRecentProjectsTests() { expect(screen.getByText('RootGodot')).not.toBeNull(); }); - it('removes recent launcher projects without opening them', () => { - const invoke = vi.fn(async () => undefined); - window.__TAURI__ = { core: { invoke } }; - window.localStorage.setItem( - 'genarrative-ai-game-creator.recent-workspaces.v1', - JSON.stringify([ - '/tmp/recent-one', - ' /tmp/recent-one ', - ' ', - 42, - 'relative-game', - '/tmp/recent-two', - ]), + it('removes recent launcher projects without opening them', async () => { + let recentWorkspaces: unknown[] = [ + '/tmp/recent-one', + ' /tmp/recent-one ', + ' ', + 42, + 'relative-game', + '/tmp/recent-two', + ]; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_workspace_preferences') { + return { + projectCreationDirectory: null, + recentWorkspaces, + }; + } + if (command === 'remove_recent_workspace') { + const projectPath = String(args?.projectPath ?? ''); + recentWorkspaces = recentWorkspaces.filter( + (path) => typeof path !== 'string' || path.trim() !== projectPath, + ); + return { + projectCreationDirectory: null, + recentWorkspaces, + }; + } + return undefined; + }, ); + window.__TAURI__ = { core: { invoke } }; renderLauncherAt('/?launcher'); expect(screen.getByLabelText('最近项目')).not.toBeNull(); @@ -2643,8 +2723,10 @@ export function registerRecentProjectsTests() { expect(screen.queryByRole('button', { name: '清空' })).toBeNull(); fireEvent.click(screen.getByRole('button', { name: '项目组' })); - expect(screen.getAllByText('/tmp/recent-one')).toHaveLength(1); - expect(screen.getByText('/tmp/recent-two')).not.toBeNull(); + await waitFor(() => { + expect(screen.getAllByText('/tmp/recent-one')).toHaveLength(1); + expect(screen.getByText('/tmp/recent-two')).not.toBeNull(); + }); expect(screen.queryByText('relative-game')).toBeNull(); expect(invoke).not.toHaveBeenCalledWith('inspect_local_project_directory', { projectPath: 'relative-game', @@ -2656,8 +2738,13 @@ export function registerRecentProjectsTests() { }), ); - expect(screen.queryByText('/tmp/recent-one')).toBeNull(); - expect(screen.getByText('/tmp/recent-two')).not.toBeNull(); + await waitFor(() => { + expect(screen.queryByText('/tmp/recent-one')).toBeNull(); + expect(screen.getByText('/tmp/recent-two')).not.toBeNull(); + }); + expect(invoke).toHaveBeenCalledWith('remove_recent_workspace', { + projectPath: '/tmp/recent-one', + }); expect(invoke).not.toHaveBeenCalledWith( 'open_game_creator_workspace_window', expect.anything(), @@ -2668,8 +2755,12 @@ export function registerRecentProjectsTests() { name: '从列表移除', }), ); - expect(screen.getByText('暂无项目')).not.toBeNull(); - expect(window.localStorage.length).toBe(0); + await waitFor(() => { + expect(screen.getByText('暂无项目')).not.toBeNull(); + }); + expect(invoke).toHaveBeenCalledWith('remove_recent_workspace', { + projectPath: '/tmp/recent-two', + }); }); it('renames a recent project and refreshes the inspected manifest name', async () => { @@ -3058,20 +3149,35 @@ export function registerRecentProjectsTests() { recentRunStopReason: string; }) => void) | null = null; - const invoke = vi.fn(async (command: string) => { - if (command === 'get_design_agent_runtime_mode') return null; - if (command === 'inspect_local_project_directory') { - return await new Promise((resolve) => { - finishRefresh = resolve; - }); - } - throw new Error(`unexpected invoke ${command}`); - }); - window.__TAURI__ = { core: { invoke } }; - window.localStorage.setItem( - 'genarrative-ai-game-creator.recent-workspaces.v1', - JSON.stringify(['/tmp/slow-refresh-game']), + let recentWorkspaces = ['/tmp/slow-refresh-game']; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'get_design_agent_runtime_mode') return null; + if (command === 'read_workspace_preferences') { + return { + projectCreationDirectory: null, + recentWorkspaces, + }; + } + if (command === 'remove_recent_workspace') { + const projectPath = String(args?.projectPath ?? ''); + recentWorkspaces = recentWorkspaces.filter( + (path) => path !== projectPath, + ); + return { + projectCreationDirectory: null, + recentWorkspaces, + }; + } + if (command === 'inspect_local_project_directory') { + return await new Promise((resolve) => { + finishRefresh = resolve; + }); + } + throw new Error(`unexpected invoke ${command}`); + }, ); + window.__TAURI__ = { core: { invoke } }; renderLauncherAt('/?launcher'); expect(screen.getByLabelText('最近项目')).not.toBeNull(); @@ -3087,7 +3193,12 @@ export function registerRecentProjectsTests() { name: '从列表移除', }), ); - expect(screen.queryByText('/tmp/slow-refresh-game')).toBeNull(); + await waitFor(() => { + expect(screen.queryByText('/tmp/slow-refresh-game')).toBeNull(); + }); + expect(invoke).toHaveBeenCalledWith('remove_recent_workspace', { + projectPath: '/tmp/slow-refresh-game', + }); await act(async () => { finishRefresh?.({ @@ -3414,7 +3525,6 @@ export function registerRecentProjectsTests() { expect(invoke).toHaveBeenCalledWith('create_automatic_local_game_project', { name: null, planning: false, - projectsRoot: creationDirectory, }); }); } diff --git a/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts index 95cbf2b10..c5f117954 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts @@ -360,39 +360,54 @@ export function registerRuntimeSettingsTests() { it('keeps the project creation directory in the workspace settings section', async () => { const creationDirectory = 'F:\\Projects\\陶泥儿游戏'; - const storageKey = - 'genarrative-ai-game-creator.project-creation-directory.v1'; - const invoke = vi.fn(async (command: string) => { - if (command === 'read_game_creator_app_config') { - return { - path: '/home/test/AppData/game-creator.config.json', - config: { - agentMode: 'codex_app_server', - llm: { - apiKey: '', - baseUrl: 'https://llm.example.test/v1', - model: 'gpt-workspace', - apiKind: 'openai_responses', - reasoningEffort: 'high', - stream: true, - webSearchEnabled: false, - contextWindowTokens: 128000, - autoCompactTokenLimit: 64000, - toolOutputTokenLimit: 12000, - requestTimeoutMs: 180000, - maxRetries: 2, - retryBackoffMs: 500, + let projectCreationDirectory: string | null = null; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_workspace_preferences') { + return { + projectCreationDirectory, + recentWorkspaces: [], + }; + } + if (command === 'set_project_creation_directory') { + projectCreationDirectory = + typeof args?.directory === 'string' ? args.directory : null; + return { + projectCreationDirectory, + recentWorkspaces: [], + }; + } + if (command === 'read_game_creator_app_config') { + return { + path: '/home/test/AppData/game-creator.config.json', + config: { + agentMode: 'codex_app_server', + llm: { + apiKey: '', + baseUrl: 'https://llm.example.test/v1', + model: 'gpt-workspace', + apiKind: 'openai_responses', + reasoningEffort: 'high', + stream: true, + webSearchEnabled: false, + contextWindowTokens: 128000, + autoCompactTokenLimit: 64000, + toolOutputTokenLimit: 12000, + requestTimeoutMs: 180000, + maxRetries: 2, + retryBackoffMs: 500, + }, + agentLlm: {}, + editorApi: { baseUrl: 'http://127.0.0.1:8082', apiKey: '' }, }, - agentLlm: {}, - editorApi: { baseUrl: 'http://127.0.0.1:8082', apiKey: '' }, - }, - }; - } - if (command === 'pick_local_project_directory') { - return creationDirectory; - } - throw new Error(`unexpected invoke ${command}`); - }); + }; + } + if (command === 'pick_local_project_directory') { + return creationDirectory; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); window.__TAURI__ = { core: { invoke } }; renderLauncherAt('/?launcher'); @@ -402,23 +417,29 @@ export function registerRuntimeSettingsTests() { // 不选目录时是默认位置,且本地不写任何偏好。 expect(within(dialog).getByText('默认位置')).not.toBeNull(); - expect(window.localStorage.getItem(storageKey)).toBeNull(); + expect(projectCreationDirectory).toBeNull(); fireEvent.click(within(dialog).getByRole('button', { name: '选择目录' })); expect(await within(dialog).findByText(creationDirectory)).not.toBeNull(); expect(invoke).toHaveBeenCalledWith('pick_local_project_directory', { title: '选择项目创建目录', }); - expect(window.localStorage.getItem(storageKey)).toBe( - JSON.stringify(creationDirectory), - ); + expect(invoke).toHaveBeenCalledWith('set_project_creation_directory', { + directory: creationDirectory, + }); + expect(projectCreationDirectory).toBe(creationDirectory); expect(within(dialog).getByText('已更新项目创建目录')).not.toBeNull(); fireEvent.click( within(dialog).getByRole('button', { name: '恢复默认位置' }), ); - expect(within(dialog).getByText('默认位置')).not.toBeNull(); - expect(window.localStorage.getItem(storageKey)).toBeNull(); + await waitFor(() => { + expect(within(dialog).getByText('默认位置')).not.toBeNull(); + }); + expect(invoke).toHaveBeenCalledWith('set_project_creation_directory', { + directory: null, + }); + expect(projectCreationDirectory).toBeNull(); expect(within(dialog).getByText('已恢复默认位置')).not.toBeNull(); }); diff --git a/apps/ai-game-creator-shell/tests/assetDirectUpload.test.ts b/apps/ai-game-creator-shell/tests/assetDirectUpload.test.ts index 5d9d59f3e..12c1bf235 100644 --- a/apps/ai-game-creator-shell/tests/assetDirectUpload.test.ts +++ b/apps/ai-game-creator-shell/tests/assetDirectUpload.test.ts @@ -1,199 +1,81 @@ // @vitest-environment jsdom /** - * AGC 素材直传(封面 / 截图)的三步链路边界。 + * AGC 素材直传 facade 的边界。 * - * 这里只替换平台 API 调用与对象存储直传实现,验证「凭证 → 直传 → confirm」的字段口径、 - * 地址白名单与错误文案,不触达真实 Tauri HTTP 插件或 OSS。 + * React 只把用户选择的文件和结构化元数据交给 Rust typed command;凭证、对象存储直传和 + * confirm 的网络合同由 Rust 测试覆盖,渲染层这里只验证 IPC 载荷与失败关闭。 */ import { beforeEach, expect, test, vi } from 'vitest'; -const requestClientApiMock = vi.hoisted(() => vi.fn()); +import { uploadPlatformMediaAsset } from '../src/services/assetDirectUpload'; -vi.mock('../src/services/clientApi', () => ({ - requestClientApi: (...args: unknown[]) => requestClientApiMock(...args), -})); - -import { - resolvePlatformAssetUploadUrl, - uploadPlatformMediaAsset, -} from '../src/services/assetDirectUpload'; - -const TICKET = { - upload: { - bucket: 'genarrative-assets', - host: 'https://genarrative-assets.oss-cn-shanghai.aliyuncs.com/', - objectKey: - 'generated-character-drafts/game-distribution/cover/42/cover.png', - legacyPublicPath: '/generated-character-drafts/game-distribution/cover/42', - formFields: { - key: 'generated-character-drafts/game-distribution/cover/42/cover.png', - policy: 'policy-value', - OSSAccessKeyId: 'ak-value', - signature: 'signature-value', - success_action_status: '204', - 'x-oss-meta-asset_kind': 'game_distribution_cover', - }, - }, -}; - -const CONFIRMED = { - assetObject: { - assetObjectId: 'asset_cover_1', - objectKey: TICKET.upload.objectKey, - assetKind: 'game_distribution_cover', - }, -}; +const invoke = vi.fn(); function buildCoverFile() { return new File(['cover-bytes'], 'cover.png', { type: 'image/png' }); } beforeEach(() => { - requestClientApiMock.mockReset(); - requestClientApiMock - .mockResolvedValueOnce(TICKET) - .mockResolvedValueOnce(CONFIRMED); + invoke.mockReset(); + invoke.mockResolvedValue({ + assetObjectId: 'asset_cover_1', + objectKey: + 'generated-character-drafts/game-distribution/cover/42/cover.png', + }); }); -test('封面按凭证、直传、confirm 三步上传并返回素材标识', async () => { - const uploadFetch = vi.fn(async () => new Response(null, { status: 204 })); +test('把用户文件交给 Rust typed command 并返回素材标识', async () => { const file = buildCoverFile(); - const uploaded = await uploadPlatformMediaAsset({ file, assetKind: 'game_distribution_cover', pathSegments: ['game-distribution', 'cover', '42'], entityId: 'game-distribution-cover', metadata: { game_distribution_media: 'cover' }, - fetchImpl: uploadFetch, + invoke, }); expect(uploaded).toEqual({ assetObjectId: 'asset_cover_1', - objectKey: TICKET.upload.objectKey, + objectKey: + 'generated-character-drafts/game-distribution/cover/42/cover.png', }); - - const ticketCall = requestClientApiMock.mock.calls[0]; - expect(ticketCall?.[0]).toBe('/api/assets/direct-upload-tickets'); - const ticketBody = JSON.parse(String((ticketCall?.[1] as RequestInit).body)); - expect(ticketBody).toEqual( - expect.objectContaining({ - legacyPrefix: 'generated-character-drafts', - pathSegments: ['game-distribution', 'cover', '42'], + expect(invoke).toHaveBeenCalledTimes(1); + expect(invoke).toHaveBeenCalledWith('upload_platform_media_asset', { + input: { fileName: 'cover.png', contentType: 'image/png', - access: 'private', - maxSizeBytes: file.size, + assetKind: 'game_distribution_cover', + pathSegments: ['game-distribution', 'cover', '42'], + entityId: 'game-distribution-cover', + bytes: Array.from(new TextEncoder().encode('cover-bytes')), metadata: { - asset_kind: 'game_distribution_cover', game_distribution_media: 'cover', }, - }), - ); - - const uploadCall = uploadFetch.mock.calls[0] as unknown as [ - string, - RequestInit, - ]; - expect(uploadCall[0]).toBe(TICKET.upload.host); - expect(uploadCall[1].method).toBe('POST'); - const formData = uploadCall[1].body as FormData; - expect(formData.get('policy')).toBe('policy-value'); - expect(formData.get('signature')).toBe('signature-value'); - expect(formData.get('success_action_status')).toBe('204'); - const uploadedFile = formData.get('file'); - expect(uploadedFile).toBeInstanceOf(File); - expect((uploadedFile as File).name).toBe('cover.png'); - - const confirmCall = requestClientApiMock.mock.calls[1]; - expect(confirmCall?.[0]).toBe('/api/assets/objects/confirm'); - expect(JSON.parse(String((confirmCall?.[1] as RequestInit).body))).toEqual({ - bucket: 'genarrative-assets', - objectKey: TICKET.upload.objectKey, - contentType: 'image/png', - contentLength: file.size, - assetKind: 'game_distribution_cover', - accessPolicy: 'private', - entityId: 'game-distribution-cover', + }, }); }); -test('直传地址不属于平台素材存储时失败关闭,且不发送文件', async () => { - requestClientApiMock.mockReset(); - requestClientApiMock - .mockResolvedValueOnce({ - upload: { ...TICKET.upload, host: 'https://evil.example.com/upload' }, - }) - .mockResolvedValueOnce(CONFIRMED); - const uploadFetch = vi.fn(); - +test('没有 Tauri command 时明确失败关闭', async () => { await expect( uploadPlatformMediaAsset({ file: buildCoverFile(), assetKind: 'game_distribution_cover', pathSegments: ['game-distribution', 'cover', '42'], entityId: 'game-distribution-cover', - fetchImpl: uploadFetch as never, }), - ).rejects.toThrow('素材上传地址不属于平台素材存储,已终止上传'); - - expect(uploadFetch).not.toHaveBeenCalled(); - expect(requestClientApiMock).toHaveBeenCalledTimes(1); + ).rejects.toThrow('需要在 Tauri App 内上传平台素材'); }); -test('对象存储拒绝上传时给出可重试文案', async () => { - const uploadFetch = vi.fn( - async () => new Response('denied', { status: 403 }), - ); - - await expect( - uploadPlatformMediaAsset({ - file: buildCoverFile(), - assetKind: 'game_distribution_screenshot', - pathSegments: ['game-distribution', 'screenshot', '42'], - entityId: 'game-distribution-screenshot', - fetchImpl: uploadFetch, - }), - ).rejects.toThrow('上传素材到对象存储失败(HTTP 403),请重试'); - - // 直传失败时不得再登记素材,避免留下没有实体的素材记录。 - expect(requestClientApiMock).toHaveBeenCalledTimes(1); -}); - -test('直传网络/作用域错误转成可操作文案', async () => { - const uploadFetch = vi.fn(async () => { - throw new Error('url not allowed on the configured scope'); - }); - +test('Rust 返回缺少素材标识时不把半成功投影给页面', async () => { + invoke.mockResolvedValueOnce({ assetObjectId: '', objectKey: '' }); await expect( uploadPlatformMediaAsset({ file: buildCoverFile(), assetKind: 'game_distribution_cover', pathSegments: ['game-distribution', 'cover', '42'], entityId: 'game-distribution-cover', - fetchImpl: uploadFetch, + invoke, }), - ).rejects.toThrow( - '上传素材失败:无法访问素材存储,请检查网络后重试(url not allowed on the configured scope)', - ); - - // 直传抛错时不得继续登记素材。 - expect(requestClientApiMock).toHaveBeenCalledTimes(1); -}); - -test('本地回环与阿里云 OSS 之外的主机一律拒绝', () => { - expect(resolvePlatformAssetUploadUrl('http://127.0.0.1:9000/bucket')).toBe( - 'http://127.0.0.1:9000/bucket', - ); - expect( - resolvePlatformAssetUploadUrl( - 'https://genarrative-assets.oss-cn-beijing.aliyuncs.com/', - ), - ).toBe('https://genarrative-assets.oss-cn-beijing.aliyuncs.com/'); - expect(() => - resolvePlatformAssetUploadUrl('http://oss.example.com/'), - ).toThrow('素材上传地址不属于平台素材存储,已终止上传'); - expect(() => resolvePlatformAssetUploadUrl(' ')).toThrow( - '素材上传地址为空,请稍后重试', - ); + ).rejects.toThrow('平台素材上传响应缺少素材标识'); }); diff --git a/apps/ai-game-creator-shell/tests/assetImporter.test.ts b/apps/ai-game-creator-shell/tests/assetImporter.test.ts index 80402a7b7..fafa1c684 100644 --- a/apps/ai-game-creator-shell/tests/assetImporter.test.ts +++ b/apps/ai-game-creator-shell/tests/assetImporter.test.ts @@ -6,9 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ invoke: vi.fn(), - loadEditorAssetLibrary: vi.fn(), openNativeFileDialog: vi.fn(), - resolveClientAssetReadUrl: vi.fn(), })); let includeUnregisteredProjectFile = false; @@ -17,10 +15,6 @@ vi.mock('@tauri-apps/api/core', () => ({ invoke: mocks.invoke })); vi.mock('@tauri-apps/plugin-dialog', () => ({ open: mocks.openNativeFileDialog, })); -vi.mock('../src/services/clientApi', () => ({ - loadEditorAssetLibrary: mocks.loadEditorAssetLibrary, - resolveClientAssetReadUrl: mocks.resolveClientAssetReadUrl, -})); vi.mock('@cubone/react-file-manager', () => ({ FileManager: ({ files, @@ -79,9 +73,7 @@ import { beforeEach(() => { includeUnregisteredProjectFile = false; mocks.invoke.mockReset(); - mocks.loadEditorAssetLibrary.mockReset(); mocks.openNativeFileDialog.mockReset(); - mocks.resolveClientAssetReadUrl.mockReset(); let manifestReadCount = 0; mocks.invoke.mockImplementation(async (command: string) => { if (command === 'get_local_game_manifest') { @@ -123,27 +115,30 @@ beforeEach(() => { if (command === 'import_ui_editor_assets') { throw new Error('第二个素材登记失败'); } + if (command === 'read_editor_asset_library') { + return { + folders: [{ folderId: 'folder-1', label: '角色' }], + assets: [ + { + folderId: 'folder-1', + assetId: 'remote-1', + label: '英雄', + assetKind: 'image', + sizeBytes: 1024, + previewAvailable: true, + }, + ], + }; + } + if (command === 'read_editor_asset_preview') { + return { previewUrl: 'https://assets.example/hero.png' }; + } return undefined; }); - mocks.loadEditorAssetLibrary.mockResolvedValue({ - folders: [{ folderId: 'folder-1', label: '角色' }], - assets: [ - { - folderId: 'folder-1', - assetId: 'remote-1', - objectKey: 'remote/hero.png', - label: '英雄', - size: 1024, - }, - ], - }); mocks.openNativeFileDialog.mockResolvedValue([ '/tmp/first.png', '/tmp/second.png', ]); - mocks.resolveClientAssetReadUrl.mockResolvedValue( - 'https://assets.example/hero.png', - ); }); describe('AssetImporter font mode', () => { diff --git a/apps/ai-game-creator-shell/tests/chatPromptPolish.test.tsx b/apps/ai-game-creator-shell/tests/chatPromptPolish.test.tsx index c5727d28e..3d7f2998f 100644 --- a/apps/ai-game-creator-shell/tests/chatPromptPolish.test.tsx +++ b/apps/ai-game-creator-shell/tests/chatPromptPolish.test.tsx @@ -14,7 +14,6 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; import { CHAT_PROMPT_POLISH_REMINDER_MIN_TEXT_LENGTH, - CHAT_PROMPT_POLISH_REMINDER_STORAGE_KEY, chatPromptDraftKey, readChatPromptPolishReminderDisabled, requestChatPromptPolish, @@ -231,18 +230,41 @@ describe('发送前提醒判据', () => { ); }); - test('persists the 不再提醒 preference on this machine only', () => { - expect(readChatPromptPolishReminderDisabled()).toBe(false); - writeChatPromptPolishReminderDisabled(true); - expect( - window.localStorage.getItem(CHAT_PROMPT_POLISH_REMINDER_STORAGE_KEY), - ).toBe('true'); - expect(readChatPromptPolishReminderDisabled()).toBe(true); - writeChatPromptPolishReminderDisabled(false); - expect( - window.localStorage.getItem(CHAT_PROMPT_POLISH_REMINDER_STORAGE_KEY), - ).toBeNull(); - expect(readChatPromptPolishReminderDisabled()).toBe(false); + test('persists the 不再提醒 preference through Rust instead of localStorage', async () => { + let stored = false; + const invoke = installTauriInvoke(async (command, args) => { + if (command === 'read_workspace_preferences') { + return { + projectCreationDirectory: null, + recentWorkspaces: [], + chatPromptPolishReminderDisabled: stored, + }; + } + if (command === 'set_chat_prompt_polish_reminder_disabled') { + stored = args?.disabled === true; + return { + projectCreationDirectory: null, + recentWorkspaces: [], + chatPromptPolishReminderDisabled: stored, + }; + } + return undefined; + }); + + await expect(readChatPromptPolishReminderDisabled()).resolves.toBe(false); + await expect(writeChatPromptPolishReminderDisabled(true)).resolves.toBe( + true, + ); + expect(invoke).toHaveBeenCalledWith( + 'set_chat_prompt_polish_reminder_disabled', + { disabled: true }, + ); + expect(storage.size).toBe(0); + await expect(readChatPromptPolishReminderDisabled()).resolves.toBe(true); + await expect(writeChatPromptPolishReminderDisabled(false)).resolves.toBe( + false, + ); + await expect(readChatPromptPolishReminderDisabled()).resolves.toBe(false); }); }); @@ -455,16 +477,39 @@ describe('聊天输入区 AI 润色与发送前提醒', () => { }); }); - test('persists 不再提醒 locally and stops holding later sends', async () => { + test('persists 不再提醒 through Rust and stops holding later sends', async () => { + let stored = false; + const invoke = installTauriInvoke(async (command, args) => { + if (command === 'read_workspace_preferences') { + return { + projectCreationDirectory: null, + recentWorkspaces: [], + chatPromptPolishReminderDisabled: stored, + }; + } + if (command === 'set_chat_prompt_polish_reminder_disabled') { + stored = args?.disabled === true; + return { + projectCreationDirectory: null, + recentWorkspaces: [], + chatPromptPolishReminderDisabled: stored, + }; + } + return undefined; + }); const onSubmitDraft = renderComposer({ initialText: LONG_PROMPT }); fireEvent.click(sendButton()); fireEvent.click( within(reminderPanel()).getByRole('checkbox', { name: '不再提醒' }), ); - expect( - window.localStorage.getItem(CHAT_PROMPT_POLISH_REMINDER_STORAGE_KEY), - ).toBe('true'); + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith( + 'set_chat_prompt_polish_reminder_disabled', + { disabled: true }, + ); + }); + expect(storage.size).toBe(0); fireEvent.click( within(reminderPanel()).getByRole('button', { name: '关闭' }), @@ -478,13 +523,24 @@ describe('聊天输入区 AI 润色与发送前提醒', () => { expect(screen.queryByRole('dialog', { name: '发送前提醒' })).toBeNull(); }); - test('reads the stored 不再提醒 preference when the composer mounts', () => { - window.localStorage.setItem( - CHAT_PROMPT_POLISH_REMINDER_STORAGE_KEY, - 'true', - ); + test('reads the Rust 不再提醒 preference when the composer mounts', async () => { + const invoke = installTauriInvoke(async (command) => { + if (command === 'read_workspace_preferences') { + return { + projectCreationDirectory: null, + recentWorkspaces: [], + chatPromptPolishReminderDisabled: true, + }; + } + return undefined; + }); const onSubmitDraft = renderComposer({ initialText: LONG_PROMPT }); + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith('read_workspace_preferences'); + }); + // 偏好快照是异步投影:让 React 追平后再发,未生效时这里会弹出提醒面板。 + await settleComposer(); fireEvent.click(sendButton()); expect(screen.queryByRole('dialog', { name: '发送前提醒' })).toBeNull(); expect(onSubmitDraft).toHaveBeenCalledWith({ diff --git a/apps/ai-game-creator-shell/tests/clientApi.test.ts b/apps/ai-game-creator-shell/tests/clientApi.test.ts deleted file mode 100644 index 92922dec7..000000000 --- a/apps/ai-game-creator-shell/tests/clientApi.test.ts +++ /dev/null @@ -1,288 +0,0 @@ -/** @vitest-environment jsdom */ -import { afterEach, beforeEach, expect, it, vi } from 'vitest'; - -import type { AuthUser } from '../../../packages/shared/src/contracts/auth'; -import { - requestClientApi, - setStoredAuthAccessToken, -} from '../src/services/clientApi'; -import { - getClientAuthRefreshOperation, - getStoredAuthAccessToken, - refreshClientAuthAccessToken, -} from '../src/services/clientAuth'; -import { - AGC_DEVELOPMENT_API_BASE_URL, - CLIENT_HTTP_DEFAULT_TIMEOUT_MS, -} from '../src/services/clientHttp'; -import { - cachedLlmModelCatalog, - refreshLlmModelCatalog, - resetLlmModelCatalogCacheForTest, -} from '../src/services/llmModelCatalog'; -import { - beginPlatformSessionTransition, - commitAuthenticatedPlatformSession, - currentPlatformSessionGeneration, - resetPlatformSessionStateForTests, -} from '../src/services/platformSession'; - -vi.mock('@tauri-apps/plugin-http', () => ({ fetch: vi.fn() })); -vi.mock( - '../../../packages/shared/src', - () => import('../../../packages/shared/src/http'), -); -vi.mock('../src/services/errorReporting', () => ({ - captureClientError: vi.fn(), -})); - -const user = { id: 'session-user' } as AuthUser; -const nativeInvoke = vi.fn(async () => null); -const catalog = { models: [{ id: 'quality', displayName: '高质量' }] }; -const json = (value: unknown, status = 200) => - new Response(JSON.stringify(value), { status }); - -beforeEach(async () => { - resetLlmModelCatalogCacheForTest(); - resetPlatformSessionStateForTests(); - window.localStorage.clear(); - nativeInvoke.mockClear(); - window.__TAURI__ = { core: { invoke: nativeInvoke } }; - setStoredAuthAccessToken('expired-token'); - await commitAuthenticatedPlatformSession( - user, - currentPlatformSessionGeneration(), - ); - nativeInvoke.mockClear(); -}); - -afterEach(() => { - vi.useRealTimers(); - resetLlmModelCatalogCacheForTest(); - resetPlatformSessionStateForTests(); - window.localStorage.clear(); - delete window.__TAURI__; - vi.restoreAllMocks(); -}); - -it.each([200, 503])( - '模型目录 HTTP %s 响应体卡住后超时,保留缓存且能再次刷新', - async (status) => { - vi.useFakeTimers(); - const previous = { ...catalog, defaultModelId: 'quality', revision: 1 }; - const updated = { - defaultModelId: 'fast', - models: [{ id: 'fast', displayName: '快速' }], - revision: 2, - }; - let body!: ReadableStreamDefaultController; - const stalledResponse = new Response( - new ReadableStream({ - start(controller) { - body = controller; - }, - }), - { status }, - ); - const fetch = vi - .spyOn(globalThis, 'fetch') - .mockResolvedValueOnce(json(previous)) - .mockResolvedValueOnce(stalledResponse) - .mockResolvedValueOnce(json(updated)); - await expect(refreshLlmModelCatalog()).resolves.toEqual(previous); - let failure: unknown; - const pending = refreshLlmModelCatalog().catch((error: unknown) => { - failure = error; - }); - try { - await vi.advanceTimersByTimeAsync(CLIENT_HTTP_DEFAULT_TIMEOUT_MS); - expect(failure).toMatchObject({ code: 'CLIENT_HTTP_TIMEOUT' }); - await pending; - expect(cachedLlmModelCatalog()).toEqual(previous); - await expect(refreshLlmModelCatalog()).resolves.toEqual(updated); - expect(fetch).toHaveBeenCalledTimes(3); - } finally { - // 迟到的响应不能在新刷新完成后覆盖缓存,同时释放测试流。 - body.enqueue(new TextEncoder().encode(JSON.stringify(previous))); - body.close(); - await pending; - } - expect(cachedLlmModelCatalog()).toEqual(updated); - }, -); - -it('并发模型请求共享续期,并在安装 Rust 会话后使用新 token 重试', async () => { - let refreshCalls = 0; - let modelCalls = 0; - const fetch = vi - .spyOn(globalThis, 'fetch') - .mockImplementation(async (input, init) => { - if (input === '/api/auth/refresh') { - refreshCalls += 1; - return json({ token: 'fresh-token' }); - } - if (input === '/api/auth/me') return json({ user }); - modelCalls += 1; - const token = new Headers(init?.headers).get('Authorization'); - if (token === 'Bearer expired-token') return json({}, 401); - expect(token).toBe('Bearer fresh-token'); - expect(nativeInvoke).toHaveBeenCalledWith( - 'install_platform_account_session', - expect.objectContaining({ - accessToken: 'fresh-token', - userId: user.id, - }), - ); - return json(catalog); - }); - - const results = await Promise.all([ - requestClientApi('/api/llm/models', { method: 'GET' }, '读取失败'), - requestClientApi('/api/llm/models', { method: 'GET' }, '读取失败'), - ]); - expect(results).toEqual([catalog, catalog]); - expect(refreshCalls).toBe(1); - expect(modelCalls).toBe(4); - expect(fetch).toHaveBeenCalledTimes(6); -}); - -it.each([401])( - '续期被明确拒绝时保留原 HTTP %s,且不重发业务请求', - async (status) => { - let refreshCalls = 0; - const fetch = vi - .spyOn(globalThis, 'fetch') - .mockImplementation(async (input) => { - if (String(input) === '/api/auth/refresh') { - refreshCalls += 1; - return json({}, 401); - } - return json({}, status); - }); - await expect( - requestClientApi('/api/llm/models', { method: 'GET' }, '读取失败'), - ).rejects.toMatchObject({ status }); - // 401 刷新先用当前 cookie 收敛重试一次;重试仍被拒绝才算登录态权威失效, - // 而且不能把一次卡片级失败放大成全局登出。 - expect(refreshCalls).toBe(2); - expect(fetch).toHaveBeenCalledTimes(3); - expect(getStoredAuthAccessToken()).toBe(''); - }, -); - -it('续期 401 后用当前 cookie 收敛重试并继续业务请求', async () => { - let refreshCalls = 0; - const fetch = vi - .spyOn(globalThis, 'fetch') - .mockImplementation(async (input, init) => { - if (String(input) === '/api/auth/refresh') { - refreshCalls += 1; - return refreshCalls === 1 - ? json({}, 401) - : json({ token: 'rotated-token' }); - } - if (String(input) === '/api/auth/me') return json({ user }); - const token = new Headers(init?.headers).get('Authorization'); - if (token === 'Bearer expired-token') return json({}, 401); - expect(token).toBe('Bearer rotated-token'); - return json(catalog); - }); - - await expect( - requestClientApi('/api/llm/models', { method: 'GET' }, '读取失败'), - ).resolves.toEqual(catalog); - expect(refreshCalls).toBe(2); - expect(getStoredAuthAccessToken()).toBe('rotated-token'); -}); - -it('跳过鉴权的请求不触发续期', async () => { - const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue(json({}, 401)); - await expect( - requestClientApi('/api/example', {}, '读取失败', { skipAuth: true }), - ).rejects.toMatchObject({ status: 401 }); - expect(fetch).toHaveBeenCalledTimes(1); -}); - -it('403 权限拒绝不触发续期或重发写请求', async () => { - const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue(json({}, 403)); - await expect( - requestClientApi( - '/api/example', - { method: 'POST', body: '{}' }, - '权限不足', - ), - ).rejects.toMatchObject({ status: 403 }); - expect(fetch).toHaveBeenCalledTimes(1); - expect(nativeInvoke).not.toHaveBeenCalled(); -}); - -it('续期成功后的再次未授权不循环重试', async () => { - const fetch = vi - .spyOn(globalThis, 'fetch') - .mockResolvedValueOnce(json({}, 401)) - .mockResolvedValueOnce(json({ token: 'fresh-token' })) - .mockResolvedValueOnce(json({ user })) - .mockResolvedValueOnce(json({}, 401)); - await expect( - requestClientApi('/api/llm/models', {}, '读取失败'), - ).rejects.toMatchObject({ status: 401 }); - expect(fetch).toHaveBeenCalledTimes(4); -}); - -it('请求期间账号切换后,不替新账号续期或重发旧请求', async () => { - let finish!: (response: Response) => void; - const fetch = vi.spyOn(globalThis, 'fetch').mockImplementation( - () => - new Promise((resolve) => { - finish = resolve; - }), - ); - const pending = requestClientApi('/api/llm/models', {}, '读取失败'); - const rejection = expect(pending).rejects.toMatchObject({ status: 401 }); - const generation = beginPlatformSessionTransition(); - setStoredAuthAccessToken('other-token'); - await commitAuthenticatedPlatformSession( - { ...user, id: 'other-user' }, - generation, - ); - finish(json({}, 401)); - await rejection; - expect(fetch).toHaveBeenCalledTimes(1); -}); - -it('响应体卡住超时后,下一次续期会重新发起请求', async () => { - vi.useFakeTimers(); - let refreshCalls = 0; - vi.spyOn(globalThis, 'fetch').mockImplementation((input) => { - if (input === '/api/auth/refresh') { - refreshCalls += 1; - } - return Promise.resolve( - new Response( - new ReadableStream({ - start() { - // Simulate headers returned while the body remains open. - }, - }), - { status: 200 }, - ), - ); - }); - - const first = refreshClientAuthAccessToken(AGC_DEVELOPMENT_API_BASE_URL); - const firstAssertion = expect(first).rejects.toThrow(); - await vi.advanceTimersByTimeAsync(15_000); - await firstAssertion; - expect( - getClientAuthRefreshOperation(AGC_DEVELOPMENT_API_BASE_URL), - ).toMatchObject({ - kind: 'auth-refresh', - phase: 'retryable-failure', - }); - - const second = refreshClientAuthAccessToken(AGC_DEVELOPMENT_API_BASE_URL); - const secondAssertion = expect(second).rejects.toThrow(); - expect(refreshCalls).toBe(2); - await vi.advanceTimersByTimeAsync(15_000); - await secondAssertion; -}); diff --git a/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts b/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts new file mode 100644 index 000000000..4b10a4a2f --- /dev/null +++ b/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts @@ -0,0 +1,187 @@ +// @vitest-environment jsdom +/** + * 登录态 façade 的边界。 + * + * 凭据持久化、续期 HTTP、原生会话安装都在 Rust;这里只验证渲染层提交的结构化参数、三态 + * 投影映射,以及没有原生宿主时的失败关闭。 + */ +import { beforeEach, expect, test, vi } from 'vitest'; + +const invoke = vi.fn(); + +import { + getClientAuthErrorMessage, + loginClientWithPassword, + loginClientWithPhoneCode, + logoutClientAuthSession, + normalizeAuthPhoneInput, + readClientAuthState, + refreshClientAuthSession, + sendClientPhoneLoginCode, +} from '../src/services/clientAuth'; + +const testUser = { + id: 'user-1', + publicUserCode: 'tn-1', + displayName: '测试用户', + avatarUrl: null, + phoneNumber: null, + phoneNumberMasked: '138****0000', + loginMethod: 'password', + bindingStatus: 'active', + wechatBound: false, + wechatDisplayName: null, + wechatAccount: null, +}; + +beforeEach(() => { + invoke.mockReset(); + window.__TAURI__ = { + core: { invoke: (...args: unknown[]) => invoke(...args) }, + } as never; +}); + +test('手机号归一化只保留可提交的纯号码', () => { + expect(normalizeAuthPhoneInput('138 0000 0000')).toBe('13800000000'); + expect(normalizeAuthPhoneInput('+8613800000000')).toBe('13800000000'); + expect(normalizeAuthPhoneInput('0086-138-0000-0000')).toBe('008613800000000'); +}); + +test('恢复登录态映射为 authenticated / unauthenticated / unavailable', async () => { + invoke.mockResolvedValueOnce({ + status: 'authenticated', + user: testUser, + apiBaseUrl: 'https://dev.genarrative.world', + }); + await expect( + readClientAuthState('https://dev.genarrative.world'), + ).resolves.toEqual({ + status: 'authenticated', + user: testUser, + apiBaseUrl: 'https://dev.genarrative.world', + }); + expect(invoke).toHaveBeenCalledWith('read_client_auth_state', { + expectedApiBaseUrl: 'https://dev.genarrative.world', + }); + + invoke.mockResolvedValueOnce({ status: 'unauthenticated' }); + await expect(readClientAuthState()).resolves.toEqual({ + status: 'unauthenticated', + }); + expect(invoke).toHaveBeenCalledWith('read_client_auth_state', { + expectedApiBaseUrl: null, + }); + + invoke.mockResolvedValueOnce({ + status: 'unavailable', + errorMessage: 'network-error: 无法连接登录服务', + }); + await expect(readClientAuthState()).resolves.toEqual({ + status: 'unavailable', + message: 'network-error: 无法连接登录服务', + }); +}); + +test('登录只提交结构化参数,凭据由 Rust 持有', async () => { + invoke.mockResolvedValue(testUser); + await expect( + loginClientWithPassword( + '138 0000 0000', + ' secret ', + 'https://dev.genarrative.world', + ), + ).resolves.toEqual(testUser); + await expect( + loginClientWithPhoneCode( + '13800000000', + ' 123456 ', + 'https://dev.genarrative.world', + ), + ).resolves.toEqual(testUser); + await expect( + sendClientPhoneLoginCode('13800000000', 'https://dev.genarrative.world'), + ).resolves.toEqual({ cooldownSeconds: 0, expiresInSeconds: 0 }); + await logoutClientAuthSession(); + + expect(invoke.mock.calls).toEqual([ + [ + 'login_client_with_password', + { + apiBaseUrl: 'https://dev.genarrative.world', + phone: '13800000000', + password: 'secret', + }, + ], + [ + 'login_client_with_phone_code', + { + apiBaseUrl: 'https://dev.genarrative.world', + phone: '13800000000', + code: '123456', + }, + ], + [ + 'send_client_phone_login_code', + { + apiBaseUrl: 'https://dev.genarrative.world', + phone: '13800000000', + }, + ], + ['logout_client_session'], + ]); + const serialized = JSON.stringify(invoke.mock.calls); + expect(serialized).not.toContain('token'); + expect(serialized).not.toContain('Authorization'); + expect(serialized).not.toContain('cookie'); +}); + +test('续期结果区分成功、失效、身份变化与暂时失败', async () => { + invoke.mockResolvedValueOnce({ status: 'refreshed', user: testUser }); + await expect(refreshClientAuthSession('user-1')).resolves.toEqual({ + status: 'refreshed', + user: testUser, + }); + + invoke.mockResolvedValueOnce({ status: 'unauthenticated' }); + await expect(refreshClientAuthSession()).resolves.toEqual({ + status: 'unauthenticated', + }); + + invoke.mockResolvedValueOnce({ status: 'stale' }); + await expect(refreshClientAuthSession()).resolves.toEqual({ + status: 'stale', + }); + + invoke.mockResolvedValueOnce({ + status: 'failed', + authoritative: false, + errorMessage: 'network-error: 无法连接登录服务', + }); + await expect(refreshClientAuthSession()).resolves.toEqual({ + status: 'failed', + message: 'network-error: 无法连接登录服务', + authoritative: false, + }); +}); + +test('没有原生宿主时登录能力明确失败关闭', async () => { + delete window.__TAURI__; + await expect(readClientAuthState()).rejects.toThrow( + '需要在 Tauri App 内登录', + ); + await expect( + loginClientWithPassword( + '13800000000', + 'secret', + 'https://dev.genarrative.world', + ), + ).rejects.toThrow('需要在 Tauri App 内登录'); + expect(invoke).not.toHaveBeenCalled(); +}); + +test('错误文案优先使用服务端原因,缺失时回落到调用方文案', () => { + expect( + getClientAuthErrorMessage(new Error('手机号或密码错误'), '登录失败'), + ).toBe('手机号或密码错误'); + expect(getClientAuthErrorMessage('', '登录失败')).toBe('登录失败'); +}); diff --git a/apps/ai-game-creator-shell/tests/clientAuthStorage.test.ts b/apps/ai-game-creator-shell/tests/clientAuthStorage.test.ts deleted file mode 100644 index a34adaf91..000000000 --- a/apps/ai-game-creator-shell/tests/clientAuthStorage.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -/** @vitest-environment jsdom */ -import { afterEach, describe, expect, it, vi } from 'vitest'; - -import { - getStoredAuthAccessToken as getApiAccessToken, - requestClientApi, -} from '../src/services/clientApi'; -import { - clearStoredAuthAccessToken, - getStoredAuthAccessToken, - setStoredAuthAccessToken, -} from '../src/services/clientAuth'; -import { AGC_DEVELOPMENT_API_BASE_URL } from '../src/services/clientHttp'; - -vi.mock('@tauri-apps/plugin-http', () => ({ fetch: vi.fn() })); -vi.mock('../src/services/errorReporting', () => ({ - captureClientError: vi.fn(), -})); - -const tokenKey = 'genarrative.auth.access-token.v1'; -const originKey = 'genarrative.auth.access-token-origin.v1'; -const selectionKey = 'genarrative.client.server-selection.v1'; - -describe('AGC platform credential origin', () => { - afterEach(() => { - window.localStorage.clear(); - vi.restoreAllMocks(); - vi.unstubAllEnvs(); - }); - - it('preserves a credential already marked as dev', () => { - window.localStorage.setItem(tokenKey, 'existing-dev-token'); - window.localStorage.setItem(originKey, AGC_DEVELOPMENT_API_BASE_URL); - - expect(getStoredAuthAccessToken()).toBe('existing-dev-token'); - expect(getApiAccessToken()).toBe('existing-dev-token'); - expect(window.localStorage.getItem(originKey)).toBe( - AGC_DEVELOPMENT_API_BASE_URL, - ); - }); - - it.each([ - JSON.stringify({ preset: 'dev', customBaseUrl: '' }), - JSON.stringify({ - preset: 'custom', - customBaseUrl: `${AGC_DEVELOPMENT_API_BASE_URL}/`, - }), - JSON.stringify({ preset: 'release', customBaseUrl: '' }), - JSON.stringify({ preset: 'custom', customBaseUrl: 'https://example.com' }), - JSON.stringify({ - preset: 'custom', - customBaseUrl: 'http://localhost:8082', - }), - JSON.stringify({ preset: 'unknown', customBaseUrl: '' }), - 'invalid-json', - 'null', - ])( - 'never infers a legacy credential origin from a saved preference: %s', - async (selection) => { - window.localStorage.setItem(tokenKey, 'other-server-token'); - window.localStorage.setItem(selectionKey, selection); - vi.stubEnv('MODE', 'production'); - const fetchMock = vi - .spyOn(globalThis, 'fetch') - .mockResolvedValue( - new Response(JSON.stringify({ result: true }), { status: 200 }), - ); - - await requestClientApi('/api/profile/dashboard', {}, '读取失败'); - - expect(fetchMock).toHaveBeenCalledTimes(1); - const [url, init] = fetchMock.mock.calls[0]; - expect(url).toBe(`${AGC_DEVELOPMENT_API_BASE_URL}/api/profile/dashboard`); - expect(new Headers(init?.headers).get('Authorization')).toBeNull(); - expect(window.localStorage.getItem(tokenKey)).toBeNull(); - }, - ); - - it.each([true, false])( - 'clears an unmarked credential without a preference (development=%s)', - (development) => { - // Vitest 0.34 stores stubbed env values as strings; use a falsy value for DEV=false. - vi.stubEnv('DEV', development ? 'true' : ''); - window.localStorage.setItem(tokenKey, 'legacy-token'); - - expect(getStoredAuthAccessToken()).toBe(''); - }, - ); - - it('does not relabel a credential that already belongs to another origin', () => { - window.localStorage.setItem(tokenKey, 'other-origin-token'); - window.localStorage.setItem(originKey, 'https://www.genarrative.world'); - window.localStorage.setItem( - selectionKey, - JSON.stringify({ preset: 'dev' }), - ); - - expect(getApiAccessToken()).toBe(''); - expect(window.localStorage.getItem(tokenKey)).toBeNull(); - expect(window.localStorage.getItem(originKey)).toBeNull(); - }); - - it('stores credentials with their origin and keeps debug server preferences', () => { - setStoredAuthAccessToken('new-token'); - window.localStorage.setItem( - selectionKey, - JSON.stringify({ preset: 'release' }), - ); - - expect(getStoredAuthAccessToken(AGC_DEVELOPMENT_API_BASE_URL)).toBe( - 'new-token', - ); - expect(getStoredAuthAccessToken('https://www.genarrative.world')).toBe(''); - setStoredAuthAccessToken('release-token', 'https://www.genarrative.world'); - expect(getStoredAuthAccessToken('https://www.genarrative.world')).toBe( - 'release-token', - ); - expect(window.localStorage.getItem(selectionKey)).toContain('release'); - clearStoredAuthAccessToken(); - expect(window.localStorage.getItem(tokenKey)).toBeNull(); - expect(window.localStorage.getItem(originKey)).toBeNull(); - }); -}); diff --git a/apps/ai-game-creator-shell/tests/clientHttp.test.ts b/apps/ai-game-creator-shell/tests/clientHttp.test.ts index 28c6b6938..30b480e52 100644 --- a/apps/ai-game-creator-shell/tests/clientHttp.test.ts +++ b/apps/ai-game-creator-shell/tests/clientHttp.test.ts @@ -1,283 +1,134 @@ -/** @vitest-environment jsdom */ -import { fetch as tauriHttpFetch } from '@tauri-apps/plugin-http'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +// @vitest-environment node +/** + * 客户端服务器选择的边界。 + * + * 正式包只跟随构建渠道(`VITE_AGC_PLATFORM_CHANNEL`),自定义地址只在本地 Vite debug 可用; + * 归一化必须拒绝把凭据、路径、查询、哈希或明文远程地址带进登录请求——这些值随后会作为 + * origin 交给 Rust 与后端,渲染层这一层只负责即时反馈与内存投影。 + */ +import { beforeEach, expect, test, vi } from 'vitest'; -import { - API_RESPONSE_ENVELOPE_HEADER, - API_RESPONSE_ENVELOPE_VERSION, -} from '../../../packages/shared/src/http'; -import { - AGC_CLIENT_MARKER_HEADER, - AGC_CLIENT_MARKER_VALUE, - AGC_DEVELOPMENT_API_BASE_URL, - ClientHttpTimeoutError, - fetchClientHttp, - getClientServerBaseUrl, - readClientHttpResponseText, - resolveClientHttpTarget, -} from '../src/services/clientHttp'; +const { invoke, resolveTauriInvoke } = vi.hoisted(() => { + const invokeMock = vi.fn(); + return { + invoke: invokeMock, + resolveTauriInvoke: vi.fn(() => invokeMock), + }; +}); -vi.mock('@tauri-apps/plugin-http', () => ({ - fetch: vi.fn(), -})); +vi.mock('../src/app/tauri', () => ({ resolveTauriInvoke })); -describe('AGC client HTTP transport', () => { - afterEach(() => { - vi.useRealTimers(); - vi.clearAllMocks(); - vi.unstubAllEnvs(); - vi.unstubAllGlobals(); - window.localStorage.clear(); - }); +async function loadClientHttp() { + return import('../src/services/clientHttp'); +} - it('adds the AGC marker while preserving and overriding request headers', async () => { - const fetchMock = vi - .fn() - .mockResolvedValue(new Response(null, { status: 204 })); - vi.stubGlobal('fetch', fetchMock); - const inputHeaders = new Headers({ - Authorization: 'Bearer fixture-token', - 'X-Request-ID': 'request-123', - [API_RESPONSE_ENVELOPE_HEADER]: API_RESPONSE_ENVELOPE_VERSION, - [AGC_CLIENT_MARKER_HEADER]: 'caller-value', - }); - const init: RequestInit = { - method: 'POST', - headers: inputHeaders, - body: '{}', - credentials: 'same-origin', - }; +beforeEach(() => { + vi.resetModules(); + vi.unstubAllEnvs(); + invoke.mockReset(); + resolveTauriInvoke.mockReturnValue(invoke); +}); - await fetchClientHttp('/api/auth/me', init); +test('非法服务器地址按原因逐条拒绝', async () => { + const { normalizeClientServerBaseUrl } = await loadClientHttp(); - expect(fetchMock).toHaveBeenCalledTimes(1); - const [target, forwardedInit] = fetchMock.mock.calls[0] as [ - string, - RequestInit, - ]; - const forwardedHeaders = new Headers(forwardedInit.headers); - expect(target).toBe('/api/auth/me'); - expect(forwardedHeaders.get(AGC_CLIENT_MARKER_HEADER)).toBe( - AGC_CLIENT_MARKER_VALUE, - ); - expect(forwardedHeaders.get('Authorization')).toBe('Bearer fixture-token'); - expect(forwardedHeaders.get('X-Request-ID')).toBe('request-123'); - expect(forwardedHeaders.get(API_RESPONSE_ENVELOPE_HEADER)).toBe( - API_RESPONSE_ENVELOPE_VERSION, - ); - expect(forwardedInit.method).toBe('POST'); - expect(forwardedInit.body).toBe('{}'); - expect(forwardedInit.credentials).toBe('same-origin'); - expect(inputHeaders.get(AGC_CLIENT_MARKER_HEADER)).toBe('caller-value'); - }); - - it.each([ - '/api/auth/me', - '/api/profile/dashboard', - '/api/editor/projects', - '/api/assets/read-bytes?objectKey=fixture', - ])('marks %s through the shared Web transport', async (url) => { - const fetchMock = vi - .fn() - .mockResolvedValue(new Response(null, { status: 204 })); - vi.stubGlobal('fetch', fetchMock); - - await fetchClientHttp(url, {}); - - const [, forwardedInit] = fetchMock.mock.calls[0] as [string, RequestInit]; - expect( - new Headers(forwardedInit.headers).get(AGC_CLIENT_MARKER_HEADER), - ).toBe(AGC_CLIENT_MARKER_VALUE); - }); - - it('adds the AGC marker to the Tauri HTTP transport', async () => { - const tauriFetchMock = vi.mocked(tauriHttpFetch); - tauriFetchMock.mockResolvedValue(new Response(null, { status: 204 })); - vi.stubEnv('MODE', 'production'); - vi.stubEnv('DEV', false); - vi.stubGlobal('window', { - __TAURI__: {}, - location: { protocol: 'tauri:' }, - }); - - await fetchClientHttp( - '/api/auth/me', - { headers: { Authorization: 'Bearer fixture-token' } }, - { serverBaseUrl: AGC_DEVELOPMENT_API_BASE_URL }, - ); - - expect(tauriFetchMock).toHaveBeenCalledTimes(1); - const [target, forwardedInit] = tauriFetchMock.mock.calls[0] as [ - string, - RequestInit, - ]; - const forwardedHeaders = new Headers(forwardedInit.headers); - expect(target).toBe(`${AGC_DEVELOPMENT_API_BASE_URL}/api/auth/me`); - expect(forwardedHeaders.get(AGC_CLIENT_MARKER_HEADER)).toBe( - AGC_CLIENT_MARKER_VALUE, - ); - expect(forwardedHeaders.get('Authorization')).toBe('Bearer fixture-token'); - }); - - it.each(['development', 'production'])( - 'routes %s Tauri requests through the active channel server', - (mode) => { - expect( - resolveClientHttpTarget('/api/auth/me', { - isTauri: true, - mode, - }), - ).toEqual({ - transport: 'tauri-http', - url: `${AGC_DEVELOPMENT_API_BASE_URL}/api/auth/me`, - }); - }, + expect(() => normalizeClientServerBaseUrl('dev.genarrative.world')).toThrow( + '服务器地址无效', ); - - it('keeps test fixtures on relative requests after origin validation', () => { - expect( - resolveClientHttpTarget('/api/auth/me', { - isTauri: false, - mode: 'test', - }), - ).toEqual({ transport: 'web', url: '/api/auth/me' }); - }); - - it('routes debug requests through an explicit custom server', () => { - expect( - resolveClientHttpTarget('https://example.com/api/auth/me', { - isTauri: true, - mode: 'development', - serverBaseUrl: 'https://example.com', - }), - ).toEqual({ - transport: 'tauri-http', - url: 'https://example.com/api/auth/me', - }); - }); - - it.each(['release', 'dev', 'custom'])( - 'honors persisted %s preference in debug requests', - (preset) => { - window.localStorage.setItem( - 'genarrative.client.server-selection.v1', - JSON.stringify({ - preset, - customBaseUrl: 'https://staging.example.com', - }), - ); - expect(getClientServerBaseUrl()).toBe( - preset === 'release' - ? 'https://www.genarrative.world' - : preset === 'custom' - ? 'https://staging.example.com' - : AGC_DEVELOPMENT_API_BASE_URL, - ); - expect( - resolveClientHttpTarget('/api/auth/me', { - isTauri: false, - mode: 'development', - }), - ).toEqual({ - transport: 'web', - url: `${getClientServerBaseUrl()}/api/auth/me`, - }); - }, + expect(() => normalizeClientServerBaseUrl('ftp://example.com')).toThrow( + '服务器地址必须是纯 HTTP(S) 地址', ); + expect(() => + normalizeClientServerBaseUrl('https://user:pass@example.com'), + ).toThrow('服务器地址必须是纯 HTTP(S) 地址'); + expect(() => normalizeClientServerBaseUrl('https://example.com/api')).toThrow( + '服务器地址必须是纯 HTTP(S) 地址', + ); + expect(() => + normalizeClientServerBaseUrl('https://example.com/?token=1'), + ).toThrow('服务器地址必须是纯 HTTP(S) 地址'); + expect(() => + normalizeClientServerBaseUrl('https://example.com/#frag'), + ).toThrow('服务器地址必须是纯 HTTP(S) 地址'); + expect(() => normalizeClientServerBaseUrl('http://example.com')).toThrow( + '非本机服务器必须使用 HTTPS', + ); +}); - it('keeps explicit server origin when sending a debug request', async () => { - const fetchMock = vi.fn(); - vi.stubGlobal('fetch', fetchMock); - fetchMock.mockResolvedValue(new Response(null, { status: 204 })); - await fetchClientHttp( - '/api/auth/me', - {}, - { - serverBaseUrl: 'https://www.genarrative.world', - }, - ); - expect(fetchMock).toHaveBeenCalledWith('/api/auth/me', expect.any(Object)); - expect(tauriHttpFetch).not.toHaveBeenCalled(); +test('合法地址去掉尾部斜杠并保留回环端口', async () => { + const { normalizeClientServerBaseUrl } = await loadClientHttp(); + + expect(normalizeClientServerBaseUrl('https://dev.genarrative.world///')).toBe( + 'https://dev.genarrative.world', + ); + expect(normalizeClientServerBaseUrl(' http://127.0.0.1:10001/ ')).toBe( + 'http://127.0.0.1:10001', + ); + expect(normalizeClientServerBaseUrl('http://localhost:8080')).toBe( + 'http://localhost:8080', + ); +}); + +test('预设映射到渠道地址,自定义走归一化', async () => { + const mod = await loadClientHttp(); + + expect( + mod.getClientServerBaseUrl({ preset: 'release', customBaseUrl: '' }), + ).toBe(mod.AGC_RELEASE_API_BASE_URL); + expect(mod.getClientServerBaseUrl({ preset: 'dev', customBaseUrl: '' })).toBe( + mod.AGC_DEVELOPMENT_API_BASE_URL, + ); + expect( + mod.getClientServerBaseUrl({ + preset: 'custom', + customBaseUrl: 'https://staging.example.com/', + }), + ).toBe('https://staging.example.com'); + expect(mod.isClientServerPreset('custom')).toBe(true); + expect(mod.isClientServerPreset('staging')).toBe(false); +}); + +test('开发态切换服务器会落盘并在内存中生效', async () => { + vi.stubEnv('DEV', true); + const mod = await loadClientHttp(); + + expect(mod.isClientServerSelectionEnabled()).toBe(true); + expect( + mod.setClientServerSelection({ + preset: 'custom', + customBaseUrl: ' https://staging.example.com/ ', + }), + ).toEqual({ preset: 'custom', customBaseUrl: 'https://staging.example.com' }); + await Promise.resolve(); + expect(invoke).toHaveBeenCalledWith('set_client_server_selection', { + preset: 'custom', + customBaseUrl: 'https://staging.example.com', + }); + expect(mod.getClientServerBaseUrl()).toBe('https://staging.example.com'); +}); + +test('开发态从 Rust 偏好恢复服务器选择', async () => { + vi.stubEnv('DEV', true); + const mod = await loadClientHttp(); + invoke.mockResolvedValue({ + clientServerSelection: { + preset: 'custom', + customBaseUrl: 'https://restored.example.com', + }, }); - it('aborts a stalled Web request at the configured timeout', async () => { - vi.useFakeTimers(); - const fetchMock = vi.fn(() => new Promise(() => {})); - vi.stubGlobal('fetch', fetchMock); - - const request = fetchClientHttp('/api/auth/me', {}, { timeoutMs: 25 }); - const timeoutAssertion = expect(request).rejects.toMatchObject({ - name: 'ClientHttpTimeoutError', - code: 'CLIENT_HTTP_TIMEOUT', - timeoutMs: 25, - url: '/api/auth/me', - }); - await vi.advanceTimersByTimeAsync(25); - await timeoutAssertion; - expect(fetchMock).toHaveBeenCalledTimes(1); - const [, forwardedInit] = fetchMock.mock.calls[0] as [string, RequestInit]; - expect(forwardedInit.signal).toBeInstanceOf(AbortSignal); - expect((forwardedInit.signal as AbortSignal).aborted).toBe(true); + await expect(mod.hydrateClientServerSelection()).resolves.toEqual({ + preset: 'custom', + customBaseUrl: 'https://restored.example.com', }); - - it('times out after response headers when the response body never completes', async () => { - vi.useFakeTimers(); - const response = new Response( - new ReadableStream({ - start() { - // Keep the stream open forever: headers exist, body does not finish. - }, - }), - ); - const read = readClientHttpResponseText(response, { - timeoutMs: 25, - url: '/api/auth/refresh', - }); - const assertion = expect(read).rejects.toMatchObject({ - name: 'ClientHttpTimeoutError', - code: 'CLIENT_HTTP_TIMEOUT', - timeoutMs: 25, - url: '/api/auth/refresh', - }); - await vi.advanceTimersByTimeAsync(25); - await assertion; - }); - - it('preserves caller AbortError and does not report it as a timeout', async () => { - const fetchMock = vi.fn( - (_url: string, init: RequestInit) => - new Promise((_, reject) => { - init.signal?.addEventListener('abort', () => { - reject( - new DOMException('The operation was aborted.', 'AbortError'), - ); - }); - }), - ); - vi.stubGlobal('fetch', fetchMock); - const callerController = new AbortController(); - const request = fetchClientHttp( - '/api/auth/me', - { signal: callerController.signal }, - { timeoutMs: 10_000 }, - ); - - callerController.abort(); - - await expect(request).rejects.toMatchObject({ name: 'AbortError' }); - await expect(request).rejects.not.toBeInstanceOf(ClientHttpTimeoutError); - }); - - it('allows explicitly disabling the timeout for long-running requests', async () => { - const fetchMock = vi - .fn() - .mockResolvedValue(new Response(null, { status: 204 })); - vi.stubGlobal('fetch', fetchMock); - - await expect( - fetchClientHttp('/api/agent/run', {}, { timeoutMs: null }), - ).resolves.toBeInstanceOf(Response); - const [, forwardedInit] = fetchMock.mock.calls[0] as [string, RequestInit]; - expect(forwardedInit.signal).toBeUndefined(); + expect(invoke).toHaveBeenCalledWith('read_workspace_preferences'); + expect(mod.getClientServerSelection()).toEqual({ + preset: 'custom', + customBaseUrl: 'https://restored.example.com', }); }); + +// 「打包态不开放服务器切换」这条规则这里测不了:`import.meta.env.DEV` 在测试运行下恒为 +// true,`vi.stubEnv('DEV', false)` 改不动它(Vite/Vitest 把它按构建期常量处理),因此 +// `isClientServerSelectionEnabled()` 与 `getClientServerSelection()` 的打包分支只能在真实 +// 发布构建里核对。本文件覆盖的是地址归一化、预设映射、开发态落盘与偏好恢复。 diff --git a/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx b/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx index c7441a14f..775473e02 100644 --- a/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx +++ b/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx @@ -17,46 +17,30 @@ import { type ConversationModelSelectHandle, } from '../src/features/project-workspace/ConversationModelSelect'; import { - ClientAuthRequestError, type ClientLlmModelCatalog, - loadClientLlmModels, -} from '../src/services/clientApi'; -import { ClientHttpTimeoutError } from '../src/services/clientHttp'; -import { notifyLlmConfigChanged, resetLlmModelCatalogCacheForTest, } from '../src/services/llmModelCatalog'; vi.mock('../src/app/tauri', () => ({ resolveTauriInvoke: vi.fn() })); -const MockClientAuthRequestError = vi.hoisted( - () => - class MockClientAuthRequestError extends Error { - readonly status: number | null; - readonly networkError: boolean; - - constructor( - message: string, - options: { status?: number | null; networkError?: boolean } = {}, - ) { - super(message); - this.status = options.status ?? null; - this.networkError = options.networkError ?? false; - } - }, -); -vi.mock('../src/services/clientApi', () => ({ - ClientAuthRequestError: MockClientAuthRequestError, - loadClientLlmModels: vi.fn(), -})); -vi.mock('@tauri-apps/plugin-http', () => ({ fetch: vi.fn() })); +/** + * 目录读取现在由 Rust typed command 提供:官方目录替身只需接管 + * `load_game_creator_llm_models` 的返回值,目录缓存、单飞与自定义模式语义仍走真实实现。 + */ +const loadClientLlmModels = vi.hoisted(() => vi.fn()); const invoke = vi.fn(); +const nativeInvoke = vi.fn((command: string, input: unknown) => + command === 'load_game_creator_llm_models' + ? loadClientLlmModels() + : invoke(command, input), +); let savedModelId = 'quality'; let savedModelIsDefault = true; beforeEach(() => { vi.clearAllMocks(); resetLlmModelCatalogCacheForTest(); - vi.mocked(resolveTauriInvoke).mockReturnValue(invoke); + vi.mocked(resolveTauriInvoke).mockReturnValue(nativeInvoke); vi.mocked(loadClientLlmModels).mockResolvedValue({ defaultModelId: 'quality', models: [ @@ -242,20 +226,16 @@ test('confirms a manual refresh even when the catalog revision is unchanged', as test.each([ [ - 'HTTP 404', - new ClientAuthRequestError('private server detail', { status: 404 }), - '模型列表加载失败(HTTP 404)', + '登录失效', + new Error('authentication-required: 陶泥儿登录态已过期'), + '模型列表加载失败:登录已失效,请重新登录', ], [ - 'HTTP 401', - new ClientAuthRequestError('private server detail', { status: 401 }), - '模型列表加载失败(HTTP 401)', - ], - [ - 'timeout', - new ClientHttpTimeoutError('https://private.example/models', 15000), - '模型列表请求超时,请重试', + 'network-error', + new Error('network-error: 无法连接登录服务'), + '模型列表加载失败', ], + ['timeout', new Error('模型目录请求超时'), '模型列表请求超时,请重试'], ['unknown', new Error('private server detail'), '模型列表加载失败'], ])( 'reports a safe %s failure with cached models and permits retry without claiming success', diff --git a/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx b/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx index 40acfd0a5..6cb5e3856 100644 --- a/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx +++ b/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx @@ -12,12 +12,19 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import type { GameCreatorDirectActiveTurn } from '../src/app/types'; import { - DIRECT_ACTIVE_TURNS_POLL_INTERVAL_MS, + DIRECT_ACTIVE_TURNS_CHANGED_EVENT, useDirectActiveTurns, } from '../src/features/agent-runtime/directActiveTurns'; import { ActiveProjectRunsPanel } from '../src/features/app-shell/ActiveProjectRunsPanel'; +import { createTauriEventFake, type TauriEventFake } from './tauriEventFake'; -afterEach(() => cleanup()); +let eventFake: TauriEventFake | null = null; + +afterEach(() => { + cleanup(); + eventFake?.restore(); + eventFake = null; +}); const ACTIVE_TURN = { projectPath: 'C:/projects/demo', @@ -26,15 +33,13 @@ const ACTIVE_TURN = { } as unknown as GameCreatorDirectActiveTurn; describe('useDirectActiveTurns', () => { - it('keeps the snapshot identity when the poll returns the same content', async () => { - // 回归点:轮询每次都 setActiveTurns(新数组) 会让所有依赖 activeTurns 的 effect - // 反复重跑(窗口标题栏的活动项目面板曾因此无限 setState)。 + it('keeps the snapshot identity when an event-triggered read returns the same content', async () => { + // 回归点:事件重复或内容未变时不能换掉数组身份,避免窗口标题栏重新发布。 const invoke = vi.fn(async () => [ACTIVE_TURN]) as never; const { result } = renderHook(() => useDirectActiveTurns({ invoke, enabled: true, - pollIntervalMs: DIRECT_ACTIVE_TURNS_POLL_INTERVAL_MS, }), ); @@ -50,6 +55,101 @@ describe('useDirectActiveTurns', () => { expect(result.current.activeTurns).toBe(firstSnapshot); }); + it('subscribes before the first snapshot and does not read while quiet', async () => { + const invoke = vi.fn(async () => [ACTIVE_TURN]); + eventFake = createTauriEventFake({ invoke }); + eventFake.install(); + const setIntervalSpy = vi.spyOn(window, 'setInterval'); + const { result } = renderHook(() => + useDirectActiveTurns({ invoke: invoke as never, enabled: true }), + ); + + await waitFor(() => + expect( + eventFake?.listenInvokeCounts.get(DIRECT_ACTIVE_TURNS_CHANGED_EVENT), + ).toBe(1), + ); + const listReads = () => + invoke.mock.calls.filter( + ([command]) => command === 'list_game_creator_direct_active_turns', + ).length; + await waitFor(() => expect(result.current.activeTurns).toHaveLength(1)); + expect(listReads()).toBe(1); + // The native event registration is already in place before the first list read; + // the fake keeps the callback eval pending until we explicitly flush it. + expect(eventFake.pendingRegistrationEvals()).toBe(1); + eventFake.flushRegistrationEvals(); + expect(setIntervalSpy.mock.calls.some(([, delay]) => delay === 5_000)).toBe( + false, + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(listReads()).toBe(1); + setIntervalSpy.mockRestore(); + }); + + it('coalesces repeated in-flight notifications into one follow-up read', async () => { + let resolveFirst!: (turns: GameCreatorDirectActiveTurn[]) => void; + const first = new Promise((resolve) => { + resolveFirst = resolve; + }); + const invoke = vi + .fn() + .mockReturnValueOnce(first) + .mockResolvedValue([ACTIVE_TURN]); + eventFake = createTauriEventFake({ invoke }); + eventFake.install(); + renderHook(() => + useDirectActiveTurns({ invoke: invoke as never, enabled: true }), + ); + await waitFor(() => + expect( + eventFake?.listenInvokeCounts.get(DIRECT_ACTIVE_TURNS_CHANGED_EVENT), + ).toBe(1), + ); + eventFake.flushRegistrationEvals(); + await waitFor(() => expect(invoke).toHaveBeenCalledTimes(1)); + + eventFake.emit(DIRECT_ACTIVE_TURNS_CHANGED_EVENT, { revision: 1 }); + eventFake.emit(DIRECT_ACTIVE_TURNS_CHANGED_EVENT, { revision: 2 }); + resolveFirst([ACTIVE_TURN]); + await waitFor(() => expect(invoke).toHaveBeenCalledTimes(2)); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(invoke).toHaveBeenCalledTimes(2); + }); + + it('keeps the last snapshot and reports observability failure when an event read fails', async () => { + let fail = false; + const invoke = vi.fn(async () => { + if (fail) { + throw new Error('snapshot unavailable'); + } + return [ACTIVE_TURN]; + }); + eventFake = createTauriEventFake({ invoke }); + eventFake.install(); + const { result } = renderHook(() => + useDirectActiveTurns({ invoke: invoke as never, enabled: true }), + ); + eventFake.flushRegistrationEvals(); + await waitFor(() => expect(result.current.activeTurns).toHaveLength(1)); + const firstSnapshot = result.current.activeTurns; + + vi.useFakeTimers(); + try { + fail = true; + act(() => { + eventFake?.emit(DIRECT_ACTIVE_TURNS_CHANGED_EVENT, { revision: 1 }); + }); + await act(async () => { + await vi.runAllTimersAsync(); + }); + expect(result.current.activeTurns).toBe(firstSnapshot); + expect(result.current.snapshotReadFailed).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + it('clears to a stable empty snapshot when the hook is disabled', async () => { let resolveSnapshot!: (turns: GameCreatorDirectActiveTurn[]) => void; const snapshot = new Promise((resolve) => { @@ -58,7 +158,7 @@ describe('useDirectActiveTurns', () => { const invoke = vi.fn(() => snapshot) as never; const { result, rerender } = renderHook( ({ enabled }: { enabled: boolean }) => - useDirectActiveTurns({ invoke, enabled, pollIntervalMs: 60_000 }), + useDirectActiveTurns({ invoke, enabled }), { initialProps: { enabled: true } }, ); diff --git a/apps/ai-game-creator-shell/tests/directThreadChatSubscription.test.tsx b/apps/ai-game-creator-shell/tests/directThreadChatSubscription.test.tsx new file mode 100644 index 000000000..91d0061a9 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/directThreadChatSubscription.test.tsx @@ -0,0 +1,109 @@ +// @vitest-environment jsdom +import { act, cleanup, renderHook, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { useDirectThreadChatSubscription } from '../src/view/project-development/chat/controller/useDirectThreadChatSubscription'; +import { createTauriEventFake, type TauriEventFake } from './tauriEventFake'; + +const BOOTSTRAP = { + subscriptionId: 'direct-subscription-1', + lastCompletedItemId: null, + events: [], +}; + +let eventFake: TauriEventFake | null = null; + +afterEach(() => { + cleanup(); + eventFake?.restore(); + eventFake = null; + delete window.__TAURI__; + delete window.__TAURI_INTERNALS__; +}); + +function installEventFake( + invoke: (command: string, args?: Record) => Promise, +) { + eventFake = createTauriEventFake({ invoke }); + eventFake.install(); + return eventFake; +} + +describe('useDirectThreadChatSubscription', () => { + it('卸载时幂等注销 native subscriber', async () => { + const unsubscribeCalls: Record[] = []; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'subscribe_direct_project_thread') return BOOTSTRAP; + if (command === 'consume_direct_project_thread') return { events: [] }; + if (command === 'unsubscribe_direct_project_thread') { + unsubscribeCalls.push(args ?? {}); + } + return null; + }, + ); + installEventFake(invoke); + + const { unmount } = renderHook(() => + useDirectThreadChatSubscription({ + enabled: true, + projectPath: 'C:/projects/demo', + }), + ); + await waitFor(() => + expect(invoke).toHaveBeenCalledWith('subscribe_direct_project_thread', { + projectPath: 'C:/projects/demo', + }), + ); + + unmount(); + await act(async () => { + await Promise.resolve(); + }); + + expect(unsubscribeCalls).toEqual([ + { subscriptionId: BOOTSTRAP.subscriptionId }, + ]); + }); + + it('bootstrap 迟到于卸载时仍注销已登记的 native subscriber', async () => { + let resolveBootstrap!: (value: typeof BOOTSTRAP) => void; + const unsubscribeCalls: Record[] = []; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'subscribe_direct_project_thread') { + return new Promise((resolve) => { + resolveBootstrap = resolve; + }); + } + if (command === 'unsubscribe_direct_project_thread') { + unsubscribeCalls.push(args ?? {}); + } + return { events: [] }; + }, + ); + installEventFake(invoke); + + const { unmount } = renderHook(() => + useDirectThreadChatSubscription({ + enabled: true, + projectPath: 'C:/projects/demo', + }), + ); + await waitFor(() => + expect(invoke).toHaveBeenCalledWith('subscribe_direct_project_thread', { + projectPath: 'C:/projects/demo', + }), + ); + + unmount(); + await act(async () => { + resolveBootstrap(BOOTSTRAP); + await Promise.resolve(); + }); + + expect(unsubscribeCalls).toEqual([ + { subscriptionId: BOOTSTRAP.subscriptionId }, + ]); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/errorReporting.test.ts b/apps/ai-game-creator-shell/tests/errorReporting.test.ts index 87a78bd73..ec1788898 100644 --- a/apps/ai-game-creator-shell/tests/errorReporting.test.ts +++ b/apps/ai-game-creator-shell/tests/errorReporting.test.ts @@ -69,23 +69,13 @@ vi.mock('@tauri-apps/api/core', () => ({ return undefined; }), })); -vi.mock('../src/services/clientAuth', () => ({ - getStoredAuthAccessToken: vi.fn(() => 'test-token'), -})); -vi.mock('../src/services/clientHttp', () => ({ - fetchClientHttp: vi.fn(), - getClientServerBaseUrl: vi.fn(() => 'https://example.test'), -})); - import { invoke } from '@tauri-apps/api/core'; -import { fetchClientHttp } from '../src/services/clientHttp'; import { ackClientErrorEventsWithRetry, captureAgentRuntimeError, captureClientError, getPendingClientErrorEvents, - getStableErrorReportSubmissionId, installWebviewLogBridge, markClientErrorEventsSubmitted, normalizeDiagnosticText, @@ -144,11 +134,6 @@ describe('客户端错误报告池', () => { const second = await captureClientError(new Error('第二个错误'), { source: 'test', }); - vi.mocked(fetchClientHttp).mockResolvedValue( - new Response(JSON.stringify({ data: { batchId: 'batch-1' } }), { - status: 200, - }), - ); await submitErrorReportBatch({ events: [first], logs: [] }); await markClientErrorEventsSubmitted([first]); @@ -156,14 +141,34 @@ describe('客户端错误报告池', () => { expect(await getPendingClientErrorEvents()).toEqual([second]); }); - it('同一批事件复用稳定 submissionId', async () => { + it('提交只把脱敏事件交给 Rust,不在渲染层携带凭据或 submissionId', async () => { const event = await captureClientError(new Error('可重试错误'), { source: 'test', }); - const firstId = getStableErrorReportSubmissionId([event]); - const secondId = getStableErrorReportSubmissionId([event]); + vi.mocked(invoke).mockImplementationOnce(async (command: string) => { + if (command !== 'submit_error_report') { + throw new Error(`unexpected invoke ${command}`); + } + return { batchId: 'batch-1' }; + }); - expect(secondId).toBe(firstId); + await submitErrorReportBatch({ + events: [event], + logs: [{ name: 'application.log', content: 'line' }], + userDescription: ' 说明 ', + }); + + expect(invoke).toHaveBeenCalledWith('submit_error_report', { + events: [event], + logs: [{ name: 'application.log', content: 'line' }], + userDescription: '说明', + }); + const [, args] = vi.mocked(invoke).mock.calls.at(-1) ?? []; + const serialized = JSON.stringify(args); + expect(serialized).not.toContain('token'); + expect(serialized).not.toContain('Authorization'); + expect(serialized).not.toContain('submissionId'); + expect(serialized).not.toContain('http'); }); it('本地 ack 失败时自动重试且不向调用方抛错', async () => { @@ -190,14 +195,18 @@ describe('客户端错误报告池', () => { vi.useRealTimers(); }); - it('未登录时拒绝提交且不发请求', async () => { - const auth = await import('../src/services/clientAuth'); - vi.mocked(auth.getStoredAuthAccessToken).mockReturnValue(''); + it('登录态缺失时把 Rust 的稳定分类原样透出,不清除本地事件', async () => { + const event = await captureClientError(new Error('未登录错误'), { + source: 'test', + }); + vi.mocked(invoke).mockRejectedValueOnce( + new Error('authentication-required: 请先登录后再提交错误报告'), + ); await expect( - submitErrorReportBatch({ events: [], logs: [] }), + submitErrorReportBatch({ events: [event], logs: [] }), ).rejects.toThrow('请先登录'); - expect(fetchClientHttp).not.toHaveBeenCalled(); + expect(await getPendingClientErrorEvents()).toEqual([event]); }); it('只采集网络错误、408 和 5xx', () => { diff --git a/apps/ai-game-creator-shell/tests/gameDistributionPublish.test.ts b/apps/ai-game-creator-shell/tests/gameDistributionPublish.test.ts index e4aa8e332..e1213d6a4 100644 --- a/apps/ai-game-creator-shell/tests/gameDistributionPublish.test.ts +++ b/apps/ai-game-creator-shell/tests/gameDistributionPublish.test.ts @@ -1,26 +1,8 @@ // @vitest-environment jsdom -import { afterEach, beforeEach, expect, test, vi } from 'vitest'; - -const fetchClientHttp = vi.fn(); - -vi.mock('../src/services/clientHttp', () => ({ - AGC_DEVELOPMENT_API_BASE_URL: 'https://dev.genarrative.world', - fetchClientHttp: (...args: unknown[]) => fetchClientHttp(...args), - getClientServerBaseUrl: () => 'https://dev.genarrative.world', - readClientHttpResponseText: (response: Response) => response.text(), -})); - -vi.mock('../src/services/errorReporting', () => ({ - captureClientError: vi.fn(), -})); - -// 原生侧上传需要登录凭据;这里只钉住「取到了 token」这一件事。 -vi.mock('../src/services/clientApi', async (importOriginal) => ({ - ...(await importOriginal()), - getStoredAuthAccessToken: () => 'test-access-token', -})); +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; +import type { TauriInvoke } from '../src/app/types'; import { generateGameDistributionCover, publishLocalProjectGame, @@ -35,313 +17,221 @@ const MANIFEST = { goal: '守住轨道城', } as unknown as GameCreationAppManifest; -/** 归一化发行包的暂存摘要;发布链路只应传递它,不再传整包字节。 */ -const STAGED_PACKAGE = { - stagingPath: 'C:/app-data/game-package-staging/aaaa.zip', +const PUBLISH_RESULT = { + gameId: 'game_1', + versionId: 'gamever_1', + versionNumber: 1, + status: 'pending_review', packageSha256: 'a'.repeat(64), packageSizeBytes: 1024, - packageFileCount: 1, + fileCount: 1, }; -function jsonResponse(payload: unknown) { - return new Response( - JSON.stringify({ - ok: true, - data: payload, - error: null, - meta: { apiVersion: 'v1' }, - }), - { - status: 200, - headers: { 'Content-Type': 'application/json' }, +type InvokeCall = { + command: string; + args: Record | undefined; +}; + +function installNativeInvoke( + implementation: (command: string, args?: Record) => unknown, +) { + const calls: InvokeCall[] = []; + const invoke = vi.fn( + async (command: string, args?: Record) => { + calls.push({ command, args }); + return implementation(command, args); }, - ); + ) as unknown as TauriInvoke; + return { invoke, calls }; } beforeEach(() => { - fetchClientHttp.mockReset(); window.localStorage.clear(); }); afterEach(() => { vi.useRealTimers(); + delete (window as unknown as { __TAURI__?: unknown }).__TAURI__; }); -test('发布时携带本地项目标识,让重复发布复用同一个平台游戏', async () => { - fetchClientHttp - .mockResolvedValueOnce( - jsonResponse({ id: 'game_1', publicationRevision: 0 }), - ) - .mockResolvedValueOnce( - jsonResponse({ - gameId: 'game_1', - versionId: 'gamever_1', - versionNumber: 1, - status: 'awaiting_upload', - }), - ) - .mockResolvedValueOnce( - jsonResponse({ version: { status: 'pending_review' } }), - ); - - const invokeCalls: Array<{ command: string; args: unknown }> = []; - const result = await publishLocalProjectGame({ - invoke: (async (command: string, args?: Record) => { - invokeCalls.push({ command, args }); - if (command === 'prepare_local_project_game_package') { - return STAGED_PACKAGE; - } - if (command === 'upload_local_project_game_package') { - return { - versionId: 'gamever_1', - status: 'uploaded', - uploadedBytes: STAGED_PACKAGE.packageSizeBytes, - }; - } +describe('Rust 发布 facade', () => { + test('发布只通过一个 native command 传递本地路径、资料和幂等键', async () => { + const { invoke, calls } = installNativeInvoke((command) => { + if (command === 'publish_local_project_game') return PUBLISH_RESULT; throw new Error(`未预期的命令:${command}`); - }) as never, - projectPath: '/tmp/project', - packageRelativePath: 'exports/playtest-package-1.zip', - manifest: MANIFEST, - metadata: { - coverAssetId: 'asset_cover', - screenshots: ['asset_shot_1', 'asset_shot_2'], - }, - }); + }); - const createGameCall = fetchClientHttp.mock.calls[0]; - expect(createGameCall?.[0]).toBe('/api/game-distribution/games'); - const createGameBody = JSON.parse( - String((createGameCall?.[1] as RequestInit).body), - ); - expect(createGameBody.localProjectId).toBe('local-proj-1'); - expect(createGameBody.coverAssetId).toBe('asset_cover'); - expect(createGameBody.screenshots).toEqual(['asset_shot_1', 'asset_shot_2']); - - const createVersionCall = fetchClientHttp.mock.calls[1]; - expect(createVersionCall?.[0]).toBe( - '/api/game-distribution/games/game_1/versions', - ); - expect( - JSON.parse(String((createVersionCall?.[1] as RequestInit).body)) - .localProjectId, - ).toBe('local-proj-1'); - - expect(result.gameId).toBe('game_1'); - expect(result.versionId).toBe('gamever_1'); - - // 关键回归:整包字节不再经过 IPC,上传交给原生侧按版本 ID + 暂存路径完成。 - const uploadCall = invokeCalls.find( - (call) => call.command === 'upload_local_project_game_package', - ); - expect(uploadCall?.args).toMatchObject({ - stagingPath: STAGED_PACKAGE.stagingPath, - versionId: 'gamever_1', - apiBaseUrl: 'https://dev.genarrative.world', - accessToken: 'test-access-token', - }); - expect(Object.keys(uploadCall?.args ?? {})).not.toContain('packageBytes'); - // 三次 HTTP:创建游戏、创建版本、送审;上传不再占用一条 HTTP 调用。 - expect(fetchClientHttp).toHaveBeenCalledTimes(3); -}); - -test('缺少本地项目标识时在发起请求前失败关闭', async () => { - await expect( - publishLocalProjectGame({ - invoke: (async () => STAGED_PACKAGE) as never, - projectPath: '/tmp/project', - packageRelativePath: 'exports/playtest-package-1.zip', - manifest: { ...MANIFEST, projectId: ' ' } as GameCreationAppManifest, - metadata: { coverAssetId: 'asset_cover' }, - }), - ).rejects.toThrow('发布需要本地项目标识'); - expect(fetchClientHttp).not.toHaveBeenCalled(); -}); - -test('缺少封面时在创建游戏前失败关闭', async () => { - await expect( - publishLocalProjectGame({ - invoke: (async () => STAGED_PACKAGE) as never, - projectPath: '/tmp/project', - packageRelativePath: 'exports/playtest-package-1.zip', - manifest: MANIFEST, - metadata: { coverAssetId: ' ' }, - }), - ).rejects.toThrow('请先选择游戏封面'); - expect(fetchClientHttp).not.toHaveBeenCalled(); -}); - -test('截图超过 6 张时在创建游戏前失败关闭', async () => { - await expect( - publishLocalProjectGame({ - invoke: (async () => STAGED_PACKAGE) as never, + const result = await publishLocalProjectGame({ + invoke, projectPath: '/tmp/project', packageRelativePath: 'exports/playtest-package-1.zip', manifest: MANIFEST, metadata: { coverAssetId: 'asset_cover', - screenshots: Array.from({ length: 7 }, (_, index) => `asset_${index}`), + screenshots: ['asset_shot_1', 'asset_shot_2'], }, - }), - ).rejects.toThrow('游戏截图最多 6 张'); - expect(fetchClientHttp).not.toHaveBeenCalled(); -}); + idempotencyKey: 'agc-publish-test', + }); -test('发布灰度按后端运行时配置判定,命中才开放入口', async () => { - fetchClientHttp.mockResolvedValueOnce( - jsonResponse({ gameDistributionPublishEnabled: true }), - ); - await expect(readGamePublishAvailability()).resolves.toBe(true); - expect(fetchClientHttp.mock.calls[0]?.[0]).toBe( - '/api/runtime/frontend-config', - ); - - fetchClientHttp.mockResolvedValueOnce( - jsonResponse({ gameDistributionPublishEnabled: false }), - ); - await expect(readGamePublishAvailability()).resolves.toBe(false); - - // 老后端/字段缺失时按未命中处理,入口不暴露。 - fetchClientHttp.mockResolvedValueOnce(jsonResponse({})); - await expect(readGamePublishAvailability()).resolves.toBe(false); -}); - -test('发布灰度读取失败时抛出,由调用方按不开放处理', async () => { - fetchClientHttp.mockRejectedValueOnce(new Error('network down')); - await expect(readGamePublishAvailability()).rejects.toThrow(); -}); - -test('免费发布资料建议只上传脱敏上下文并返回白名单分类', async () => { - fetchClientHttp.mockResolvedValueOnce( - jsonResponse({ summary: '驾驶炮台守住轨道城', category: '策略' }), - ); - - await expect( - suggestGameDistributionPublishMetadata({ - name: '星轨防线', - goal: '守住轨道城', - context: '当前任务与状态:原型已完成;现有素材:image:assets/hero.png', - }), - ).resolves.toEqual({ - summary: '驾驶炮台守住轨道城', - category: '策略', + expect(result).toEqual(PUBLISH_RESULT); + expect(calls).toHaveLength(1); + expect(calls[0]?.command).toBe('publish_local_project_game'); + expect(calls[0]?.args).toMatchObject({ + projectPath: '/tmp/project', + packageRelativePath: 'exports/playtest-package-1.zip', + idempotencyKey: 'agc-publish-test', + metadata: { + localProjectId: 'local-proj-1', + coverAssetId: 'asset_cover', + screenshots: ['asset_shot_1', 'asset_shot_2'], + }, + }); + expect(calls[0]?.args).not.toHaveProperty('accessToken'); + expect(calls[0]?.args).not.toHaveProperty('apiBaseUrl'); + expect(calls[0]?.args).not.toHaveProperty('packageBytes'); }); - expect(fetchClientHttp.mock.calls[0]?.[0]).toBe( - '/api/game-distribution/publish-metadata/suggestions', - ); - const init = fetchClientHttp.mock.calls[0]?.[1] as RequestInit; - expect(JSON.parse(String(init.body))).toEqual({ - name: '星轨防线', - goal: '守住轨道城', - context: '当前任务与状态:原型已完成;现有素材:image:assets/hero.png', + test('缺少本地项目标识时在发起 native command 前失败关闭', async () => { + const invoke = vi.fn() as unknown as TauriInvoke; + await expect( + publishLocalProjectGame({ + invoke, + projectPath: '/tmp/project', + packageRelativePath: 'exports/playtest-package-1.zip', + manifest: { ...MANIFEST, projectId: ' ' } as GameCreationAppManifest, + metadata: { coverAssetId: 'asset_cover' }, + }), + ).rejects.toThrow('发布需要本地项目标识'); + expect(invoke).not.toHaveBeenCalled(); }); -}); -test('封面生成直接使用返回的平台素材 ID,不再次上传', async () => { - fetchClientHttp.mockResolvedValueOnce( - jsonResponse({ - imageSrc: 'https://assets.example.com/generated-cover.png', + test('缺少封面时在创建 native 发布任务前失败关闭', async () => { + const invoke = vi.fn() as unknown as TauriInvoke; + await expect( + publishLocalProjectGame({ + invoke, + projectPath: '/tmp/project', + packageRelativePath: 'exports/playtest-package-1.zip', + manifest: MANIFEST, + metadata: { coverAssetId: ' ' }, + }), + ).rejects.toThrow('请先选择游戏封面'); + expect(invoke).not.toHaveBeenCalled(); + }); + + test('截图超过 6 张时在 native command 前失败关闭', async () => { + const invoke = vi.fn() as unknown as TauriInvoke; + await expect( + publishLocalProjectGame({ + invoke, + projectPath: '/tmp/project', + packageRelativePath: 'exports/playtest-package-1.zip', + manifest: MANIFEST, + metadata: { + coverAssetId: 'asset_cover', + screenshots: Array.from( + { length: 7 }, + (_, index) => `asset_${index}`, + ), + }, + }), + ).rejects.toThrow('游戏截图最多 6 张'); + expect(invoke).not.toHaveBeenCalled(); + }); + + test('发布灰度、资料建议和价格均通过 native command', async () => { + const { invoke, calls } = installNativeInvoke((command) => { + if (command === 'read_game_publish_availability') return true; + if (command === 'suggest_game_distribution_publish_metadata') { + return { summary: '驾驶炮台守住轨道城', category: '策略' }; + } + if (command === 'read_game_cover_generation_price') return 5; + throw new Error(`未预期的命令:${command}`); + }); + + await expect(readGamePublishAvailability(invoke)).resolves.toBe(true); + await expect( + suggestGameDistributionPublishMetadata( + { + name: '星轨防线', + goal: '守住轨道城', + context: + '当前任务与状态:原型已完成;现有素材:image:assets/hero.png', + }, + invoke, + ), + ).resolves.toEqual({ summary: '驾驶炮台守住轨道城', category: '策略' }); + await expect( + readGameCoverGenerationPrice( + { model: 'gpt-image-2', imageSize: '2K' }, + invoke, + ), + ).resolves.toBe(5); + + expect(calls).toEqual([ + { command: 'read_game_publish_availability', args: undefined }, + { + command: 'suggest_game_distribution_publish_metadata', + args: { + name: '星轨防线', + goal: '守住轨道城', + context: + '当前任务与状态:原型已完成;现有素材:image:assets/hero.png', + }, + }, + { + command: 'read_game_cover_generation_price', + args: { model: 'gpt-image-2', imageSize: '2K' }, + }, + ]); + }); + + test('封面生成由 Rust 等待队列后一次性返回已登记素材', async () => { + const { invoke, calls } = installNativeInvoke((command) => { + if (command === 'generate_game_distribution_cover') { + return { + assetObjectId: 'asset_generated_cover', + previewUrl: 'https://assets.example.com/generated-cover.png', + taskId: 'task_cover_1', + model: 'gpt-image-2', + }; + } + throw new Error(`未预期的命令:${command}`); + }); + + await expect( + generateGameDistributionCover( + { + prompt: '为《星轨防线》生成游戏封面', + model: 'gpt-image-2', + aspectRatio: '16:9', + imageSize: '2K', + }, + invoke, + ), + ).resolves.toEqual({ assetObjectId: 'asset_generated_cover', + previewUrl: 'https://assets.example.com/generated-cover.png', taskId: 'task_cover_1', model: 'gpt-image-2', - }), - ); - - await expect( - generateGameDistributionCover({ - prompt: '为《星轨防线》生成游戏封面', - model: 'gpt-image-2', - aspectRatio: '16:9', - imageSize: '2K', - }), - ).resolves.toEqual({ - assetObjectId: 'asset_generated_cover', - previewUrl: 'https://assets.example.com/generated-cover.png', - taskId: 'task_cover_1', - model: 'gpt-image-2', - }); - - expect(fetchClientHttp.mock.calls[0]?.[0]).toBe( - '/api/editor/images/generations', - ); - const body = JSON.parse( - String((fetchClientHttp.mock.calls[0]?.[1] as RequestInit).body), - ); - expect(body).toEqual({ - prompt: '为《星轨防线》生成游戏封面', - kind: 'publication-material', - assetKind: 'publication-material', - model: 'gpt-image-2', - aspectRatio: '16:9', - imageSize: '2K', - assetLabel: '游戏封面', - }); -}); - -test('封面生成进入队列后轮询完成结果并返回同一平台素材', async () => { - vi.useFakeTimers(); - fetchClientHttp - .mockResolvedValueOnce( - jsonResponse({ - queueState: { - operationId: 'external-job-1', - status: 'queued', - phaseDetail: '排队中。', - }, - }), - ) - .mockResolvedValueOnce( - jsonResponse({ - job: { - operationId: 'external-job-1', - status: 'completed', - result: { - imageSrc: 'https://assets.example.com/queued-cover.png', - assetObjectId: 'asset_queued_cover', - taskId: 'task_queued_cover', - model: 'gpt-image-2', - }, - }, - }), - ); - - const generation = generateGameDistributionCover({ - prompt: '为《星轨防线》生成游戏封面', - model: 'gpt-image-2', - }); - await vi.advanceTimersByTimeAsync(1_600); - - await expect(generation).resolves.toEqual({ - assetObjectId: 'asset_queued_cover', - previewUrl: 'https://assets.example.com/queued-cover.png', - taskId: 'task_queued_cover', - model: 'gpt-image-2', - }); - expect(fetchClientHttp.mock.calls[1]?.[0]).toBe( - '/api/runtime/external-generation/jobs/external-job-1', - ); -}); - -test('封面价格读取后端运行时定价,不在前端硬编码', async () => { - fetchClientHttp.mockResolvedValueOnce( - jsonResponse({ - models: { - 'gpt-image-2': { - unit: 'perGeneration', - prices: { '2K': 5 }, + }); + expect(calls).toEqual([ + { + command: 'generate_game_distribution_cover', + args: { + prompt: '为《星轨防线》生成游戏封面', + model: 'gpt-image-2', + aspectRatio: '16:9', + imageSize: '2K', + assetLabel: '游戏封面', }, }, - }), - ); + ]); + }); - await expect( - readGameCoverGenerationPrice({ - model: 'gpt-image-2', - imageSize: '2K', - }), - ).resolves.toBe(5); - expect(fetchClientHttp.mock.calls[0]?.[0]).toBe( - '/api/editor/generation-pricing', - ); + test('缺少 native 宿主时发布网络 facade 失败关闭', async () => { + await expect(readGamePublishAvailability()).rejects.toThrow( + '需要在 Tauri App 内执行发布操作', + ); + }); }); diff --git a/apps/ai-game-creator-shell/tests/gameDistributionPublishLive.test.ts b/apps/ai-game-creator-shell/tests/gameDistributionPublishLive.test.ts index b79228dd1..a55e9fee9 100644 --- a/apps/ai-game-creator-shell/tests/gameDistributionPublishLive.test.ts +++ b/apps/ai-game-creator-shell/tests/gameDistributionPublishLive.test.ts @@ -6,9 +6,9 @@ * GENARRATIVE_AGC_PUBLISH_E2E_BASE_URL=http://127.0.0.1:10001 \ * npx vitest run apps/ai-game-creator-shell/tests/gameDistributionPublishLive.test.ts * - * 开启后测试会注册一个临时作者,并通过真实的 `clientApi` / `clientHttp`(而不是 - * mock 请求层)调用 AGC 的发布函数,覆盖:本地发行包暂存摘要、创建游戏、同 - * `localProjectId` 复用游戏身份、真实分片上传、送审与版本回读。 + * 开启后测试会注册一个临时作者,并通过真实 HTTP(而不是 mock 请求层)验证 + * AGC 发布 facade 的请求合同,覆盖:创建游戏、同 `localProjectId` 复用游戏身份、 + * 真实分片上传、送审与版本回读。jsdom 只替代 Tauri command 的原生执行。 * * jsdom 里没有 Tauri 运行时,`upload_local_project_game_package` 由本测试按服务端 * 分片协议(upload-state → chunk → complete)代跑,等同于原生上传器的行为; @@ -22,7 +22,6 @@ import { expect, test, vi } from 'vitest'; import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; import type { StagedGamePackage } from '../src/app/types'; import { uploadPlatformMediaAsset } from '../src/services/assetDirectUpload'; -import { setStoredAuthAccessToken } from '../src/services/clientAuth'; import { setClientServerSelection } from '../src/services/clientHttp'; import { publishLocalProjectGame } from '../src/services/gameDistributionPublish'; @@ -156,6 +155,95 @@ async function readBlobBytes(blob: Blob): Promise { }); } +/** + * 代跑 Rust `upload_platform_media_asset` 的网络合同。 + * + * 渲染层只交出文件字节与结构化参数;凭证、直传表单、confirm 与字段校验都发生在原生侧。 + * 这里按同一协议逐步请求本地栈,校验 IPC 载荷足以驱动服务端合同。 + */ +async function simulateNativePlatformAssetUpload( + args: { input: Record }, + token: string, +): Promise<{ assetObjectId: string; objectKey: string }> { + const input = args.input; + const bytes = Buffer.from(input.bytes as number[]); + const fileName = String(input.fileName); + const contentType = String(input.contentType); + if (bytes.length === 0) { + throw new Error('原生载荷缺少素材字节'); + } + const ticketPayload = await unwrap<{ + upload: { + host: string; + bucket: string; + objectKey: string; + successActionStatus?: number; + formFields: Record; + }; + }>( + await realFetch(apiUrl('/api/assets/direct-upload-tickets'), { + method: 'POST', + headers: { + ...packageAuthHeaders(token), + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + legacyPrefix: 'generated-character-drafts', + pathSegments: input.pathSegments, + fileName, + contentType, + access: 'private', + maxSizeBytes: bytes.length, + metadata: input.metadata, + }), + }), + ); + + const form = new FormData(); + for (const [key, value] of Object.entries( + ticketPayload.upload.formFields ?? {}, + )) { + form.append(key, value); + } + form.append('file', new Blob([bytes], { type: contentType }), fileName); + const multipart = await serializeFormData(form); + const uploadResponse = await realFetch(ticketPayload.upload.host, { + method: 'POST', + headers: { 'Content-Type': multipart.contentType }, + body: multipart.body, + }); + if ( + uploadResponse.status !== (ticketPayload.upload.successActionStatus ?? 204) + ) { + throw new Error(`对象存储直传失败:${uploadResponse.status}`); + } + + const confirmed = await unwrap<{ + assetObject: { assetObjectId: string; objectKey: string }; + }>( + await realFetch(apiUrl('/api/assets/objects/confirm'), { + method: 'POST', + headers: { + ...packageAuthHeaders(token), + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + bucket: ticketPayload.upload.bucket, + objectKey: ticketPayload.upload.objectKey, + contentType, + contentLength: bytes.length, + assetKind: input.assetKind, + accessPolicy: 'private', + entityId: input.entityId, + }), + }), + ); + return { + assetObjectId: confirmed.assetObject.assetObjectId, + objectKey: confirmed.assetObject.objectKey, + }; +} + async function unwrap(response: Response): Promise { const text = await response.text(); const parsed: unknown = JSON.parse(text); @@ -363,7 +451,6 @@ liveTest( async () => { installFetchBridge(); const token = await registerAuthor(); - setStoredAccessToken(token); const { staged, bytes } = await buildStagedPackage(); const stamp = String(Date.now()); @@ -376,35 +463,105 @@ liveTest( const sentOffsets: number[] = []; const invoke = vi.fn( async (command: string, args?: Record) => { - if (command === 'prepare_local_project_game_package') { - return staged; + if (command !== 'publish_local_project_game') { + throw new Error(`未预期的命令:${command}`); } - if (command === 'upload_local_project_game_package') { - const uploaded = await uploadStagedPackageViaProtocol({ - versionId: String(args?.versionId ?? ''), - bytes, - token, - idempotencyKey: String(args?.idempotencyKey ?? ''), - sentOffsets, - }); - return { - versionId: uploaded.versionId, - status: uploaded.status, - uploadedBytes: bytes.length, - }; - } - throw new Error(`未预期的命令:${command}`); + const metadata = args?.metadata as Record; + const rootKey = String(args?.idempotencyKey ?? ''); + const game = await unwrap<{ + id: string; + publicationRevision?: number; + }>( + await realFetch(apiUrl('/api/game-distribution/games'), { + method: 'POST', + headers: { + ...packageAuthHeaders(token), + 'Content-Type': 'application/json', + 'Idempotency-Key': `${rootKey}:game`, + }, + body: JSON.stringify(metadata), + }), + ); + const version = await unwrap<{ + versionId: string; + versionNumber: number; + status: string; + }>( + await realFetch( + apiUrl(`/api/game-distribution/games/${game.id}/versions`), + { + method: 'POST', + headers: { + ...packageAuthHeaders(token), + 'Content-Type': 'application/json', + 'Idempotency-Key': `${rootKey}:version`, + }, + body: JSON.stringify({ + localProjectId: metadata.localProjectId, + packageSha256: staged.packageSha256, + packageBytes: staged.packageSizeBytes, + packageFileCount: staged.packageFileCount, + packageEntryPath: 'index.html', + gameMetadata: metadata, + }), + }, + ), + ); + const uploaded = await uploadStagedPackageViaProtocol({ + versionId: version.versionId, + bytes, + token, + idempotencyKey: `${rootKey}:upload`, + sentOffsets, + }); + const submitted = await unwrap<{ + version?: { status?: string }; + }>( + await realFetch( + apiUrl( + `/api/game-distribution/versions/${version.versionId}/submit`, + ), + { + method: 'POST', + headers: { + ...packageAuthHeaders(token), + 'Content-Type': 'application/json', + 'Idempotency-Key': `${rootKey}:submit`, + }, + body: JSON.stringify({ + expectedPublicationRevision: game.publicationRevision ?? 0, + }), + }, + ), + ); + return { + gameId: game.id, + versionId: version.versionId, + versionNumber: version.versionNumber, + status: submitted.version?.status ?? uploaded.status, + packageSha256: staged.packageSha256, + packageSizeBytes: staged.packageSizeBytes, + fileCount: staged.packageFileCount, + }; }, ); - // 服务端要求发布必须带封面:真实走一遍凭证 → 直传 → confirm。 + // 服务端要求发布必须带封面。真实 Tauri 里 `upload_platform_media_asset` 由 Rust + // 完成「凭证 → 对象存储直传 → confirm」;jsdom 没有原生宿主,这里用同一个 + // invoke 合同按服务端协议代跑,证明渲染层只提交字节和结构化元数据。 const uploadedCover = await uploadPlatformMediaAsset({ file: buildLiveCoverFile(), assetKind: 'game_distribution_cover', pathSegments: ['game-distribution', 'cover', stamp], entityId: 'game-distribution-cover', - // jsdom 里没有 Tauri HTTP 插件:直传也走同一个桥,跨 realm 的 FormData 会被 - // 先序列化成 Node 侧 multipart 字节,否则 OSS 会以 405 拒绝。 - fetchImpl: (input, init) => globalThis.fetch(input, init), + invoke: async (command, args) => { + if (command !== 'upload_platform_media_asset') { + throw new Error(`未预期的命令:${command}`); + } + return await simulateNativePlatformAssetUpload( + args as { input: Record }, + token, + ); + }, }); expect(uploadedCover.assetObjectId).toMatch(/\S/u); const metadata = { @@ -482,7 +639,3 @@ liveTest( }, 120_000, ); - -function setStoredAccessToken(token: string) { - setStoredAuthAccessToken(token); -} diff --git a/apps/ai-game-creator-shell/tests/platformSession.test.ts b/apps/ai-game-creator-shell/tests/platformSession.test.ts new file mode 100644 index 000000000..10734765c --- /dev/null +++ b/apps/ai-game-creator-shell/tests/platformSession.test.ts @@ -0,0 +1,187 @@ +// @vitest-environment node +/** + * 平台会话代次的语义。 + * + * 渲染层只持有这一个派生计数,用来识别换号/登出后到达的迟到结果。它必须在 + * 「同一账号续期」和「同一身份重复采纳(StrictMode 双次 hydrate)」时保持不变——否则 + * 在途生成会被自己的续期判成旧账号请求;而换账号、换 origin、登出又必须立刻推进。 + */ +import { beforeEach, expect, test, vi } from 'vitest'; + +const refreshClientAuthSession = vi.hoisted(() => vi.fn()); + +vi.mock('../src/services/clientAuth', () => ({ + refreshClientAuthSession, + subscribeClientAuthState: () => () => {}, +})); + +import { + beginPlatformSessionTransition, + commitAuthenticatedPlatformSession, + currentPlatformSessionGeneration, + requestPlatformSessionRefresh, + resetPlatformSessionForTests, + subscribePlatformSessionGeneration, +} from '../src/services/platformSession'; + +const userA = { + id: 'user-a', + publicUserCode: 'tn-a', + displayName: '账号 A', + avatarUrl: null, + phoneNumber: null, + phoneNumberMasked: null, + loginMethod: 'password', + bindingStatus: 'active', + wechatBound: false, + wechatDisplayName: null, + wechatAccount: null, +}; +const userB = { + ...userA, + id: 'user-b', + publicUserCode: 'tn-b', + displayName: '账号 B', +}; + +beforeEach(() => { + refreshClientAuthSession.mockReset(); + resetPlatformSessionForTests(); +}); + +test('同一身份重复采纳不推进代次,换 origin 或换账号才推进', async () => { + const observed: number[] = []; + const unsubscribe = subscribePlatformSessionGeneration((generation) => { + observed.push(generation); + }); + + const first = await commitAuthenticatedPlatformSession( + userA, + currentPlatformSessionGeneration(), + 'https://a.example.com', + ); + expect(first).toBe(1); + // StrictMode 双次 hydrate 或同一账号重新登录:代次必须原地不动。 + const again = await commitAuthenticatedPlatformSession( + userA, + currentPlatformSessionGeneration(), + 'https://a.example.com', + ); + expect(again).toBe(first); + + const otherOrigin = await commitAuthenticatedPlatformSession( + userA, + currentPlatformSessionGeneration(), + 'https://b.example.com', + ); + expect(otherOrigin).toBe(2); + const otherUser = await commitAuthenticatedPlatformSession( + userB, + currentPlatformSessionGeneration(), + 'https://b.example.com', + ); + expect(otherUser).toBe(3); + expect(observed).toEqual([1, 2, 3]); + unsubscribe(); +}); + +test('期望代次已过期时不采纳身份', async () => { + await commitAuthenticatedPlatformSession( + userA, + currentPlatformSessionGeneration(), + 'https://a.example.com', + ); + + await expect( + commitAuthenticatedPlatformSession(userB, 0, 'https://a.example.com'), + ).resolves.toBeNull(); + expect(currentPlatformSessionGeneration()).toBe(1); +}); + +test('登录切换与登出立刻推进代次', () => { + const before = currentPlatformSessionGeneration(); + const next = beginPlatformSessionTransition(); + + expect(next).toBe(before + 1); + expect(currentPlatformSessionGeneration()).toBe(next); +}); + +test('同一账号续期不推进代次,并发续期只发一次请求', async () => { + await commitAuthenticatedPlatformSession( + userA, + currentPlatformSessionGeneration(), + 'https://a.example.com', + ); + const generationBefore = currentPlatformSessionGeneration(); + refreshClientAuthSession.mockResolvedValue({ + status: 'refreshed', + user: userA, + }); + + const [first, second] = await Promise.all([ + requestPlatformSessionRefresh(), + requestPlatformSessionRefresh(), + ]); + + expect(refreshClientAuthSession).toHaveBeenCalledTimes(1); + expect(first).toEqual({ + status: 'refreshed', + user: userA, + generation: generationBefore, + }); + expect(second).toEqual(first); + expect(currentPlatformSessionGeneration()).toBe(generationBefore); +}); + +test('续期发现未登录时采纳空身份并给出权威失败', async () => { + await commitAuthenticatedPlatformSession( + userA, + currentPlatformSessionGeneration(), + 'https://a.example.com', + ); + const generationBefore = currentPlatformSessionGeneration(); + refreshClientAuthSession.mockResolvedValue({ status: 'unauthenticated' }); + + const result = await requestPlatformSessionRefresh(); + + expect(result.status).toBe('failed'); + if (result.status === 'failed') { + expect(result.authoritative).toBe(true); + expect(String(result.error)).toContain('登录已失效'); + } + expect(currentPlatformSessionGeneration()).toBe(generationBefore + 1); +}); + +test('续期期间换号时返回 stale,不覆盖新身份', async () => { + await commitAuthenticatedPlatformSession( + userA, + currentPlatformSessionGeneration(), + 'https://a.example.com', + ); + refreshClientAuthSession.mockImplementation(async () => { + beginPlatformSessionTransition(); + return { status: 'refreshed', user: userA }; + }); + + await expect(requestPlatformSessionRefresh()).resolves.toEqual({ + status: 'stale', + }); +}); + +test('网络类续期失败保持会话且不标记为权威失败', async () => { + await commitAuthenticatedPlatformSession( + userA, + currentPlatformSessionGeneration(), + 'https://a.example.com', + ); + const generationBefore = currentPlatformSessionGeneration(); + refreshClientAuthSession.mockRejectedValue(new Error('网络不可达')); + + const result = await requestPlatformSessionRefresh(); + + expect(result.status).toBe('failed'); + if (result.status === 'failed') { + expect(result.authoritative).toBe(false); + } + expect(currentPlatformSessionGeneration()).toBe(generationBefore); +}); diff --git a/apps/ai-game-creator-shell/tests/platformSessionRetry.test.ts b/apps/ai-game-creator-shell/tests/platformSessionRetry.test.ts new file mode 100644 index 000000000..834ea98ef --- /dev/null +++ b/apps/ai-game-creator-shell/tests/platformSessionRetry.test.ts @@ -0,0 +1,157 @@ +// @vitest-environment node +/** + * 平台会话续期重试的边界。 + * + * 凭据与续期 HTTP 都在 Rust;渲染层这一层只保证两件事:只有服务端明确否认身份时才会 + * 续期一次并重放,以及身份代次围栏必须挡住换号/登出期间的重放——带着旧身份的请求重放 + * 会把上一个账号的副作用打进新账号。 + */ +import { beforeEach, expect, test, vi } from 'vitest'; + +const refreshClientAuthSession = vi.hoisted(() => vi.fn()); + +vi.mock('../src/services/clientAuth', () => ({ + refreshClientAuthSession, + subscribeClientAuthState: () => () => {}, +})); + +import { + beginPlatformSessionTransition, + resetPlatformSessionForTests, + subscribePlatformSessionRefresh, +} from '../src/services/platformSession'; +import { + isPlatformAuthenticationRequiredError, + withPlatformSessionRefreshRetry, +} from '../src/services/platformSessionRetry'; + +const testUser = { + id: 'user-1', + publicUserCode: 'tn-1', + displayName: '测试用户', + avatarUrl: null, + phoneNumber: null, + phoneNumberMasked: null, + loginMethod: 'password', + bindingStatus: 'active', + wechatBound: false, + wechatDisplayName: null, + wechatAccount: null, +}; + +beforeEach(() => { + refreshClientAuthSession.mockReset(); + resetPlatformSessionForTests(); +}); + +test('只有服务端明确否认身份的错误才被识别为可续期', () => { + expect( + isPlatformAuthenticationRequiredError( + new Error('authentication-required: 请重新登录'), + ), + ).toBe(true); + expect( + isPlatformAuthenticationRequiredError( + new Error('codex-app-server-error:unauthorized'), + ), + ).toBe(true); + expect( + isPlatformAuthenticationRequiredError( + new Error('provider failed kind=codex-app-server-unauthorized'), + ), + ).toBe(true); + expect( + isPlatformAuthenticationRequiredError(new Error('登录已失效,请重新登录')), + ).toBe(true); + + // 网络抖动、超时、5xx 与业务拒绝都不能被当成身份否认。 + expect( + isPlatformAuthenticationRequiredError(new Error('请求超时,请稍后重试')), + ).toBe(false); + expect(isPlatformAuthenticationRequiredError(new Error('HTTP 503'))).toBe( + false, + ); + expect(isPlatformAuthenticationRequiredError(new Error('泥点余额不足'))).toBe( + false, + ); + expect( + isPlatformAuthenticationRequiredError('unauthenticated-provider-response'), + ).toBe(false); +}); + +test('请求成功时不触碰续期', async () => { + const operation = vi.fn().mockResolvedValue(42); + + await expect(withPlatformSessionRefreshRetry(operation)).resolves.toBe(42); + expect(operation).toHaveBeenCalledTimes(1); + expect(refreshClientAuthSession).not.toHaveBeenCalled(); +}); + +test('业务失败原样抛出且不续期', async () => { + const failure = new Error('该游戏名已被占用'); + const operation = vi.fn().mockRejectedValue(failure); + + await expect(withPlatformSessionRefreshRetry(operation)).rejects.toBe( + failure, + ); + expect(operation).toHaveBeenCalledTimes(1); + expect(refreshClientAuthSession).not.toHaveBeenCalled(); +}); + +test('身份被否认时续期一次并重放原请求', async () => { + const operation = vi + .fn() + .mockRejectedValueOnce(new Error('authentication-required')) + .mockResolvedValueOnce('done'); + refreshClientAuthSession.mockResolvedValue({ + status: 'refreshed', + user: testUser, + }); + + await expect(withPlatformSessionRefreshRetry(operation)).resolves.toBe( + 'done', + ); + expect(operation).toHaveBeenCalledTimes(2); + expect(refreshClientAuthSession).toHaveBeenCalledTimes(1); +}); + +test('续期失败时抛回原始错误而不是续期错误', async () => { + const failure = new Error('authentication-required'); + const operation = vi.fn().mockRejectedValue(failure); + refreshClientAuthSession.mockResolvedValue({ status: 'unauthenticated' }); + + await expect(withPlatformSessionRefreshRetry(operation)).rejects.toBe( + failure, + ); + expect(operation).toHaveBeenCalledTimes(1); +}); + +test('请求期间已经换号或登出时不续期也不重放', async () => { + const failure = new Error('authentication-required'); + const operation = vi.fn().mockImplementation(async () => { + beginPlatformSessionTransition(); + throw failure; + }); + + await expect(withPlatformSessionRefreshRetry(operation)).rejects.toBe( + failure, + ); + expect(refreshClientAuthSession).not.toHaveBeenCalled(); +}); + +test('续期完成瞬间换号时拒绝用旧身份重放', async () => { + const failure = new Error('authentication-required'); + const operation = vi.fn().mockRejectedValueOnce(failure); + refreshClientAuthSession.mockResolvedValue({ + status: 'refreshed', + user: testUser, + }); + subscribePlatformSessionRefresh(() => { + beginPlatformSessionTransition(); + }); + + await expect(withPlatformSessionRefreshRetry(operation)).rejects.toThrow( + '登录账号已变化,原请求已停止', + ); + expect(operation).toHaveBeenCalledTimes(1); +}); diff --git a/apps/ai-game-creator-shell/tests/projectCreationDirectory.test.ts b/apps/ai-game-creator-shell/tests/projectCreationDirectory.test.ts index 65a91837b..3b5fbfa05 100644 --- a/apps/ai-game-creator-shell/tests/projectCreationDirectory.test.ts +++ b/apps/ai-game-creator-shell/tests/projectCreationDirectory.test.ts @@ -1,17 +1,19 @@ /** @vitest-environment jsdom */ -import { beforeEach, describe, expect, it } from 'vitest'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { normalizeProjectCreationDirectory } from '../src/features/app-shell/model'; +import { useProjectCreationDirectory } from '../src/features/app-shell/useProjectCreationDirectory'; import { - normalizeProjectCreationDirectory, - readProjectCreationDirectory, - writeProjectCreationDirectory, -} from '../src/features/app-shell/model'; - -const STORAGE_KEY = 'genarrative-ai-game-creator.project-creation-directory.v1'; + readWorkspacePreferences, + resetWorkspacePreferencesForTests, + setProjectCreationDirectory, +} from '../src/features/app-shell/workspacePreferences'; describe('项目创建目录偏好', () => { beforeEach(() => { - window.localStorage.clear(); + resetWorkspacePreferencesForTests(); + delete window.__TAURI__; }); it('去掉首尾空白与多余分隔符,并保留盘根', () => { @@ -28,26 +30,89 @@ describe('项目创建目录偏好', () => { expect(normalizeProjectCreationDirectory('F:\\游\n戏')).toBe(''); }); - it('保存选中的目录,并在恢复默认位置时清空', () => { - expect(readProjectCreationDirectory()).toBe(''); - - expect(writeProjectCreationDirectory(' F:\\Projects\\游戏 ')).toBe( - 'F:\\Projects\\游戏', - ); - expect(readProjectCreationDirectory()).toBe('F:\\Projects\\游戏'); - - expect(writeProjectCreationDirectory(' ')).toBe(''); - expect(readProjectCreationDirectory()).toBe(''); + it('在无 Tauri 时由内存快照承接设置交互', async () => { + await expect(readWorkspacePreferences()).resolves.toMatchObject({ + projectCreationDirectory: null, + }); + await expect( + setProjectCreationDirectory('C:\\Projects\\games'), + ).resolves.toMatchObject({ + projectCreationDirectory: 'C:\\Projects\\games', + }); + await expect(readWorkspacePreferences()).resolves.toMatchObject({ + projectCreationDirectory: 'C:\\Projects\\games', + }); + await expect(setProjectCreationDirectory(null)).resolves.toMatchObject({ + projectCreationDirectory: null, + }); }); - it('忽略存储里不可用的值,退回默认位置', () => { - window.localStorage.setItem(STORAGE_KEY, JSON.stringify('relative/games')); - expect(readProjectCreationDirectory()).toBe(''); + it('非法目录不会进入正式快照', async () => { + await expect( + setProjectCreationDirectory('relative/games'), + ).resolves.toMatchObject({ + projectCreationDirectory: null, + }); + }); - window.localStorage.setItem(STORAGE_KEY, JSON.stringify(42)); - expect(readProjectCreationDirectory()).toBe(''); + it('订阅成功后读取的旧快照不会覆盖较新的 Rust 偏好事件', async () => { + let resolveRead: + | ((snapshot: { + projectCreationDirectory: string | null; + recentWorkspaces: string[]; + }) => void) + | null = null; + let eventHandler: + | ((event: { + payload: { + projectCreationDirectory: string | null; + recentWorkspaces: string[]; + }; + }) => void) + | null = null; + const invoke = vi.fn(async (command: string) => { + if (command === 'read_workspace_preferences') { + return await new Promise<{ + projectCreationDirectory: string | null; + recentWorkspaces: string[]; + }>((resolve) => { + resolveRead = resolve; + }); + } + throw new Error('unexpected invoke ' + command); + }); + window.__TAURI__ = { + core: { invoke }, + event: { + listen: vi.fn(async (_event, handler) => { + eventHandler = handler; + return () => { + eventHandler = null; + }; + }), + }, + }; - window.localStorage.setItem(STORAGE_KEY, '{not json'); - expect(readProjectCreationDirectory()).toBe(''); + const { result } = renderHook(() => useProjectCreationDirectory()); + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith('read_workspace_preferences'); + }); + act(() => { + eventHandler?.({ + payload: { + projectCreationDirectory: 'F:\\Projects\\new', + recentWorkspaces: [], + }, + }); + }); + await act(async () => { + resolveRead?.({ + projectCreationDirectory: null, + recentWorkspaces: [], + }); + }); + await waitFor(() => { + expect(result.current.projectCreationDirectory).toBe('F:\\Projects\\new'); + }); }); }); diff --git a/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx b/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx index 946a60c71..574229d91 100644 --- a/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx +++ b/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx @@ -2328,4 +2328,54 @@ describe('project resource live canvas integration', () => { expect(intersectingSceneCards(cards)).toEqual([]); }); + + /** + * 画布验收待办里的「文档全文预览入口不可用或不可发现」:入口在选中文档卡之后的浮动工具条上, + * 这里把「文档卡 → 工具条「预览」→ 浮层读到全文」整条链路钉住,避免入口被改动悄悄拿掉。 + */ + it('文档卡选中后工具条给出「预览」入口,点开是读取后的全文预览', async () => { + const { invoke } = installTauri(); + // 场景里的栏目标题栏是数组子节点,缺 key 时 React 只打警告不报错:这里把它钉住。 + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + try { + render(); + + fireEvent.click(await screen.findByRole('button', { name: '打开文档' })); + fireEvent.click(await findResourceSelectButton('note-01.md')); + + const toolbars = await screen.findAllByRole('toolbar'); + const preview = toolbars + .flatMap((toolbar) => + within(toolbar).queryAllByRole('button', { name: '预览' }), + ) + .at(0); + expect(preview).toBeDefined(); + + fireEvent.click(preview as HTMLElement); + const dialog = await screen.findByRole('dialog', { name: '文档预览' }); + // 内容走文档预览队列的文本读取命令;卡片可见读取与浮层读取共用同一条缓存, + // 因此这里只断言「这条路径被读过」,不锁死调用次数。 + await waitFor(() => + expect( + invoke.mock.calls.some( + ([command, args]) => + command === 'read_local_project_text_preview' && + args?.relativePath === 'docs/note-01.md', + ), + ).toBe(true), + ); + // 浮层展示的是读取回来的全文,不是卡面上的摘要截断。 + expect(within(dialog).getByText('玩法规则')).not.toBeNull(); + expect(within(dialog).queryByText('正在加载文档…')).toBeNull(); + expect( + consoleError.mock.calls.filter(([first]) => + String(first).includes('unique "key"'), + ), + ).toHaveLength(0); + } finally { + consoleError.mockRestore(); + } + }); }); diff --git a/apps/ai-game-creator-shell/tests/recentProjectsHook.test.tsx b/apps/ai-game-creator-shell/tests/recentProjectsHook.test.tsx index 7abb15b64..43d546d8f 100644 --- a/apps/ai-game-creator-shell/tests/recentProjectsHook.test.tsx +++ b/apps/ai-game-creator-shell/tests/recentProjectsHook.test.tsx @@ -206,3 +206,66 @@ test('提权类失败不重试:不放大 UAC 弹窗', async () => { }); expect(attempts).toBe(1); }); + +test('Rust 偏好事件与 command 返回同时到达时只检查新增项目一次', async () => { + let snapshot = { + projectCreationDirectory: null, + recentWorkspaces: ['/tmp/ready-project'], + }; + let emitPreferenceEvent: + | ((event: { payload: typeof snapshot }) => void) + | null = null; + const inspectCounts: Record = {}; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_workspace_preferences') { + return snapshot; + } + if (command === 'remember_recent_workspace') { + const projectPath = String(args?.projectPath ?? ''); + snapshot = { + ...snapshot, + recentWorkspaces: [projectPath, ...snapshot.recentWorkspaces], + }; + emitPreferenceEvent?.({ payload: snapshot }); + return snapshot; + } + if (command === 'inspect_local_project_directory') { + const projectPath = String(args?.projectPath ?? ''); + inspectCounts[projectPath] = (inspectCounts[projectPath] ?? 0) + 1; + return { ...READY_PROJECT, projectPath }; + } + throw new Error('unexpected invoke ' + command); + }, + ); + window.__TAURI__ = { + core: { invoke }, + event: { + listen: vi.fn(async (_event, handler) => { + emitPreferenceEvent = handler; + return () => { + emitPreferenceEvent = null; + }; + }), + }, + }; + + const { result } = renderHook(() => useRecentProjects(vi.fn())); + await waitFor(() => { + expect(result.current.projectRows[0]).toMatchObject({ + path: '/tmp/ready-project', + canOpen: true, + }); + }); + + act(() => { + result.current.rememberRecentWorkspace('/tmp/new-project'); + }); + await waitFor(() => { + expect(result.current.projectRows[0]).toMatchObject({ + path: '/tmp/new-project', + canOpen: true, + }); + }); + expect(inspectCounts['/tmp/new-project']).toBe(1); +}); diff --git a/apps/ai-game-creator-shell/tests/resourceBatchTagsIntegration.test.tsx b/apps/ai-game-creator-shell/tests/resourceBatchTagsIntegration.test.tsx index ab3277f2a..c1d0863b7 100644 --- a/apps/ai-game-creator-shell/tests/resourceBatchTagsIntegration.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceBatchTagsIntegration.test.tsx @@ -5,9 +5,11 @@ import { beforeEach } from 'vitest'; import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; import { createGameCreationAppManifest, + describe, expect, findResourceSelectButton, fireEvent, + it, ProjectDevelopmentView, render, screen, diff --git a/apps/ai-game-creator-shell/tests/resourceBookModel.test.ts b/apps/ai-game-creator-shell/tests/resourceBookModel.test.ts index abe8f5d6d..356c85f08 100644 --- a/apps/ai-game-creator-shell/tests/resourceBookModel.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceBookModel.test.ts @@ -10,8 +10,29 @@ import { resourceBookShowsAllCategories, resourceBookShowsExitingCards, resourceBookShowsOverviewCards, + sameResourceIdSelection, } from '../src/view/project-development/resourceBookModel'; +describe('框选选中集合的等价判据', () => { + it('忽略顺序比较,集合相同即为等价', () => { + expect( + sameResourceIdSelection(['asset-a', 'asset-b'], ['asset-b', 'asset-a']), + ).toBe(true); + expect(sameResourceIdSelection([], [])).toBe(true); + expect(sameResourceIdSelection(['asset-a'], ['asset-a'])).toBe(true); + }); + + it('集合不同、长度不同或存在重复项时都不等价', () => { + expect(sameResourceIdSelection(['asset-a'], ['asset-b'])).toBe(false); + expect(sameResourceIdSelection(['asset-a'], ['asset-a', 'asset-b'])).toBe( + false, + ); + expect( + sameResourceIdSelection(['asset-a', 'asset-a'], ['asset-a', 'asset-b']), + ).toBe(false); + }); +}); + describe('resource book state machine', () => { it('keeps overview titles and preview cards visible on the steady main canvas', () => { expect(resourceBookShowsOverviewCards(initialResourceBookState)).toBe(true); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts index 3cd498860..da51d6641 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts @@ -307,6 +307,34 @@ function createHarness(waitScript?: (pollCount: number, api: Harness) => void) { } describe('本地排队驱动器', () => { + test('有 Rust 账本事件时不挂定时等待,事件到达后只补读一次', async () => { + const harness = createHarness(); + let notify: (() => void) | null = null; + let releaseCount = 0; + const wait = vi.fn(harness.deps.wait); + harness.deps.wait = wait; + harness.deps.subscribe = async (_projectPath, _taskId, onChanged) => { + notify = onChanged; + return () => { + releaseCount += 1; + }; + }; + const queue = createResourceCanvasAssetGenerationQueue(harness.deps); + const settled = queue.submit(localTask('task-event', 10)); + + await Promise.resolve(); + await Promise.resolve(); + expect(notify).not.toBeNull(); + expect(wait).not.toHaveBeenCalled(); + + harness.advance('task-event', 'completed'); + notify?.(); + await settled; + + expect(wait).not.toHaveBeenCalled(); + expect(releaseCount).toBe(1); + }); + test('第一条未完成时提交第二条:第二条仍是排队中且生成提交 IPC 只发了一次', async () => { let assertedMidFlight = false; const harness = createHarness((pollCount, api) => { diff --git a/apps/ai-game-creator-shell/tests/resourceFilterPanelStyle.test.ts b/apps/ai-game-creator-shell/tests/resourceFilterPanelStyle.test.ts new file mode 100644 index 000000000..d41aa7e9f --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceFilterPanelStyle.test.ts @@ -0,0 +1,49 @@ +// @vitest-environment jsdom +import { readFileSync } from 'node:fs'; + +import { describe, expect, test } from 'vitest'; + +import { repoPath } from './repoPath'; +import { + declaration, + parseStyleSheet, + resolveDeclarations, +} from './styleCascade'; + +/** + * 筛选面板「自定义标签」区的可达性守卫。 + * + * jsdom 不做排版,所以这里按仓库既有做法直接解析真实生效的声明,把「夹紧写在哪一层」钉住: + * 百分比 `max-height` 只相对**定位祖先**解析,写在内层(包含块高度 auto)会被按 `none` + * 处理,长标签列表就顶出管理区、被 `overflow: hidden` 裁掉且滚不动——现场「标签面板无法 + * 查看更多标签」正是这个成因。 + */ +const css = readFileSync( + repoPath('apps/ai-game-creator-shell/src/styles.css'), + 'utf8', +); +const rules = parseStyleSheet(css); + +describe('资源筛选面板标签区的高度与滚动', () => { + test('高度夹紧与滚动落在定位锚点上,百分比才有解析对象', () => { + const anchor = resolveDeclarations( + rules, + ['.game-resource-filter-panel'], + 1440, + ); + const maxHeight = declaration(anchor, 'max-height'); + expect(maxHeight).toContain('%'); + expect(declaration(anchor, 'overflow-y')).toBe('auto'); + }); + + test('内层卡片不再声明依赖父级高度的百分比 max-height', () => { + const card = resolveDeclarations( + rules, + ['.game-resource-filter-panel__card'], + 1440, + ); + const maxHeight = card.get('max-height') ?? 'none'; + expect(maxHeight).toBe('none'); + expect(maxHeight).not.toContain('%'); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx b/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx index 723689942..d07631abb 100644 --- a/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx @@ -406,6 +406,13 @@ describe('ResourceReferenceInput', () => { return [{ name: 'agc-test-skill', description: '测试 Skill' }]; } if (command === 'list_client_extensions') return []; + if (command === 'read_workspace_preferences') { + return { + projectCreationDirectory: null, + recentWorkspaces: [], + chatPromptPolishReminderDisabled: false, + }; + } throw new Error(`unexpected invoke ${command}`); }); window.__TAURI__ = { core: { invoke: invoke as never } }; @@ -421,8 +428,9 @@ describe('ResourceReferenceInput', () => { />, ); - // 挂载即查询会让「工作区路径非法时不产生任何后端访问」的边界失效。 - expect(invoke).not.toHaveBeenCalled(); + // 挂载即查询 Skill 目录会让「工作区路径非法时不产生 Skill 后端访问」的边界失效。 + // 客户端偏好读取是 Rust 本地存储,不属于 Skill 目录访问。 + expect(invoke).not.toHaveBeenCalledWith('list_agc_skill_catalog'); insertComposerText('$'); @@ -589,6 +597,13 @@ describe('ResourceReferenceInput', () => { return [{ name: 'agc-test-skill', description: '测试 Skill' }]; } if (command === 'list_client_extensions') return []; + if (command === 'read_workspace_preferences') { + return { + projectCreationDirectory: null, + recentWorkspaces: [], + chatPromptPolishReminderDisabled: false, + }; + } throw new Error(`unexpected invoke ${command}`); }); window.__TAURI__ = { core: { invoke: invoke as never } }; @@ -612,7 +627,7 @@ describe('ResourceReferenceInput', () => { '用 $agc-test-skill 出图', ); }); - expect(invoke).not.toHaveBeenCalled(); + expect(invoke).not.toHaveBeenCalledWith('list_agc_skill_catalog'); // 用户敲出 `$`(菜单懒加载)之后目录才就绪,此后粘贴才重建 chip。 act(() => { diff --git a/apps/ai-game-creator-shell/tests/resourceTagStatsRefresh.test.tsx b/apps/ai-game-creator-shell/tests/resourceTagStatsRefresh.test.tsx index 1add06ef1..dcb318aaf 100644 --- a/apps/ai-game-creator-shell/tests/resourceTagStatsRefresh.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceTagStatsRefresh.test.tsx @@ -7,9 +7,11 @@ import { App, createGameCreationAppManifest, createProjectChatRuntimeHarness, + describe, expect, findResourceSelectButton, fireEvent, + it, ProjectDevelopmentView, render, screen, diff --git a/apps/ai-game-creator-shell/tests/useTemplateLibrary.test.tsx b/apps/ai-game-creator-shell/tests/useTemplateLibrary.test.tsx index 0d11196e2..9cbda5a08 100644 --- a/apps/ai-game-creator-shell/tests/useTemplateLibrary.test.tsx +++ b/apps/ai-game-creator-shell/tests/useTemplateLibrary.test.tsx @@ -11,7 +11,7 @@ import type { import { useTemplateLibrary } from '../src/features/template-library/useTemplateLibrary'; import { beginPlatformSessionClearTransition, - resetPlatformSessionStateForTests, + resetPlatformSessionForTests as resetPlatformSessionStateForTests, } from '../src/services/platformSession'; const invoke = vi.hoisted(() => vi.fn()); diff --git a/apps/ai-game-creator-shell/tests/walletStore.test.ts b/apps/ai-game-creator-shell/tests/walletStore.test.ts index b3b42fad8..984fb5cff 100644 --- a/apps/ai-game-creator-shell/tests/walletStore.test.ts +++ b/apps/ai-game-creator-shell/tests/walletStore.test.ts @@ -12,7 +12,7 @@ const clientApi = vi.hoisted(() => ({ getClientProfileWalletLedger: vi.fn(), })); -vi.mock('../src/services/clientApi', () => clientApi); +vi.mock('../src/services/accountHost', () => clientApi); import { useAccountWallet } from '../src/features/app-shell/useAccountWallet'; import { useWalletStore } from '../src/stores/useWalletStore';