diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index f176e5092..3626d7907 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -71,6 +71,10 @@ const eventCapabilityPath = new URL( '../src-tauri/capabilities/events.json', import.meta.url, ); +const windowChromeCapabilityPath = new URL( + '../src-tauri/capabilities/window-chrome.json', + import.meta.url, +); const defaultAppConfig = JSON.parse( fs.readFileSync( new URL('../game-creator.config.json', import.meta.url), @@ -1489,6 +1493,41 @@ if ( ); } +// 自绘标题栏的窗口手势由两条不同命令承担:三个窗口按钮走 JS API 的 +// toggle_maximize / minimize / close,拖拽区双击由 Tauri 注入的 drag.js 派发 +// internal_toggle_maximize。少授其中任何一条,用户手势都会在 ACL 处被拒, +// 且拒绝通过全局 unhandledrejection 冒到错误池,而不是表现为按钮无反应。 +if (!fs.existsSync(windowChromeCapabilityPath)) { + throw new Error( + 'AI game creator shell must declare a Tauri window-chrome capability', + ); +} +const windowChromeCapability = JSON.parse( + fs.readFileSync(windowChromeCapabilityPath, 'utf8'), +); +const windowChromeWindows = new Set(windowChromeCapability.windows ?? []); +const windowChromePermissions = new Set( + windowChromeCapability.permissions ?? [], +); +for (const windowLabel of ['client', 'main', 'launcher']) { + if (!windowChromeWindows.has(windowLabel)) { + throw new Error( + `AI game creator shell window-chrome capability missing window: ${windowLabel}`, + ); + } +} +for (const permission of [ + 'core:window:allow-start-dragging', + 'core:window:allow-toggle-maximize', + 'core:window:allow-internal-toggle-maximize', +]) { + if (!windowChromePermissions.has(permission)) { + throw new Error( + `AI game creator shell window-chrome capability missing permission: ${permission}`, + ); + } +} + if ( !Array.isArray(tauriConfig.app?.windows) || tauriConfig.app.windows.length !== 1 || diff --git a/apps/ai-game-creator-shell/src-tauri/capabilities/window-chrome.json b/apps/ai-game-creator-shell/src-tauri/capabilities/window-chrome.json index f7d700272..9717ec72f 100644 --- a/apps/ai-game-creator-shell/src-tauri/capabilities/window-chrome.json +++ b/apps/ai-game-creator-shell/src-tauri/capabilities/window-chrome.json @@ -5,6 +5,7 @@ "windows": ["client", "main", "launcher"], "permissions": [ "core:window:allow-close", + "core:window:allow-internal-toggle-maximize", "core:window:allow-is-maximized", "core:window:allow-minimize", "core:window:allow-start-dragging", diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index 3fe5183e5..179adde22 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -318,6 +318,7 @@ export interface GameCreatorAppConfig { }; selectedModelId?: string; selectedModelIsDefault?: boolean; + selectedModelProtocol?: GameCreatorLlmApiKind; planning?: { capabilityEnabled: boolean; }; 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 93afe7a43..605eb7220 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 @@ -262,19 +262,30 @@ export function ConversationModelSelect({ ); } } - if (next && (next !== saved || nextIsDefault !== followsDefault)) { + const nextModel = catalog.models.find((model) => model.id === next); + const nextProtocol = nextModel + ? resolveModelProtocol(nextModel) + : undefined; + // 后台可能只改同一目录项的 agentMode/protocol,而不改 id。旧配置若仍保留 + // Responses,会把 Claude/Anthropic 模型发到 /v1/responses 并在上游才失败。 + // 缺省协议按 Responses 兼容旧配置;非 Responses 或已有显式协议发生漂移时必须写回。 + const protocolChanged = + Boolean(nextModel) && + config.config.selectedModelProtocol !== nextProtocol && + (config.config.selectedModelProtocol != null || + nextProtocol !== 'openai_responses'); + if ( + next && + (next !== saved || nextIsDefault !== followsDefault || protocolChanged) + ) { let persisted: GameCreatorAppConfigView; try { persisted = await queueConfigWrite(() => invoke('select_game_creator_model', { modelId: next, isDefault: nextIsDefault, - agentMode: - catalog.models.find((model) => model.id === next)?.agentMode ?? - 'codex', - protocol: resolveModelProtocol( - catalog.models.find((model) => model.id === next), - ), + agentMode: nextModel?.agentMode ?? 'codex', + protocol: nextProtocol, }), ); } catch (error) { diff --git a/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx b/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx index 409f4854f..37b12e60c 100644 --- a/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx +++ b/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx @@ -36,6 +36,8 @@ const nativeInvoke = vi.fn((command: string, input: unknown) => ); let savedModelId = 'quality'; let savedModelIsDefault = true; +let savedModelProtocol: string | undefined = 'openai_responses'; +let savedAgentMode = 'codex_app_server'; beforeEach(() => { vi.clearAllMocks(); @@ -51,15 +53,22 @@ beforeEach(() => { }); savedModelId = 'quality'; savedModelIsDefault = true; + savedModelProtocol = 'openai_responses'; + savedAgentMode = 'codex_app_server'; invoke.mockImplementation(async (command, input) => { if (command === 'select_game_creator_model') { savedModelId = String(input.modelId); savedModelIsDefault = Boolean(input.isDefault); + savedModelProtocol = input.protocol; + savedAgentMode = + input.agentMode === 'cc' ? 'claude_code_cli' : 'codex_app_server'; } return { config: { + agentMode: savedAgentMode, selectedModelId: savedModelId, selectedModelIsDefault: savedModelIsDefault, + selectedModelProtocol: savedModelProtocol, }, }; }); @@ -622,6 +631,70 @@ test('keeps an explicit selection when the server default changes', async () => expect(savedModelIsDefault).toBe(false); }); +test('rewrites the saved protocol when the selected model routing changes', async () => { + let selectedModelId = 'claude-opus-5-5'; + let selectedModelIsDefault = false; + let selectedModelProtocol = 'openai_responses'; + let agentMode = 'codex_app_server'; + vi.mocked(loadClientLlmModels).mockResolvedValue({ + defaultModelId: 'claude-opus-5-5', + models: [ + { + id: 'claude-opus-5-5', + displayName: 'claude-opus-5-5', + agentMode: 'codex', + protocol: 'openai_responses', + }, + ], + unavailableModels: [], + revision: 7, + }); + invoke.mockImplementation(async (command, input) => { + if (command === 'select_game_creator_model') { + selectedModelId = input.modelId; + selectedModelIsDefault = Boolean(input.isDefault); + selectedModelProtocol = input.protocol; + agentMode = + input.agentMode === 'cc' ? 'claude_code_cli' : 'codex_app_server'; + } + return { + config: { + agentMode, + selectedModelId, + selectedModelIsDefault, + selectedModelProtocol, + }, + }; + }); + + render(); + await screen.findByRole('button', { name: '对话模型' }); + + vi.mocked(loadClientLlmModels).mockResolvedValue({ + defaultModelId: 'claude-opus-5-5', + models: [ + { + id: 'claude-opus-5-5', + displayName: 'claude-opus-5-5', + agentMode: 'cc', + protocol: 'anthropic', + }, + ], + unavailableModels: [], + revision: 8, + }); + fireEvent(window, new Event('focus')); + + await waitFor(() => expect(selectedModelProtocol).toBe('anthropic')); + expect(agentMode).toBe('claude_code_cli'); + expect(invoke).toHaveBeenCalledWith('select_game_creator_model', { + modelId: 'claude-opus-5-5', + isDefault: false, + agentMode: 'cc', + protocol: 'anthropic', + }); +}); + test('recovers the selector when reading the native config fails', async () => { invoke.mockRejectedValueOnce(new Error('config unreadable')); const onReady = vi.fn(); @@ -649,14 +722,19 @@ test('pre-send validation waits for an in-flight selection save', async () => { resolveSave = () => { savedModelId = String(input.modelId); savedModelIsDefault = Boolean(input.isDefault); + savedModelProtocol = input.protocol; + savedAgentMode = + input.agentMode === 'cc' ? 'claude_code_cli' : 'codex_app_server'; resolve(); }; }); } return { config: { + agentMode: savedAgentMode, selectedModelId: savedModelId, selectedModelIsDefault: savedModelIsDefault, + selectedModelProtocol: savedModelProtocol, }, }; }); diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index f8702b2fb..79f46f59b 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -1176,6 +1176,14 @@ AGC macOS 发布入口一度传入 `--no-sign`(目的是绕过没有 Apple 证 - **当前网络边界**:登录与素材直传均已由 Rust facade 承担,渲染层不再授予 `http:default`;旧“登录 HTTP ACL 拒绝”症状不作为现役诊断依据。窗口 label 漂移仍会影响原生对话框、剪贴板、opener/updater 等权限。 - **关联**:`apps/ai-game-creator-shell/scripts/build-release.mjs`、`build-release.test.mjs`、`check-config.mjs`、`src-tauri/tauri.conf.json`、`src-tauri/capabilities/main.json`。 +### 自绘标题栏双击最大化走的是另一条命令,只授 `toggle-maximize` 会以未捕获拒绝冒到错误池 + +- **现象**:双击 AGC 自绘标题栏的拖拽区,窗口不最大化,错误池多一条 `unhandledrejection`:`window.internal_toggle_maximize not allowed. Permissions associated with this command: core:window:allow-internal-toggle-maximize, core:window:default`;同一根标题栏上的最小化 / 最大化按钮却都正常。 +- **原因**:同一个用户动作对应两个不同命令。按钮走 JS API `getCurrentWindow().toggleMaximize()` → `plugin:window|toggle_maximize`(权限 `core:window:allow-toggle-maximize`);拖拽区双击由 Tauri 注入的 `drag.js` 派发 → `plugin:window|internal_toggle_maximize`(权限 `core:window:allow-internal-toggle-maximize`)。capability 只授了前者,ACL 拒绝后 `drag.js` 里那个没人接管的 `invoke` 直接变成全局 `unhandledrejection`,于是症状看起来像「窗口按钮正常、双击没反应还多一条错误上报」。 +- **处理(现行口径)**:带 `data-tauri-drag-region` 且要保留「双击最大化」的窗口,capability 必须显式列出 `core:window:allow-internal-toggle-maximize`。`core:window:default` 虽然包含这条,但 capability 不引用 default 集合时不会生效;新增窗口或新 capability 时按同一口径核对全部窗口手势。 +- **验证判据**:改 capability 后必须重新编译并重启客户端(编译期生成 ACL,运行中的窗口不会继承),再双击标题栏:窗口应最大化,且错误池不再新增该条。 +- **关联**:`apps/ai-game-creator-shell/src-tauri/capabilities/window-chrome.json`、`apps/ai-game-creator-shell/src/components/WindowChrome.tsx`。 + ### 按目录扫描更新产物时须核对包内版本与渠道身份 - **风险**:复用构建工作区里残留的其它版本或渠道更新包,会被扫描式清单生成器选中;对象存在且签名有效仍可能发错包。 @@ -3529,6 +3537,13 @@ Phaser `Scale.FIT` 与 `autoCenter: CENTER_BOTH` 会给 canvas 计算定位外 当前 Router 用户保持 `LLM_ROUTER_USER_GROUP = "taonier"`,Key 创建与修复使用 `LLM_ROUTER_TOKEN_GROUP = "auto"`。排查时分别核对用户、Key 和渠道,不因用户组正确就排除路由问题。模型目录启动同步使用用户分组 `LLM_ROUTER_USER_GROUP`(`taonier`),不能复用 Token 的 `auto` 路由分组,否则调整 Key 路由会意外改变在售目录;目录请求测试须固定断言 `group=taonier`。合法的存量目录不会自动重建,不能把 Key 修复成功当作目录已更新。入口为 `external_api_keys.rs` 与 `agc_models.rs`。 +### 模型目录协议变更必须同步回写客户端选中项 + +- **现象**:后台把同一个模型目录项从 Codex/Responses 改成 Claude Code/Anthropic 后,客户端仍把该模型发到 `/v1/responses`,Router 最终以 `500 not implemented` 失败;模型列表本身已经显示新协议。 +- **根因**:模型选择器原先只在 `selectedModelId` 或默认项变化时写回配置,没有比较同一 ID 的 `protocol`。已选模型保留旧的 `selectedModelProtocol` 和 `agentMode`,直到用户重新点选。 +- **现行口径**:目录同步发现协议漂移时,按目录中的 `agentMode` / `protocol` 重写选中项;旧配置缺省协议仍按 `openai_responses` 兼容,非 Responses 或已有显式协议不一致才触发写入。回归测试覆盖同一模型 ID 从 Codex/Responses 切到 Claude Code/Anthropic。 +- **关联**:`ConversationModelSelect.tsx`、`apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx`。 + ## 跨领域与待归类 ### 资源预览的 scope、容量与队列边界不能靠浅层文本测试验证