From 4f3f0f24ffba2f18f686541ebf2609becc1d0574 Mon Sep 17 00:00:00 2001 From: suzmii Date: Sat, 5 Sep 2026 19:10:49 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=A5=E5=85=A5AGC=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E7=9B=AE=E5=BD=95=E4=B8=8E=E5=AF=B9=E8=AF=9D?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E9=80=89=E6=8B=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增后台 AGC 模型目录、别名、启停和默认项管理 客户端设置页恢复原状,对话框右下角按别名选择模型 服务端按稳定模型标识映射并校验实际模型白名单 修复 AGC 配套后端端口漂移、启动等待和 SpacetimeDB 版本检查 补充迁移、文档、启动与模型选择测试 --- apps/admin-web/src/api/adminApiClient.ts | 16 + apps/admin-web/src/api/adminApiTypes.ts | 12 + apps/admin-web/src/app/AdminApp.tsx | 4 + apps/admin-web/src/app/AdminShell.tsx | 1 + apps/admin-web/src/app/adminRoutes.ts | 7 +- .../src/pages/AdminAgcModelsPage.test.tsx | 54 ++ .../src/pages/AdminAgcModelsPage.tsx | 218 ++++++ apps/admin-web/src/styles/admin.css | 53 +- .../game-creator.config.json | 2 +- .../scripts/start-dev-stack.mjs | 3 + .../scripts/start-tauri-dev.mjs | 77 +- .../src-tauri/src/agent/codex_app_server.rs | 4 +- .../src-tauri/src/commands.rs | 32 + .../src-tauri/src/config.rs | 38 +- .../src-tauri/src/main.rs | 40 +- apps/ai-game-creator-shell/src/app/types.ts | 11 +- .../ConversationModelSelect.tsx | 110 +++ .../ProjectSupervisorView.tsx | 25 +- .../runtime-config/RuntimeConfigDialog.tsx | 618 ++-------------- .../src/services/clientApi.ts | 6 +- apps/ai-game-creator-shell/src/styles.css | 665 ++---------------- .../tests/appSurface/harness.ts | 41 ++ .../appSurface/runtime-settings.suite.ts | 148 +--- .../tests/conversationModelSelect.test.tsx | 89 +++ .../tests/start-tauri-dev.test.ts | 43 +- .../shared-memory/decision-log.md | 2 +- docs/project-memory/shared-memory/pitfalls.md | 6 + ...方案】AGC后台模型别名与对话选择-2026-09-05.md | 17 + ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 7 + ...发运维】本地开发验证与生产运维-2026-05-15.md | 4 + scripts/dev.mjs | 23 + scripts/dev.test.ts | 38 + server-rs/crates/api-server/src/admin.rs | 15 + server-rs/crates/api-server/src/agc_models.rs | 97 +++ server-rs/crates/api-server/src/config.rs | 2 +- server-rs/crates/api-server/src/llm/mod.rs | 259 +++---- server-rs/crates/api-server/src/main.rs | 1 + .../crates/api-server/src/modules/admin.rs | 9 + .../crates/module-runtime/src/agc_models.rs | 118 ++++ server-rs/crates/module-runtime/src/lib.rs | 2 + .../crates/shared-contracts/src/admin.rs | 17 + server-rs/crates/shared-contracts/src/llm.rs | 13 + .../crates/spacetime-client/src/active.rs | 1 + .../crates/spacetime-client/src/agc_models.rs | 42 ++ .../spacetime-client/src/module_bindings.rs | 29 + .../agc_model_catalog_row_type.rs | 58 ++ .../agc_model_catalog_table.rs | 230 ++++++ .../read_agc_model_catalog_procedure.rs | 52 ++ .../save_agc_model_catalog_procedure.rs | 56 ++ .../crates/spacetime-module/src/active.rs | 3 + .../crates/spacetime-module/src/agc_models.rs | 66 ++ .../crates/spacetime-module/src/migration.rs | 2 + 52 files changed, 1942 insertions(+), 1544 deletions(-) create mode 100644 apps/admin-web/src/pages/AdminAgcModelsPage.test.tsx create mode 100644 apps/admin-web/src/pages/AdminAgcModelsPage.tsx create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx create mode 100644 apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx create mode 100644 docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md create mode 100644 server-rs/crates/api-server/src/agc_models.rs create mode 100644 server-rs/crates/module-runtime/src/agc_models.rs create mode 100644 server-rs/crates/spacetime-client/src/agc_models.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/agc_model_catalog_row_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/agc_model_catalog_table.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/read_agc_model_catalog_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/save_agc_model_catalog_procedure.rs create mode 100644 server-rs/crates/spacetime-module/src/agc_models.rs diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index cd73d02d8..1f53fd340 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -1072,3 +1072,19 @@ function buildAdminApiError( function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } +export function getAgcModelCatalog(token: string) { + return request( + '/admin/api/agc-models', + { token }, + ); +} + +export function saveAgcModelCatalog( + token: string, + body: import('./adminApiTypes').AdminAgcModelCatalog, +) { + return request( + '/admin/api/agc-models', + { token, method: 'PUT', body }, + ); +} diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index d8cda23c0..1973d77a5 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -998,3 +998,15 @@ export interface AdminRechargeRefundActionResponse { export interface AdminWalletRestrictionResponse { wallet: AdminProfileWalletPayload; } +export interface AdminAgcModel { + id: string; + alias: string; + modelId: string; + enabled: boolean; +} + +export interface AdminAgcModelCatalog { + revision: number; + defaultModelId: string; + models: AdminAgcModel[]; +} diff --git a/apps/admin-web/src/app/AdminApp.tsx b/apps/admin-web/src/app/AdminApp.tsx index a717f146b..afb62b22f 100644 --- a/apps/admin-web/src/app/AdminApp.tsx +++ b/apps/admin-web/src/app/AdminApp.tsx @@ -18,6 +18,7 @@ import { setStoredAdminToken, } from '../auth/adminAuthStore'; import { AdminAccountsPage } from '../pages/AdminAccountsPage'; +import { AdminAgcModelsPage } from '../pages/AdminAgcModelsPage'; import { AdminDashboardPage } from '../pages/AdminDashboardPage'; import { AdminDatabaseTablesPage } from '../pages/AdminDatabaseTablesPage'; import { AdminDebugHttpPage } from '../pages/AdminDebugHttpPage'; @@ -289,6 +290,9 @@ export function AdminApp() { onUnauthorized={handleUnauthorized} /> ) : null} + {activeRouteId === 'agc-models' ? ( + + ) : null} {activeRouteId === 'editor-showcase' ? ( ; export function AdminShell({ diff --git a/apps/admin-web/src/app/adminRoutes.ts b/apps/admin-web/src/app/adminRoutes.ts index 6afaf6b7e..1110bb05d 100644 --- a/apps/admin-web/src/app/adminRoutes.ts +++ b/apps/admin-web/src/app/adminRoutes.ts @@ -16,9 +16,13 @@ export type AdminRouteId = | 'editor-generation-pricing' | 'editor-showcase' | 'editor-assets' + | 'agc-models' | 'accounts'; -export type AdminTabPermission = Exclude; +export type AdminTabPermission = Exclude< + AdminRouteId, + 'accounts' | 'agc-models' +>; /** 后台导航项定义,hash 是浏览器地址栏和移动底栏共用入口。 */ export interface AdminRouteDefinition { @@ -47,6 +51,7 @@ export const adminRoutes: AdminRouteDefinition[] = [ label: '模型定价', hash: '#editor-generation-pricing', }, + { id: 'agc-models', label: 'AGC 模型', hash: '#agc-models', ownerOnly: true }, { id: 'editor-showcase', label: '精选审核', hash: '#editor-showcase' }, { id: 'editor-assets', label: '素材查询', hash: '#editor-assets' }, { id: 'accounts', label: '账号管理', hash: '#accounts', ownerOnly: true }, diff --git a/apps/admin-web/src/pages/AdminAgcModelsPage.test.tsx b/apps/admin-web/src/pages/AdminAgcModelsPage.test.tsx new file mode 100644 index 000000000..41bf69552 --- /dev/null +++ b/apps/admin-web/src/pages/AdminAgcModelsPage.test.tsx @@ -0,0 +1,54 @@ +// @vitest-environment jsdom +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; +import { afterEach, expect, test, vi } from 'vitest'; + +import { getAgcModelCatalog, saveAgcModelCatalog } from '../api/adminApiClient'; +import { AdminAgcModelsPage } from './AdminAgcModelsPage'; + +vi.mock('../api/adminApiClient', () => ({ + getAgcModelCatalog: vi.fn(), + saveAgcModelCatalog: vi.fn(), + isAdminApiError: vi.fn(() => false), + formatAdminApiError: vi.fn(() => '保存失败'), +})); +vi.mock('../components/useAdminWriteConfirm', () => ({ + useAdminWriteConfirm: () => ({ + confirmWrite: async () => true, + confirmDialog: null, + }), +})); +afterEach(cleanup); + +test('edits alias and upstream model without changing the stable identifier or revision', async () => { + const catalog = { + revision: 3, + defaultModelId: 'quality', + models: [ + { id: 'quality', alias: '高质量', modelId: 'gpt-6-astra', enabled: true }, + ], + }; + vi.mocked(getAgcModelCatalog).mockResolvedValue(catalog); + vi.mocked(saveAgcModelCatalog).mockImplementation(async (_, input) => ({ + ...input, + revision: 4, + })); + render(); + await screen.findByDisplayValue('gpt-6-astra'); + fireEvent.change(screen.getByLabelText('模型 1 别名'), { + target: { value: '精细创作' }, + }); + fireEvent.click(screen.getByRole('button', { name: '保存' })); + await waitFor(() => + expect(saveAgcModelCatalog).toHaveBeenCalledWith('test', { + ...catalog, + models: [{ ...catalog.models[0], alias: '精细创作' }], + }), + ); + await screen.findByText('已保存'); +}); diff --git a/apps/admin-web/src/pages/AdminAgcModelsPage.tsx b/apps/admin-web/src/pages/AdminAgcModelsPage.tsx new file mode 100644 index 000000000..a9affc116 --- /dev/null +++ b/apps/admin-web/src/pages/AdminAgcModelsPage.tsx @@ -0,0 +1,218 @@ +import { Plus, RefreshCcw, Save, Trash2 } from 'lucide-react'; +import { useEffect, useState } from 'react'; + +import { getAgcModelCatalog, saveAgcModelCatalog } from '../api/adminApiClient'; +import type { AdminAgcModel, AdminAgcModelCatalog } from '../api/adminApiTypes'; +import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm'; +import { handlePageError } from './pageUtils'; + +export function AdminAgcModelsPage({ + token, + onUnauthorized, +}: { + token: string; + onUnauthorized: (message?: string) => void; +}) { + const [catalog, setCatalog] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + const [saved, setSaved] = useState(false); + const { confirmWrite, confirmDialog } = useAdminWriteConfirm(); + + async function refresh() { + if (!token) return; + setBusy(true); + setError(''); + setSaved(false); + try { + setCatalog(await getAgcModelCatalog(token)); + } catch (error) { + handlePageError(error, onUnauthorized, setError); + } finally { + setBusy(false); + } + } + + useEffect(() => { + void refresh(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [token]); + + function update(id: string, patch: Partial) { + setSaved(false); + setCatalog( + (current) => + current && { + ...current, + models: current.models.map((model) => + model.id === id ? { ...model, ...patch } : model, + ), + }, + ); + } + + async function save() { + if (!catalog || busy) return; + if ( + !(await confirmWrite({ + action: '保存 AGC 模型目录', + target: `${catalog.models.length} 个模型`, + })) + ) + return; + setBusy(true); + setError(''); + setSaved(false); + try { + setCatalog(await saveAgcModelCatalog(token, catalog)); + setSaved(true); + } catch (error) { + handlePageError(error, onUnauthorized, setError); + } finally { + setBusy(false); + } + } + + return ( +
+
+

AGC 模型

+
+ + + +
+
+ {error ?

{error}

: null} + {saved ?

已保存

: null} + {busy ?

正在处理

: null} +
+ + + + + + + + + + + + {catalog?.models.map((model, index) => ( + + + + + + + + ))} + +
别名实际模型名启用默认操作
+ + update(model.id, { alias: e.target.value }) + } + /> + + + update(model.id, { modelId: e.target.value }) + } + /> + + + update(model.id, { enabled: e.target.checked }) + } + /> + + { + setSaved(false); + setCatalog({ ...catalog, defaultModelId: model.id }); + }} + /> + + +
+
+ {confirmDialog} +
+ ); +} diff --git a/apps/admin-web/src/styles/admin.css b/apps/admin-web/src/styles/admin.css index adfb46737..e500b7abd 100644 --- a/apps/admin-web/src/styles/admin.css +++ b/apps/admin-web/src/styles/admin.css @@ -3093,5 +3093,54 @@ button:disabled { background: var(--admin-surface, #fff); } .admin-detail-modal__panel header, -.admin-detail-modal__actions { display: flex; align-items: center; justify-content: space-between; gap: 12px; } -.admin-detail-modal__panel pre { max-height: 360px; overflow: auto; white-space: pre-wrap; background: #f8fafc; padding: 12px; border-radius: 8px; } +.admin-detail-modal__actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.admin-detail-modal__panel pre { + max-height: 360px; + overflow: auto; + white-space: pre-wrap; + background: #f8fafc; + padding: 12px; + border-radius: 8px; +} +.admin-agc-models { + min-width: 0; +} +.admin-agc-models > header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.admin-agc-models > header > div { + display: flex; + gap: 8px; +} +.admin-agc-models button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + min-height: 32px; +} +.admin-agc-models-table { + overflow-x: auto; +} +.admin-agc-models table { + width: 100%; + border-collapse: collapse; +} +.admin-agc-models th, +.admin-agc-models td { + padding: 8px; + text-align: left; +} +.admin-agc-models td input:not([type]) { + min-width: 150px; + width: 100%; + box-sizing: border-box; +} diff --git a/apps/ai-game-creator-shell/game-creator.config.json b/apps/ai-game-creator-shell/game-creator.config.json index 6b3214da9..bb00a1ed7 100644 --- a/apps/ai-game-creator-shell/game-creator.config.json +++ b/apps/ai-game-creator-shell/game-creator.config.json @@ -4,7 +4,7 @@ "llm": { "apiKey": "", "baseUrl": "https://dev.genarrative.world/gpt/v1", - "model": "gpt-5.6-sol", + "model": "gpt-6-astra", "apiKind": "openai_responses", "reasoningEffort": "max", "stream": true, diff --git a/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs b/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs index 968c8927e..397d88dcf 100644 --- a/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs +++ b/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs @@ -540,6 +540,7 @@ async function ensureBackend({ backendDatabase, '--spacetime-data-dir', backendSpacetimeDataDir, + '--preserve-database', '--no-interactive', ], { cwd: appRoot }, @@ -680,11 +681,13 @@ function isDirectModuleExecution() { export { ensureBackend, formatChildFailure, + isAiGameCreatorServer, isBackendReady, isDirectModuleExecution, isProcessGroupAlive, preflightExistingVite, readChildFailure, + readExistingViteServer, readLinuxProcessGroupAlive, resolveBackendTargetsFromState, runWindowsTaskkill, diff --git a/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs b/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs index 7667c1389..95a866f95 100644 --- a/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs +++ b/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs @@ -7,7 +7,10 @@ import { withAgcDevEndpointEnv, } from './dev-port.mjs'; import { + isAiGameCreatorServer, preflightExistingVite, + readChildFailure, + readExistingViteServer, spawnChild, stopChild, terminateChildTree, @@ -20,7 +23,9 @@ const tauriCliPath = resolve(repoRoot, 'node_modules/@tauri-apps/cli/tauri.js'); function buildTauriArguments(argv, devUrl = readAgcDevEndpoint().url) { const args = [...argv]; - const configOverride = JSON.stringify({ build: { devUrl } }); + const configOverride = JSON.stringify({ + build: { devUrl, beforeDevCommand: '' }, + }); const separatorIndex = args.indexOf('--'); if (separatorIndex < 0) { return ['dev', ...args, '--config', configOverride]; @@ -51,6 +56,7 @@ async function runTauriDev( { resolveDevEndpoint = resolveAgcDevEndpoint, preflight = preflightExistingVite, + prepareFrontend = prepareFrontendDev, spawnCli = spawnTauriCli, waitForCli = waitForChildTermination, terminateTree = terminateChildTree, @@ -59,10 +65,9 @@ async function runTauriDev( const endpoint = await resolveDevEndpoint(); await preflight({ endpoint }); - const tauriArguments = buildTauriArguments(argv, endpoint.url); - const child = spawnCli(tauriArguments, { - env: withAgcDevEndpointEnv(endpoint), - }); + let child = null; + let frontendChild = null; + const preparationAbort = new AbortController(); let resolveShutdown; let shutdownSignal = ''; let repeatedSignal = false; @@ -76,21 +81,47 @@ async function runTauriDev( if (!shutdownSignal) { shutdownSignal = signal; stopChild(child, 'SIGTERM'); + stopChild(frontendChild, 'SIGTERM'); + preparationAbort.abort(); resolveShutdown(signal); return; } repeatedSignal = true; stopChild(child, 'SIGKILL'); + stopChild(frontendChild, 'SIGKILL'); }; signalHandlers.set(signal, handler); process.on(signal, handler); } try { + const preparation = prepareFrontend(endpoint, { + signal: preparationAbort.signal, + onChild(frontend) { + frontendChild = frontend; + }, + }); + const prepared = await Promise.race([ + preparation.then(() => true), + shutdownRequested.then(() => false), + ]); + if (!prepared || shutdownSignal) return 1; + const tauriArguments = buildTauriArguments(argv, endpoint.url); + child = spawnCli(tauriArguments, { + env: withAgcDevEndpointEnv(endpoint), + }); const childResult = waitForCli(child); const outcome = await Promise.race([ childResult.then((failure) => ({ type: 'exit', failure })), shutdownRequested.then((signal) => ({ type: 'signal', signal })), + ...(frontendChild + ? [ + waitForChildTermination(frontendChild).then((failure) => ({ + type: 'frontend-exit', + failure, + })), + ] + : []), ]); const cleanup = await terminateTree(child, { gracefulTimeoutMs: repeatedSignal ? 0 : 2500, @@ -105,15 +136,51 @@ async function runTauriDev( if (outcome.type === 'signal') { return 1; } + if (outcome.type === 'frontend-exit') return 1; const { failure } = outcome; return failure.type === 'error' || failure.signal ? 1 : (failure.code ?? 0); } finally { for (const [signal, handler] of signalHandlers) { process.off(signal, handler); } + preparationAbort.abort(); + if (frontendChild) { + const cleanup = await terminateTree(frontendChild); + if (!cleanup.stopped) { + console.error('[ai-game-creator-shell] 配套开发服务未能完全停止。'); + } + } } } +async function prepareFrontendDev(endpoint, { onChild, signal }) { + const frontend = spawnChild( + process.platform === 'win32' ? 'npm.cmd' : 'npm', + ['run', 'agc:serve'], + { cwd: repoRoot, env: withAgcDevEndpointEnv(endpoint) }, + ); + onChild(frontend); + console.log( + '[ai-game-creator-shell] 正在准备前端与配套后端,完成后启动 Tauri', + ); + const deadline = Date.now() + 660_000; + while (Date.now() < deadline) { + signal.throwIfAborted(); + const failure = readChildFailure(frontend); + if (failure) { + throw new Error( + `配套开发服务退出,前端未就绪:${failure.error?.message ?? failure.signal ?? failure.code}`, + ); + } + if (isAiGameCreatorServer(await readExistingViteServer(endpoint))) return; + await Promise.race([ + new Promise((resolveWait) => setTimeout(resolveWait, 1000)), + waitForChildTermination(frontend), + ]); + } + throw new Error(`等待前端与配套后端就绪超时:${endpoint.url}`); +} + function isDirectModuleExecution() { return Boolean( process.argv[1] && diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs index 938e36a8e..a20098cc1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs @@ -1776,8 +1776,8 @@ impl CodexAppServerConnection { effective_llm.base_url = format!("{}/api/llm", session.api_base_url.trim_end_matches('/')); effective_llm.api_key.clear(); - // The selected model profile is resolved and validated at config - // load time; the official route still owns the provider/base URL. + // Only the platform catalog identifier reaches Codex. api-server + // validates it and resolves the actual upstream model. effective_llm.model = llm.model.clone(); CodexAppServerCredential::PlatformSession { fingerprint: format!( diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 03597027a..2f564e863 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -1997,8 +1997,40 @@ pub(crate) fn read_game_creator_app_config() -> Result = std::sync::Mutex::new(()); + #[tauri::command] pub(crate) fn write_game_creator_app_config( + mut config: GameCreatorAppConfig, +) -> Result { + let _guard = GAME_CREATOR_CONFIG_WRITE_LOCK + .lock() + .map_err(|_| "配置写入锁不可用")?; + config.selected_model_id = load_game_creator_app_config()?.selected_model_id; + persist_game_creator_app_config(config) +} + +#[tauri::command] +pub(crate) fn select_game_creator_model( + model_id: String, +) -> Result { + let _guard = GAME_CREATOR_CONFIG_WRITE_LOCK + .lock() + .map_err(|_| "配置写入锁不可用")?; + if model_id.is_empty() + || model_id.len() > 64 + || !model_id + .bytes() + .all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_') + { + return Err("模型标识无效".into()); + } + let mut config = load_game_creator_app_config()?; + config.selected_model_id = model_id; + persist_game_creator_app_config(config) +} + +fn persist_game_creator_app_config( config: GameCreatorAppConfig, ) -> Result { let config = normalize_game_creator_app_config(config)?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/config.rs b/apps/ai-game-creator-shell/src-tauri/src/config.rs index f3de56e66..8fbfff37b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/config.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs @@ -93,7 +93,7 @@ fn user_selected_path_is_authorized(path: &Path, is_directory: bool) -> bool { } pub(crate) const OFFICIAL_LLM_ROUTER_BASE_URL: &str = "https://router.genarrative.world/v1"; -pub(crate) const OFFICIAL_LLM_ROUTER_MODEL: &str = "gpt-5.6-sol"; +pub(crate) const OFFICIAL_LLM_ROUTER_MODEL: &str = "gpt-6-astra"; pub(crate) const GAME_CREATOR_LLM_AGENT_REASONING_EFFORT_DEFAULTS: [(&str, &str); 21] = [ (GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, "high"), @@ -3482,23 +3482,11 @@ pub(crate) fn lock_game_creator_app_config_to_official_route(config: &mut GameCr config.agent_mode = GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER.to_string(); config.llm.api_key.clear(); config.llm.base_url = OFFICIAL_LLM_ROUTER_BASE_URL.to_string(); - if let Some(profile) = config - .model_profiles - .iter() - .find(|profile| profile.enabled && profile.id == config.selected_model_profile_id) - { - if !profile.model_id.trim().is_empty() { - config.llm.model = profile.model_id.trim().to_string(); - } - if let Some(reasoning_effort) = profile.reasoning_effort.as_deref() { - if !reasoning_effort.trim().is_empty() { - config.llm.reasoning_effort = reasoning_effort.trim().to_string(); - } - } - } - if config.llm.model.trim().is_empty() { - config.llm.model = OFFICIAL_LLM_ROUTER_MODEL.to_string(); - } + config.llm.model = if config.selected_model_id.is_empty() { + "platform-default".to_string() + } else { + config.selected_model_id.clone() + }; config.llm.api_kind = DEFAULT_GAME_CREATOR_LLM_API_KIND.to_string(); config.agent_llm.clear(); config.editor_api.api_key.clear(); @@ -3632,18 +3620,8 @@ pub(crate) fn merge_game_creator_config_file( config.planning.capability_enabled = capability_enabled; } } - if let Some(model_profiles) = file_config.model_profiles { - config.model_profiles = model_profiles - .into_iter() - .filter(|profile| { - !profile.id.trim().is_empty() - && !profile.name.trim().is_empty() - && !profile.model_id.trim().is_empty() - }) - .collect(); - } - if let Some(selected_model_profile_id) = file_config.selected_model_profile_id { - config.selected_model_profile_id = selected_model_profile_id; + if let Some(selected_model_id) = file_config.selected_model_id { + config.selected_model_id = selected_model_id; } Ok(()) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index dc22839aa..90ffd9333 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -1021,25 +1021,7 @@ struct GameCreatorAppConfigFile { editor_api: Option, planning: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - model_profiles: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - selected_model_profile_id: Option, -} - -#[derive(Clone, Debug, Default, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -struct GameCreatorModelProfileFile { - id: String, - name: String, - model_id: String, - #[serde(default = "default_model_profile_enabled")] - enabled: bool, - #[serde(default)] - reasoning_effort: Option, -} - -fn default_model_profile_enabled() -> bool { - true + selected_model_id: Option, } #[derive(Clone, Debug, Default, Deserialize, Serialize)] @@ -1101,13 +1083,7 @@ struct GameCreatorAppConfig { #[serde(default)] planning: GameCreatorPlanningConfig, #[serde(default)] - model_profiles: Vec, - #[serde(default = "default_selected_model_profile_id")] - selected_model_profile_id: String, -} - -fn default_selected_model_profile_id() -> String { - "default".to_string() + selected_model_id: String, } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -1530,7 +1506,7 @@ const GAME_CREATOR_AGENT_MODE_CODEX_CLI: &str = "codex_cli"; const GAME_CREATOR_AGENT_MODE_PROVIDER: &str = "provider"; const GAME_CREATOR_APP_CONFIG_SCHEMA_VERSION: &str = "game-creator-config.v2"; const DEFAULT_GAME_CREATOR_LLM_BASE_URL: &str = "https://dev.genarrative.world/gpt/v1"; -const DEFAULT_GAME_CREATOR_LLM_MODEL: &str = "gpt-5.6-sol"; +const DEFAULT_GAME_CREATOR_LLM_MODEL: &str = "gpt-6-astra"; const DEFAULT_GAME_CREATOR_LLM_API_KIND: &str = "openai_responses"; const DEFAULT_GAME_CREATOR_LLM_REASONING_EFFORT: &str = "max"; const DEFAULT_GAME_CREATOR_LLM_CONTEXT_WINDOW_TOKENS: u64 = 128_000; @@ -1634,14 +1610,7 @@ impl Default for GameCreatorAppConfig { agent_llm: BTreeMap::new(), editor_api: GameCreatorEditorApiConfig::default(), planning: GameCreatorPlanningConfig::default(), - model_profiles: vec![GameCreatorModelProfileFile { - id: "default".to_string(), - name: "陶泥儿智能创作".to_string(), - model_id: OFFICIAL_LLM_ROUTER_MODEL.to_string(), - enabled: true, - reasoning_effort: Some("max".to_string()), - }], - selected_model_profile_id: default_selected_model_profile_id(), + selected_model_id: String::new(), } } } @@ -2570,6 +2539,7 @@ fn main() { clear_platform_account_session, read_game_creator_app_config, write_game_creator_app_config, + select_game_creator_model, upload_local_asset, register_local_asset, create_ui_design_resource, diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index df3f223ec..493fdb748 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -718,21 +718,12 @@ export interface GameCreatorAppConfig { baseUrl: string; apiKey: string; }; - modelProfiles: GameCreatorModelProfile[]; - selectedModelProfileId: string; + selectedModelId?: string; planning?: { capabilityEnabled: boolean; }; } -export interface GameCreatorModelProfile { - id: string; - name: string; - modelId: string; - enabled: boolean; - reasoningEffort: GameCreatorLlmReasoningEffort; -} - export interface GameCreatorAppConfigView { path: string; config: GameCreatorAppConfig; 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 new file mode 100644 index 000000000..60ed9f736 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx @@ -0,0 +1,110 @@ +import { RefreshCcw } from 'lucide-react'; +import { useCallback, useEffect, useState } from 'react'; + +import { resolveTauriInvoke } from '../../app/tauri'; +import type { GameCreatorAppConfigView } from '../../app/types'; +import { + type ClientLlmModel, + loadClientLlmModels, +} from '../../services/clientApi'; + +export function ConversationModelSelect({ + disabled, + onReady, +}: { + disabled: boolean; + onReady: (ready: boolean) => void; +}) { + const [models, setModels] = useState([]); + const [selected, setSelected] = useState(''); + const [busy, setBusy] = useState(true); + const [error, setError] = useState(''); + const refresh = useCallback(async () => { + setBusy(true); + setError(''); + onReady(false); + try { + const invoke = resolveTauriInvoke(); + if (!invoke) throw new Error('Native host unavailable'); + const [catalog, config] = await Promise.all([ + loadClientLlmModels(), + invoke('read_game_creator_app_config'), + ]); + setModels(catalog.models); + const id = config.config.selectedModelId || catalog.defaultModelId; + setSelected(id); + const available = catalog.models.some((model) => model.id === id); + if (available && !config.config.selectedModelId) { + const saved = await invoke( + 'select_game_creator_model', + { modelId: id }, + ); + if (saved.config.selectedModelId !== id) + throw new Error('Default selection was not saved'); + } + onReady(available); + if (!available) setError('请选择可用模型'); + } catch { + setModels([]); + setError('模型列表加载失败'); + } finally { + setBusy(false); + } + }, [onReady]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + async function select(id: string) { + onReady(false); + setBusy(true); + setError(''); + try { + const invoke = resolveTauriInvoke(); + if (!invoke) throw new Error('Native host unavailable'); + const result = await invoke( + 'select_game_creator_model', + { modelId: id }, + ); + if (result.config.selectedModelId !== id) + throw new Error('Selection was not saved'); + setSelected(id); + onReady(true); + } catch { + setError('模型选择保存失败'); + } finally { + setBusy(false); + } + } + + return ( +
+ {error ? {error} : null} + + +
+ ); +} diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx index bd209924d..2c04e0227 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx @@ -27,6 +27,7 @@ import { } from '../agent-runtime'; import { formatAgentCardRuntimeStatus } from '../project-summary/agentPresentation'; import { taskStatusLabels } from '../project-summary/projectSummary'; +import { ConversationModelSelect } from './ConversationModelSelect'; import { PlanGddSurface } from './GddApprovalCard'; import { pendingCommandDetail, @@ -143,6 +144,7 @@ export function ProjectSupervisorView({ ? '思考中' : '发送'; const submitting = runtimePanelProps.controlBusy && !needsUserInput; + const [modelReady, setModelReady] = useState(false); return (
) : null} -
+ { + if (directCodex && !modelReady) { + event.preventDefault(); + return; + } + onSubmit(event); + }} + >