Files
Genarrative/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts
T
suzmii a0e6a83691
Project CI / Repository checks (push) Successful in 2m19s
Project CI / Frontend tests (push) Successful in 3m32s
Project CI / Native shell tests (push) Failing after 19m48s
Project CI / Backend tests (push) Failing after 7m10s
修复 AGC 模型选择入口、交互与默认模型回退 (#299)
### 背景

AGC 后台已支持维护 LLM 模型目录(`agc_model_catalog`),客户端此前只在项目右侧对话浮出模型选择,且存在几个体验问题:

1. 首页聊天框架缺少模型选择入口;
2. 首次进入项目时,右侧模型选择器「点不动」(被 disabled);
3. 模型选择下拉失去焦点不自动收起;
4. 默认模型缺少可视标识;
5. 已保存的选择被后台停用/移除后,客户端显示「请选择模型」,未自动回退默认模型;首页因懒加载在打开前也一直显示「选择模型」。

### 改动内容(完整变更集)

- **首页聊天框架新增模型选择入口**,复用项目右侧对话的 `ConversationModelSelect`;上传按钮在左,模型选择器与发送按钮在右(同一组,模型选择器紧挨发送按钮)。
- **修复首次进入项目时右侧模型选择器点不动**:选择器不再因目录加载(`busy`)或对话进行中(`controlBusy`)被禁用;切换模型只写回客户端配置并作用于后续轮次,发送仍由 `controlBusy` / `modelReady` 把关。
- **模型下拉支持点击外部 / Esc 自动收起**:给组件容器挂 ref,监听 `document` 的 `mousedown` 与 `keydown`,点击容器外或按 Esc 即收起(仅在展开时挂监听)。
- **默认模型增加「默认」标识**:组件记录 `defaultModelId`,在下拉选项中给默认模型显示小标签;亮暗主题自适应。
- **模型选择始终回退默认模型**:已保存选择仍可用时沿用;被停用/移除或从未选择时回退到后台默认模型并写回配置;只有默认模型本身不可用才走错误分流。首页由「懒加载」改为「挂载即加载」,触发按钮直接落在默认模型上,不再出现「选择模型」空态。
- **清理**:移除组件中已无调用方的 `lazy` 加载路径。
- **文档**:同步更新 `【技术方案】AGC后台模型别名与对话选择-2026-09-05.md`(首页入口、对话进行中可切换模型、失焦收起等行为)。

### 关键交互/行为说明

- 触发按钮永远显示模型名(默认或当前选择),不再出现「选择模型」占位,除非默认模型不可用。
- 模型下拉的加载中、刷新、选中态沿用原有交互;默认模型有「默认」标签,便于识别。
- 首页模型选择器不改动「开启创作」门控:目录不可用时仍可创建项目并走后台默认项。

### 验证

- `npm run test -- apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx` -> 7/7 通过
- `npm run test -- apps/ai-game-creator-shell/tests/appSurface.test.ts` -> 386/386 通过
- `npm run ai-game-creator-shell:typecheck` 通过
- `npm run check:encoding`(4309 文件)通过
- `git diff --check` 通过

### 影响范围与注意事项

- 变更集中在 AGC 客户端(`apps/ai-game-creator-shell`),不涉及 SpacetimeDB schema / 后端接口 / 契约。
- `GET /api/llm/models` 语义不变(仍只返回启用项 + 默认项),本次不改后端。
- 模型下拉在窄屏的换行/遮挡,以及「默认」标签对比度,建议在真机确认一下观感(单测只覆盖逻辑)。

### 关联

- 无关联 Issue;本 PR 独立提交。

---------

Co-authored-by: 段舒康 <kdletters@qq.com>
Reviewed-on: #299
Co-authored-by: 董羽秦 <suzmii@qq.com>
Co-committed-by: 董羽秦 <suzmii@qq.com>
2026-09-08 22:05:15 +08:00

1119 lines
39 KiB
TypeScript

import fs from 'node:fs';
import path from 'node:path';
import { APP_VERSION } from '../../src/app/appMetadata';
import {
act,
createGameCreationAppManifest,
createGameCreationAppSeedTasks,
deriveAgentStatusCards,
expect,
fireEvent,
GAME_CREATION_AGENT_RUN_SCHEMA_VERSION,
type GameCreationAgentRunTrace,
it,
renderAppAt,
renderLauncherAt,
screen,
vi,
waitFor,
within,
} from './harness';
export function registerAgentStatusDerivationTests() {
it('keeps fixed overlays below the in-page window title bar', () => {
const styles = fs.readFileSync(
path.join(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
'utf8',
);
expect(styles).toContain('--window-chrome-height: 50px;');
expect(styles).toContain('.fixed.inset-0 {');
expect(styles).toContain('top: var(--window-chrome-height);');
expect(styles).toContain('.settings-overlay {');
expect(styles).toContain('.launcher-dialog-backdrop {');
expect(styles).toContain('.game-approval-backdrop {');
expect(styles).toContain('.gdd-approval-card__dialog-backdrop {');
expect(styles).toContain('> .launcher-shell .launcher-main {');
expect(styles).toContain('padding-bottom: 0;');
expect(styles).toContain('> .platform-theme {');
});
it('derives agent card status from the latest run trace step', () => {
const manifest = createGameCreationAppManifest(
'local-project-draft',
'未命名游戏原型',
);
const taskGraphTasks = createGameCreationAppSeedTasks().map((task) =>
task.id === 'audio-director'
? { ...task, status: 'waiting-for-confirmation' as const }
: task,
);
const trace: GameCreationAgentRunTrace = {
schemaVersion: GAME_CREATION_AGENT_RUN_SCHEMA_VERSION,
runId: 'run-agent-status-cards',
commandId: 'game.generate_draft',
status: 'running',
lifecycleStatus: 'pending',
passes: 1,
maxPasses: 3,
toolCallCount: 3,
maxToolCalls: 128,
stopReason: 'running',
goal: '做一个厨房弹幕游戏',
coordination: 'Planner -> Orchestrator',
steps: [
{
pass: 1,
agent: 'Planner',
phase: 'plan',
taskId: 'design-director',
group: 'design',
role: 'Director',
status: 'running',
inputPaths: ['memory/session.md'],
outputPaths: ['.agent/spec.md'],
summary: '正在拆解创作方向',
toolCalls: [
{
toolId: 'llm.planner',
status: 'ok',
inputPaths: ['memory/session.md'],
outputPaths: ['.agent/spec.md'],
summary: 'Planner 已读取短期记忆',
},
],
},
{
pass: 1,
agent: 'Asset',
phase: 'role',
taskId: null,
group: 'art',
role: 'Asset',
status: 'failed',
inputPaths: [],
outputPaths: [],
summary: '美术资产生成失败',
toolCalls: [],
},
{
pass: 1,
agent: 'Generator',
phase: 'write',
taskId: 'code-prototype',
group: 'code',
role: 'Code',
status: 'passed',
inputPaths: [],
outputPaths: [],
summary: '代码已通过生成',
toolCalls: [],
},
{
pass: 1,
agent: 'Evaluator',
phase: 'evaluation',
status: 'passed',
inputPaths: ['.agent/passes/pass-1/draft.json', '.agent/findings.md'],
outputPaths: ['.agent/findings.md'],
summary: '质量评审通过',
toolCalls: [
{
toolId: 'agent.evaluate',
status: 'ok',
summary: 'Evaluator 质量评审',
},
],
},
],
taskGraph: {
goal: '做一个厨房弹幕游戏',
readyTaskIds: ['audio-director'],
activeTaskIds: ['design-director'],
carriedTaskIds: ['balance-director'],
repairFocus: [],
repairRoutes: [],
tasks: taskGraphTasks,
},
passPlans: [],
nextStep: 'continue',
error: null,
updatedAt: 1,
};
const runtimeTask = {
schemaVersion: 'game-creator-agent-runtime-task.v1',
agentId: 'art-asset-plan',
taskId: 'art-asset-plan',
sessionId: 'agent-session-art-asset-plan',
runId: 'runtime-art-asset-plan-1',
source: 'agent-background-task',
task: '补齐主角规范图资产列表',
status: 'pending',
phase: 'queued',
currentAction: '等待当前任务完成',
error: null,
updatedAt: 1234,
};
const runtimeTaskQueue = {
total: 1,
pending: 1,
running: 0,
completed: 0,
failed: 0,
latestRunId: 'runtime-art-asset-plan-1',
updatedAt: 1234,
};
const cards = deriveAgentStatusCards(manifest, trace, {
'art-asset-plan': {
schemaVersion: 'game-creator-agent-runtime.v1',
agentId: 'art-asset-plan',
taskId: 'art-asset-plan',
sessionId: 'agent-session-art-asset-plan',
runId: 'runtime-art-asset-plan-1',
source: 'agent-background-task',
status: 'running',
phase: 'planning',
currentTask: '补齐主角规范图资产列表',
currentAction: '拆解素材规格',
loopIteration: 2,
maxLoopIterations: 3,
toolActionBudget: 3,
plan: ['读取项目上下文'],
planSteps: [
{
index: 0,
title: '读取项目上下文',
status: 'active',
detail: '拆解素材规格',
updatedAt: 1235,
},
],
activePlanStepIndex: 0,
observations: ['已创建本轮 Agent Runtime run。'],
allowedTools: ['conversation.read'],
lastResponse: null,
error: null,
updatedAt: 1234,
taskQueue: runtimeTaskQueue,
recentTasks: [runtimeTask],
},
});
expect(cards.find((card) => card.id === 'design-director')).toMatchObject({
taskId: 'design-director',
status: 'running',
summary: '正在拆解创作方向',
pass: 1,
phase: 'plan',
lifecycleStatus: 'pending',
hasRecentEvidence: true,
taskGraphState: 'active',
inputPaths: ['memory/session.md'],
outputPaths: ['.agent/spec.md'],
toolCalls: [
expect.objectContaining({
toolId: 'llm.planner',
status: 'ok',
summary: 'Planner 已读取短期记忆',
}),
],
});
expect(cards.find((card) => card.id === 'art-asset-plan')).toMatchObject({
taskId: 'art-asset-plan',
status: 'failed',
summary: '美术资产生成失败',
hasRecentEvidence: true,
runtimeStatus: 'running',
runtimePhase: 'planning',
runtimeAction: '拆解素材规格',
runtimeTask: '补齐主角规范图资产列表',
runtimeRunId: 'runtime-art-asset-plan-1',
runtimeLoopIteration: 2,
runtimeMaxLoopIterations: 3,
runtimeToolActionBudget: 3,
runtimeActivePlanStep: '#1 active · 读取项目上下文 · 拆解素材规格',
runtimeTaskQueue,
runtimeRecentTasks: [runtimeTask],
});
expect(cards.find((card) => card.id === 'code-prototype')).toMatchObject({
taskId: 'code-prototype',
status: 'completed',
summary: '代码已通过生成',
});
expect(cards.find((card) => card.id === 'audio-director')).toMatchObject({
taskId: 'audio-director',
status: 'waiting-for-confirmation',
taskGraphState: 'ready',
hasRecentEvidence: false,
});
expect(cards.find((card) => card.id === 'balance-director')).toMatchObject({
taskId: 'balance-director',
taskGraphState: 'carried',
});
});
}
export function registerRuntimeSettingsTests() {
it('distinguishes loading the client extension list from an empty list', async () => {
let resolveExtensions!: (items: unknown[]) => void;
const extensionsRead = new Promise<unknown[]>((resolve) => {
resolveExtensions = resolve;
});
const invoke = vi.fn((command: string) => {
if (command === 'read_game_creator_app_config') {
return Promise.resolve({
path: '/home/test/AppData/game-creator.config.json',
config: {
agentMode: 'codex_app_server',
llm: {
apiKey: '',
baseUrl: 'https://llm.example.test/v1',
model: 'gpt-settings-extensions',
apiKind: 'openai_responses',
reasoningEffort: 'high',
stream: false,
webSearchEnabled: false,
requestTimeoutMs: 180000,
maxRetries: 0,
retryBackoffMs: 500,
},
editorApi: {
baseUrl: 'http://127.0.0.1:8082',
apiKey: '',
},
},
});
}
if (command === 'list_client_extensions') {
return extensionsRead;
}
throw new Error(`unexpected invoke ${command}`);
});
window.__TAURI__ = { core: { invoke } };
renderLauncherAt('/?launcher');
fireEvent.click(screen.getByRole('button', { name: '配置' }));
await screen.findByRole('dialog', { name: '运行时配置' });
fireEvent.click(screen.getByRole('button', { name: /^扩展/ }));
expect(await screen.findByText('正在加载扩展')).not.toBeNull();
expect(screen.queryByText('还没有导入扩展')).toBeNull();
await act(async () => {
resolveExtensions([]);
});
expect(await screen.findByText('还没有导入扩展')).not.toBeNull();
expect(screen.queryByText('正在加载扩展')).toBeNull();
});
it('shows the client version on the About settings page', async () => {
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-about',
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: '',
},
},
};
}
throw new Error(`unexpected invoke ${command}`);
});
window.__TAURI__ = { core: { invoke } };
renderLauncherAt('/?launcher');
fireEvent.click(screen.getByRole('button', { name: '配置' }));
expect(
await screen.findByRole('dialog', { name: '运行时配置' }),
).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: /关于/ }));
expect(screen.getByText('Genarrative AI Game Creator')).not.toBeNull();
expect(
screen.getByTestId('runtime-settings-app-logo').getAttribute('src'),
).toContain('taonier-product-ip.png');
expect(screen.getByTestId('runtime-settings-app-version').textContent).toBe(
`v${APP_VERSION}`,
);
expect(screen.getByText('桌面客户端')).not.toBeNull();
});
it('locks the Agent mode and official LLM route while dropping legacy credentials', async () => {
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_game_creator_app_config') {
return {
path: '/home/test/AppData/game-creator.config.json',
config: {
agentMode: 'codex_app_server',
llm: {
apiKey: 'preserved-provider-key',
baseUrl: 'https://llm.example.test/v1',
model: 'preserved-provider-model',
apiKind: 'openai_responses',
reasoningEffort: 'high',
stream: false,
webSearchEnabled: false,
contextWindowTokens: 128000,
autoCompactTokenLimit: 64000,
toolOutputTokenLimit: 12000,
requestTimeoutMs: 180000,
maxRetries: 2,
retryBackoffMs: 500,
},
agentLlm: {},
editorApi: {
baseUrl: 'http://127.0.0.1:8082',
apiKey: '',
},
},
};
}
if (command === 'write_game_creator_app_config') {
return {
path: '/home/test/AppData/game-creator.config.json',
config: args?.config,
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
renderLauncherAt('/?launcher');
fireEvent.click(screen.getByRole('button', { name: '配置' }));
expect(await screen.findByText('陶泥儿智能创作(固定)')).not.toBeNull();
expect(screen.queryByLabelText('Agent 模式')).toBeNull();
expect(screen.getByText('官方账号服务(固定)')).not.toBeNull();
expect(
screen.queryByText(/router\.genarrative\.world|gpt-5\.6-sol/),
).toBeNull();
expect(screen.queryByLabelText('LLM Provider')).toBeNull();
expect(screen.queryByLabelText('LLM API Key')).toBeNull();
expect(screen.queryByLabelText('LLM Base URL')).toBeNull();
expect(screen.queryByLabelText('LLM 模型')).toBeNull();
expect(screen.queryByLabelText('LLM API 类型')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '保存' }));
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith('write_game_creator_app_config', {
config: expect.objectContaining({
agentMode: 'codex_app_server',
llm: expect.objectContaining({
apiKey: '',
baseUrl: '',
model: '',
apiKind: 'openai_responses',
}),
agentLlm: {},
}),
});
});
});
it('migrates an unknown Agent mode to the fixed app-server mode on save', async () => {
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_game_creator_app_config') {
return {
path: '/home/test/AppData/game-creator.config.json',
config: {
agentMode: 'future_runtime_mode',
llm: {
apiKey: '',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4.1',
apiKind: 'openai_responses',
reasoningEffort: 'high',
stream: false,
webSearchEnabled: false,
contextWindowTokens: 128000,
autoCompactTokenLimit: 64000,
toolOutputTokenLimit: 12000,
requestTimeoutMs: 180000,
maxRetries: 2,
retryBackoffMs: 500,
},
agentLlm: {},
editorApi: {
baseUrl: 'http://127.0.0.1:8082',
apiKey: '',
},
},
};
}
if (command === 'write_game_creator_app_config') {
return {
path: '/home/test/AppData/game-creator.config.json',
config: args?.config,
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
renderLauncherAt('/?launcher');
fireEvent.click(screen.getByRole('button', { name: '配置' }));
expect(await screen.findByText('陶泥儿智能创作(固定)')).not.toBeNull();
expect(screen.queryByLabelText('Agent 模式')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '保存' }));
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith('write_game_creator_app_config', {
config: expect.objectContaining({
agentMode: 'codex_app_server',
}),
});
});
});
it('edits runtime tuning from the launcher without exposing provider fields', async () => {
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_game_creator_app_config') {
return {
path: '/home/test/AppData/game-creator.config.json',
config: {
agentMode: 'provider',
llm: {
apiKey: 'launcher-loaded-secret',
baseUrl: 'https://llm.example.test/v1',
model: 'gpt-launcher',
apiKind: 'legacy',
stream: false,
webSearchEnabled: false,
requestTimeoutMs: 10,
maxRetries: -2,
retryBackoffMs: 0,
},
editorApi: {
baseUrl: 'http://127.0.0.1:8082',
apiKey: 'editor-loaded-secret',
},
},
};
}
if (command === 'write_game_creator_app_config') {
return {
path: '/home/test/AppData/game-creator.config.json',
config: args?.config,
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
renderLauncherAt('/?launcher');
fireEvent.click(screen.getByRole('button', { name: '配置' }));
const dialog = await screen.findByRole('dialog', { name: '运行时配置' });
expect(screen.getByText('官方账号服务(固定)')).not.toBeNull();
expect(
screen.queryByText(/router\.genarrative\.world|gpt-5\.6-sol/),
).toBeNull();
expect(screen.queryByLabelText('LLM Provider')).toBeNull();
expect(screen.queryByLabelText('LLM API Key')).toBeNull();
expect(screen.queryByLabelText('LLM Base URL')).toBeNull();
expect(screen.queryByLabelText('LLM 模型')).toBeNull();
expect(screen.queryByLabelText('LLM API 类型')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: /高级参数/ }));
expect(screen.getByLabelText('请求超时 ms')).toHaveProperty(
'value',
'1000',
);
expect(screen.getByLabelText('重试次数')).toHaveProperty('value', '0');
expect(screen.getByLabelText('重试退避 ms')).toHaveProperty('value', '1');
expect(screen.getByLabelText('上下文窗口 tokens')).toHaveProperty(
'value',
'128000',
);
expect(screen.getByLabelText('自动压缩阈值 tokens')).toHaveProperty(
'value',
'64000',
);
expect(screen.getByLabelText('工具输出上限 tokens')).toHaveProperty(
'value',
'12000',
);
fireEvent.click(screen.getByRole('button', { name: '保存' }));
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith('write_game_creator_app_config', {
config: expect.objectContaining({
llm: expect.objectContaining({
apiKey: '',
baseUrl: '',
model: '',
apiKind: 'openai_responses',
requestTimeoutMs: 1000,
maxRetries: 0,
retryBackoffMs: 1,
}),
}),
});
});
expect(
(
await screen.findByText(
'已保存:/home/test/AppData/game-creator.config.json',
)
).getAttribute('data-tone'),
).toBe('success');
fireEvent.mouseDown(dialog.parentElement as HTMLElement);
expect(screen.queryByRole('dialog', { name: '运行时配置' })).toBeNull();
});
it('locks page scrolling while runtime settings are open and restores it after close', async () => {
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-scroll-lock',
apiKind: 'openai_responses',
stream: true,
webSearchEnabled: false,
requestTimeoutMs: 180000,
maxRetries: 2,
retryBackoffMs: 500,
},
},
};
}
throw new Error(`unexpected invoke ${command}`);
});
window.__TAURI__ = { core: { invoke } };
document.documentElement.style.overflow = 'auto';
document.body.style.overflow = 'scroll';
renderLauncherAt('/?launcher');
fireEvent.click(screen.getByRole('button', { name: '配置' }));
await screen.findByRole('dialog', { name: '运行时配置' });
expect(document.documentElement.style.overflow).toBe('hidden');
expect(document.body.style.overflow).toBe('hidden');
fireEvent.click(screen.getByRole('button', { name: '关闭 Agent 设置' }));
expect(document.documentElement.style.overflow).toBe('auto');
expect(document.body.style.overflow).toBe('scroll');
});
it('keeps runtime config open when Escape is pressed in an input', async () => {
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: 'provider',
llm: {
apiKey: '',
baseUrl: 'https://llm.example.test/v1',
model: 'gpt-test',
apiKind: 'openai_responses',
stream: false,
webSearchEnabled: false,
requestTimeoutMs: 180000,
maxRetries: 0,
retryBackoffMs: 500,
},
editorApi: {
baseUrl: 'http://127.0.0.1:8082',
apiKey: '',
},
},
};
}
throw new Error(`unexpected invoke ${command}`);
});
window.__TAURI__ = { core: { invoke } };
renderLauncherAt('/?launcher');
fireEvent.click(screen.getByRole('button', { name: '配置' }));
expect(
await screen.findByRole('dialog', { name: '运行时配置' }),
).not.toBeNull();
fireEvent.keyDown(screen.getByLabelText('推理档'), {
key: 'Escape',
});
expect(screen.getByRole('dialog', { name: '运行时配置' })).not.toBeNull();
fireEvent.keyDown(window, { key: 'Escape' });
expect(screen.queryByRole('dialog', { name: '运行时配置' })).toBeNull();
});
it('disables runtime config actions while reading config', async () => {
let resolveRead:
| ((value: {
path: string;
config: {
agentMode: 'provider';
llm: {
apiKey: string;
baseUrl: string;
model: string;
apiKind: string;
stream: boolean;
webSearchEnabled: boolean;
requestTimeoutMs: number;
maxRetries: number;
retryBackoffMs: number;
};
editorApi: { baseUrl: string; apiKey: string };
};
}) => void)
| undefined;
let readCount = 0;
let modelReadResolved = false;
const invoke = vi.fn((command: string) => {
if (command === 'read_game_creator_app_config') {
readCount += 1;
// 首页模型选择器会在挂载时读取一次配置;让首次读取立即完成,
// 以免一直处于 pending 干扰运行时配置对话框的读取计数。
if (!modelReadResolved) {
modelReadResolved = true;
return Promise.resolve({ config: { selectedModelId: 'quality' } });
}
return new Promise((resolve) => {
resolveRead = resolve as typeof resolveRead;
});
}
throw new Error(`unexpected invoke ${command}`);
});
window.__TAURI__ = { core: { invoke } };
renderLauncherAt('/?launcher');
fireEvent.click(screen.getByRole('button', { name: '配置' }));
expect(
await screen.findByRole('dialog', { name: '运行时配置' }),
).not.toBeNull();
expect(await screen.findByText('正在读取')).not.toBeNull();
expect(screen.getByRole('button', { name: '读取' })).toHaveProperty(
'disabled',
true,
);
expect(screen.getByRole('button', { name: '恢复默认' })).toHaveProperty(
'disabled',
true,
);
expect(screen.getByRole('button', { name: '保存' })).toHaveProperty(
'disabled',
true,
);
// 首页模型选择器挂载时会读取一次配置(上面已让首次读取立即完成),
// 这里只校验:对话框处于「正在读取」时点击读取/保存不会新增读取请求。
const readsWhileReading = readCount;
fireEvent.click(screen.getByRole('button', { name: '读取' }));
fireEvent.click(screen.getByRole('button', { name: '保存' }));
expect(readCount).toBe(readsWhileReading);
await act(async () => {
resolveRead?.({
path: '/home/test/AppData/game-creator.config.json',
config: {
agentMode: 'provider',
llm: {
apiKey: '',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4.1',
apiKind: 'openai_responses',
stream: false,
webSearchEnabled: false,
requestTimeoutMs: 180000,
maxRetries: 0,
retryBackoffMs: 500,
},
editorApi: {
baseUrl: 'http://127.0.0.1:8082',
apiKey: '',
},
},
});
});
expect(await screen.findByText(/已读取:/)).not.toBeNull();
expect(screen.getByRole('button', { name: '读取' })).toHaveProperty(
'disabled',
false,
);
});
}
export function registerPublishedRuntimeSettingsTests() {
it('edits the published runtime config without leaking API keys into chat', async () => {
let persistedConfig: Record<string, unknown> | undefined;
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_game_creator_app_config') {
return {
path: '/home/test/AppData/game-creator.config.json',
config: persistedConfig ?? {
agentMode: 'provider',
llm: {
apiKey: 'unit-loaded-secret-value',
baseUrl: 'https://llm.example.test/v1',
model: 'gpt-test',
apiKind: 'openai_responses',
reasoningEffort: 'medium',
stream: false,
webSearchEnabled: false,
requestTimeoutMs: 180000,
maxRetries: 0,
retryBackoffMs: 500,
},
agentLlm: {
planner: {
apiKey: 'planner-loaded-secret',
baseUrl: 'https://api.anthropic.com',
model: 'claude-3-5-sonnet-latest',
apiKind: 'anthropic',
reasoningEffort: 'default',
stream: false,
webSearchEnabled: false,
},
'art-asset-plan': {
apiKey: 'art-loaded-secret',
baseUrl: 'https://api.deepseek.com',
model: 'deepseek-chat',
apiKind: 'openai_chat',
stream: true,
webSearchEnabled: true,
},
},
editorApi: {
baseUrl: 'http://127.0.0.1:8082',
apiKey: 'editor-loaded-secret',
},
},
};
}
if (command === 'write_game_creator_app_config') {
persistedConfig = args?.config as Record<string, unknown>;
return {
path: '/home/test/AppData/game-creator.config.json',
config: persistedConfig,
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
renderAppAt('/?dev');
fireEvent.click(screen.getByRole('button', { name: '配置' }));
expect(await screen.findByText('官方账号服务(固定)')).not.toBeNull();
expect(
screen.queryByText(/router\.genarrative\.world|gpt-5\.6-sol/),
).toBeNull();
const runtimeConfigDialog = screen.getByRole('dialog', {
name: '运行时配置',
});
expect(
screen.getByText('/home/test/AppData/game-creator.config.json'),
).not.toBeNull();
expect(screen.getByLabelText('聊天').textContent).not.toContain(
'unit-loaded-secret-value',
);
expect(screen.getByLabelText('聊天').textContent).not.toContain(
'planner-loaded-secret',
);
expect(screen.queryByLabelText('LLM Provider')).toBeNull();
expect(screen.queryByLabelText('LLM API Key')).toBeNull();
expect(screen.queryByLabelText('LLM Base URL')).toBeNull();
expect(screen.queryByLabelText('LLM 模型')).toBeNull();
expect(screen.queryByLabelText('LLM API 类型')).toBeNull();
expect(screen.queryByRole('button', { name: /连接与工具/ })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: /Agent 分工/ }));
expect(screen.getByText('所有角色使用统一账号服务')).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: /常用设置/ }));
expect(screen.getByLabelText('联网检索')).toHaveProperty('checked', false);
fireEvent.click(screen.getByRole('button', { name: /高级参数/ }));
expect(screen.getByLabelText('上下文窗口 tokens')).toHaveProperty(
'value',
'128000',
);
expect(screen.getByLabelText('自动压缩阈值 tokens')).toHaveProperty(
'value',
'64000',
);
expect(screen.getByLabelText('工具输出上限 tokens')).toHaveProperty(
'value',
'12000',
);
fireEvent.click(screen.getByRole('button', { name: /Agent 分工/ }));
expect(screen.getByText('所有角色使用统一账号服务')).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: /常用设置/ }));
expect(screen.getByLabelText('推理档')).toHaveProperty('value', 'medium');
expect(
within(screen.getByLabelText('推理档')).getByRole('option', {
name: 'max',
}),
).not.toBeNull();
fireEvent.click(screen.getByLabelText('流式输出'));
fireEvent.click(screen.getByLabelText('联网检索'));
fireEvent.click(screen.getByRole('button', { name: /高级参数/ }));
fireEvent.change(screen.getByLabelText('上下文窗口 tokens'), {
target: { value: '160000' },
});
fireEvent.change(screen.getByLabelText('自动压缩阈值 tokens'), {
target: { value: '80000' },
});
fireEvent.change(screen.getByLabelText('工具输出上限 tokens'), {
target: { value: '10000' },
});
fireEvent.change(screen.getByLabelText('请求超时 ms'), {
target: { value: '90000' },
});
fireEvent.change(screen.getByLabelText('重试次数'), {
target: { value: '3' },
});
fireEvent.change(screen.getByLabelText('重试退避 ms'), {
target: { value: '800' },
});
fireEvent.click(screen.getByRole('button', { name: /Agent 分工/ }));
expect(screen.getByText('所有角色使用统一账号服务')).not.toBeNull();
expect(screen.queryByRole('button', { name: /连接与工具/ })).toBeNull();
fireEvent.click(
within(runtimeConfigDialog).getByRole('button', { name: '保存' }),
);
expect(await screen.findByText(/已保存:/)).not.toBeNull();
expect(
(await screen.findByLabelText('运行时配置提示')).textContent,
).toContain('保存成功');
expect(invoke).toHaveBeenCalledWith('read_game_creator_app_config');
expect(invoke).toHaveBeenCalledWith('write_game_creator_app_config', {
config: {
agentMode: 'codex_app_server',
llm: {
apiKey: '',
baseUrl: '',
model: '',
apiKind: 'openai_responses',
reasoningEffort: 'medium',
stream: true,
webSearchEnabled: true,
contextWindowTokens: 160000,
autoCompactTokenLimit: 80000,
toolOutputTokenLimit: 10000,
requestTimeoutMs: 90000,
maxRetries: 3,
retryBackoffMs: 800,
},
agentLlm: {},
editorApi: {
baseUrl: 'https://dev.genarrative.world',
apiKey: '',
},
},
});
expect(JSON.stringify(persistedConfig)).not.toContain(
'editor-loaded-secret',
);
fireEvent.click(
within(runtimeConfigDialog).getByRole('button', { name: '读取' }),
);
expect(await screen.findByText(/已读取:/)).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: /常用设置/ }));
expect(screen.getByLabelText('联网检索')).toHaveProperty('checked', true);
fireEvent.click(screen.getByRole('button', { name: /Agent 分工/ }));
expect(screen.getByText('所有角色使用统一账号服务')).not.toBeNull();
expect(screen.getByLabelText('聊天').textContent).not.toContain(
'unit-new-secret-value',
);
expect(screen.getByLabelText('聊天').textContent).not.toContain(
'generator-new-secret',
);
fireEvent.click(screen.getByRole('button', { name: /高级参数/ }));
fireEvent.change(screen.getByLabelText('请求超时 ms'), {
target: { value: '' },
});
fireEvent.change(screen.getByLabelText('重试次数'), {
target: { value: '-2' },
});
fireEvent.change(screen.getByLabelText('重试退避 ms'), {
target: { value: '0' },
});
fireEvent.click(
within(runtimeConfigDialog).getByRole('button', { name: '保存' }),
);
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith('write_game_creator_app_config', {
config: expect.objectContaining({
llm: expect.objectContaining({
requestTimeoutMs: 1000,
maxRetries: 0,
retryBackoffMs: 1,
}),
}),
});
});
fireEvent.click(
within(runtimeConfigDialog).getByRole('button', { name: '恢复默认' }),
);
expect(screen.getByText('已恢复默认配置,保存后生效')).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: /常用设置/ }));
expect(screen.getByText('陶泥儿智能创作(固定)')).not.toBeNull();
expect(screen.queryByLabelText('Agent 模式')).toBeNull();
expect(screen.getByText('官方账号服务(固定)')).not.toBeNull();
expect(
screen.queryByText(/router\.genarrative\.world|gpt-5\.6-sol/),
).toBeNull();
expect(screen.queryByLabelText('LLM API Key')).toBeNull();
expect(screen.queryByLabelText('LLM Base URL')).toBeNull();
expect(screen.queryByLabelText('LLM 模型')).toBeNull();
expect(screen.getByLabelText('推理档')).toHaveProperty('value', 'max');
expect(screen.getByLabelText('联网检索')).toHaveProperty('checked', true);
fireEvent.click(screen.getByRole('button', { name: /Agent 分工/ }));
expect(screen.getByText('所有角色使用统一账号服务')).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: /高级参数/ }));
expect(screen.getByLabelText('上下文窗口 tokens')).toHaveProperty(
'value',
'128000',
);
expect(screen.getByLabelText('自动压缩阈值 tokens')).toHaveProperty(
'value',
'64000',
);
expect(screen.getByLabelText('工具输出上限 tokens')).toHaveProperty(
'value',
'12000',
);
expect(screen.queryByRole('button', { name: /连接与工具/ })).toBeNull();
fireEvent.click(
within(runtimeConfigDialog).getByRole('button', { name: '保存' }),
);
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith('write_game_creator_app_config', {
config: expect.objectContaining({
agentMode: 'codex_app_server',
llm: expect.objectContaining({
apiKey: '',
baseUrl: '',
model: '',
apiKind: 'openai_responses',
reasoningEffort: 'max',
stream: true,
webSearchEnabled: true,
contextWindowTokens: 128000,
autoCompactTokenLimit: 64000,
toolOutputTokenLimit: 12000,
requestTimeoutMs: 180000,
maxRetries: 2,
retryBackoffMs: 500,
}),
agentLlm: {},
editorApi: {
baseUrl: 'https://dev.genarrative.world',
apiKey: '',
},
}),
});
});
}, 15000);
it('normalizes legacy provider config before saving the official route', async () => {
const invoke = vi.fn(
async (command: string, _args?: Record<string, unknown>) => {
if (command === 'read_game_creator_app_config') {
return {
path: '/home/test/AppData/game-creator.config.json',
config: {
agentMode: 'provider',
llm: {
apiKey: 'unit-secret',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4.1',
apiKind: 'openai_responses',
reasoningEffort: 'high',
stream: false,
webSearchEnabled: false,
requestTimeoutMs: 180000,
maxRetries: 0,
retryBackoffMs: 500,
},
agentLlm: {},
editorApi: {
baseUrl: 'http://127.0.0.1:8082',
apiKey: '',
},
},
};
}
if (command === 'write_game_creator_app_config') {
return {
path: '/home/test/AppData/game-creator.config.json',
config: _args?.config,
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
renderAppAt('/');
fireEvent.click(screen.getByRole('button', { name: '配置' }));
expect(await screen.findByText('官方账号服务(固定)')).not.toBeNull();
expect(screen.queryByLabelText('LLM Provider')).toBeNull();
expect(screen.queryByLabelText('LLM API Key')).toBeNull();
expect(screen.queryByLabelText('LLM Base URL')).toBeNull();
expect(screen.queryByLabelText('LLM 模型')).toBeNull();
expect(screen.queryByLabelText('LLM API 类型')).toBeNull();
fireEvent.click(screen.getByLabelText('联网检索'));
fireEvent.click(screen.getByRole('button', { name: '保存' }));
expect(await screen.findByText(/已保存:/)).not.toBeNull();
expect(screen.getByLabelText('联网检索')).toHaveProperty('checked', true);
expect(invoke).toHaveBeenCalledWith('write_game_creator_app_config', {
config: expect.objectContaining({
agentMode: 'codex_app_server',
llm: expect.objectContaining({
apiKey: '',
baseUrl: '',
model: '',
apiKind: 'openai_responses',
webSearchEnabled: true,
}),
agentLlm: {},
}),
});
});
}