Merge codex/ai-game-creator-app:融合客户端运行时配置与 LLM api_kind 链路

以 codex/ai-game-creator-app 的客户端配置文件架构(game-creator.config.json)为底,
融合当前分支的 LlmRunRequest/LlmApiKind(openai_responses/openai_chat/anthropic)维度。

手工融合 6 个冲突文件:
- src-tauri/src/main.rs:LLM 配置统一走客户端配置文件,配置字段 protocol→apiKind,
  读取/解析/校验改为 parse_game_creator_llm_api_kind,丢弃 env 读取分支,测试改用配置文件路径
- game-creator.config.json / App.tsx / appSurface.test.ts:配置 schema protocol→apiKind,
  配置面板下拉项改为三种 api_kind
- scripts/smoke-agent-run-local-provider + check-config:smoke 写入 apiKind,drift 校验同步
- 三份文档(decision-log / 技术方案 / 开发运维):改用客户端配置文件 + apiKind 口径

验证:cargo check 全目标通过;63/67 Rust 单测通过(4 个为既有 mock-server flaky 集成测试,
经核实在合并前分支上同样间歇失败,与本次合并无关);TS typecheck、vitest(45)、check-config drift 全通过。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 18:02:22 +08:00
13 changed files with 1381 additions and 434 deletions
@@ -0,0 +1,16 @@
{
"llm": {
"apiKey": "",
"baseUrl": "https://api.openai.com/v1",
"model": "gpt-4.1",
"apiKind": "openai_responses",
"stream": false,
"requestTimeoutMs": 180000,
"maxRetries": 0,
"retryBackoffMs": 500
},
"editorApi": {
"baseUrl": "http://127.0.0.1:8082",
"apiKey": ""
}
}
+2 -2
View File
@@ -7,8 +7,8 @@
"dev": "npm --prefix ../.. exec tauri -- dev",
"dev-server": "node scripts/start-dev-server.mjs",
"build": "npm --prefix ../.. exec tauri -- build",
"llm-status": "node scripts/run-cli-with-env.mjs --llm-status",
"agent-run": "node scripts/run-cli-with-env.mjs --agent-run",
"llm-status": "node scripts/run-cli-with-config.mjs --llm-status",
"agent-run": "node scripts/run-cli-with-config.mjs --agent-run",
"agent-run:smoke": "node scripts/smoke-agent-run-local-provider.mjs",
"typecheck": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json --noEmit && node scripts/check-config.mjs"
},
@@ -64,6 +64,7 @@ function assertNoOpenAiApiKeys(paths) {
assertNoOpenAiApiKeys([
new URL('../src/', import.meta.url),
new URL('../scripts/', import.meta.url),
new URL('../game-creator.config.json', import.meta.url),
new URL('../src-tauri/src/', import.meta.url),
new URL('../package.json', import.meta.url),
new URL('../vite.config.ts', import.meta.url),
@@ -97,19 +98,19 @@ if (packageConfig.name !== '@genarrative/ai-game-creator-shell') {
if (
packageConfig.scripts?.['llm-status'] !==
'node scripts/run-cli-with-env.mjs --llm-status'
'node scripts/run-cli-with-config.mjs --llm-status'
) {
throw new Error(
'AI game creator shell llm-status must load gitignored local env before checking LLM config',
'AI game creator shell llm-status must use client config before checking LLM config',
);
}
if (
packageConfig.scripts?.['agent-run'] !==
'node scripts/run-cli-with-env.mjs --agent-run'
'node scripts/run-cli-with-config.mjs --agent-run'
) {
throw new Error(
'AI game creator shell agent-run must load gitignored local env before running the provider path',
'AI game creator shell agent-run must use client config before running the provider path',
);
}
@@ -190,8 +191,8 @@ const devServerSource = fs.readFileSync(
new URL('../scripts/start-dev-server.mjs', import.meta.url),
'utf8',
);
const runCliWithEnvSource = fs.readFileSync(
new URL('../scripts/run-cli-with-env.mjs', import.meta.url),
const runCliWithConfigSource = fs.readFileSync(
new URL('../scripts/run-cli-with-config.mjs', import.meta.url),
'utf8',
);
@@ -211,15 +212,13 @@ for (const snippet of [
}
for (const snippet of [
"path.join(repoRoot, '.env.secrets.local')",
"path.join(appRoot, '.env.secrets.local')",
'dotenv.config({ path: envPath, override: false })',
"new URL('..', import.meta.url)",
"'--manifest-path'",
"'src-tauri/Cargo.toml'",
]) {
if (!runCliWithEnvSource.includes(snippet)) {
if (!runCliWithConfigSource.includes(snippet)) {
throw new Error(
`AI game creator shell local env CLI wrapper drifted: ${snippet}`,
`AI game creator shell config CLI wrapper drifted: ${snippet}`,
);
}
}
@@ -230,19 +229,32 @@ const tauriMainSource = fs.readFileSync(
);
for (const snippet of [
'fn load_game_creator_local_env()',
'fn load_game_creator_env_file(path: &Path)',
'directory.join(".env.secrets.local")',
'const GAME_CREATOR_CONFIG_FILE_NAME: &str = "game-creator.config.json"',
'const GAME_CREATOR_LOCAL_CONFIG_FILE_NAME: &str = "game-creator.config.local.json"',
'const DEFAULT_GAME_CREATOR_APP_CONFIG_JSON: &str = include_str!("../../game-creator.config.json")',
'fn configure_game_creator_runtime_config_dir(',
'app.path().app_config_dir()?',
'fn load_game_creator_app_config()',
'fn read_game_creator_app_config()',
'fn write_game_creator_app_config(',
'fn writable_game_creator_config_path()',
'fn normalize_game_creator_app_config(',
'fn merge_game_creator_config_file(',
'.join("apps")',
'.join("ai-game-creator-shell")',
'load_game_creator_local_env()?;',
'let local_env_error = load_game_creator_local_env().err();',
'local.env.load.failed',
'configure_game_creator_runtime_config_dir(app.handle())?',
'read_game_creator_app_config,',
'write_game_creator_app_config,',
'build_game_creator_llm_client_from_config()?',
'let app_config = match load_game_creator_app_config()',
'#[cfg(debug_assertions)]\nfn developer_window_url()',
'tauri::WebviewUrl::App(PathBuf::from("index.html?dev"))',
'#[cfg(debug_assertions)]\nfn open_developer_window(app: &tauri::App)',
'tauri::WebviewWindowBuilder::new(app, "developer", developer_window_url())',
'open_developer_window(app)?;',
'fn append_local_permission_log_at(',
'"command.auto"',
'GameCreationAppPermission::Auto',
]) {
if (!tauriMainSource.includes(snippet)) {
throw new Error(
@@ -257,6 +269,12 @@ const appSource = fs.readFileSync(
);
for (const snippet of [
"'game.run_local'",
"'read_game_creator_app_config'",
"'write_game_creator_app_config'",
'aria-label="运行时配置"',
'LLM API Key',
'画板 API Key',
'runtime_config.save',
"'/run:运行自检,启动本地 HTTP 预览并交给外部浏览器'",
'async function openPreviewInExternalBrowser',
"'open_local_game_preview'",
@@ -275,6 +293,8 @@ for (const snippet of [
"'permission.pending'",
"'permission.confirm'",
"'permission.cancel'",
"'command.auto'",
"'agent.run_status'",
'function summarizeAgentRunTrace',
'工具调用:${agentRunTrace.toolCallCount}/${agentRunTrace.maxToolCalls}',
'agentRunTrace.error ?',
@@ -336,8 +356,10 @@ for (const snippet of [
'function writeStreamingChatCompletion',
'requestJson?.stream === true',
`requestBodies.every((body) => body.includes('"stream":true'))`,
"GENARRATIVE_GAME_CREATOR_LLM_API_KIND: 'openai_chat'",
"GENARRATIVE_GAME_CREATOR_LLM_STREAM: 'true'",
"const localConfigPath = path.join(appRoot, 'game-creator.config.local.json')",
"apiKind: 'openai_chat'",
'stream: true',
'await restoreOptionalFile(localConfigPath, previousLocalConfig)',
"method: 'HEAD'",
'previewAssetHead.contentLength === String(smokeAssetBytes.length)',
"previewAudioHead.contentType === 'audio/mpeg'",
@@ -1,21 +1,8 @@
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import dotenv from 'dotenv';
const appRoot = path.resolve(fileURLToPath(new URL('..', import.meta.url)));
const repoRoot = path.resolve(appRoot, '../..');
for (const envPath of [
path.join(repoRoot, '.env.secrets.local'),
path.join(appRoot, '.env.secrets.local'),
]) {
if (fs.existsSync(envPath)) {
dotenv.config({ path: envPath, override: false });
}
}
const cargo = process.platform === 'win32' ? 'cargo.exe' : 'cargo';
const child = spawn(
@@ -30,7 +17,6 @@ const child = spawn(
{
cwd: appRoot,
stdio: 'inherit',
env: process.env,
},
);
@@ -6,6 +6,7 @@ import os from 'node:os';
import path from 'node:path';
const appRoot = path.resolve(new URL('..', import.meta.url).pathname);
const localConfigPath = path.join(appRoot, 'game-creator.config.local.json');
const projectRoot = path.join(
os.tmpdir(),
`genarrative-ai-game-creator-smoke-${Date.now()}`,
@@ -254,9 +255,11 @@ function writeStreamingChatCompletion(response, content) {
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const address = server.address();
const baseUrl = `http://127.0.0.1:${address.port}`;
const previousLocalConfig = await readOptionalFile(localConfigPath);
try {
await seedLocalAsset();
await writeSmokeLocalConfig(baseUrl);
const {
output,
previewHtml,
@@ -266,7 +269,7 @@ try {
previewAudio,
previewAudioHead,
previewDom,
} = await runAgent(baseUrl);
} = await runAgent();
const tracePath = path.join(projectRoot, '.agent/run.latest.json');
const trace = JSON.parse(await fs.readFile(tracePath, 'utf8'));
const pass2TaskGraph = JSON.parse(
@@ -542,10 +545,49 @@ try {
console.log(`projectPath=${projectRoot}`);
console.log(`tracePath=${tracePath}`);
} finally {
await restoreOptionalFile(localConfigPath, previousLocalConfig);
server.close();
}
function runAgent(baseUrl) {
async function readOptionalFile(filePath) {
try {
return await fs.readFile(filePath);
} catch (error) {
if (error?.code === 'ENOENT') {
return null;
}
throw error;
}
}
async function restoreOptionalFile(filePath, previous) {
if (previous === null) {
await fs.rm(filePath, { force: true });
return;
}
await fs.writeFile(filePath, previous);
}
async function writeSmokeLocalConfig(baseUrl) {
await fs.writeFile(
localConfigPath,
`${JSON.stringify(
{
llm: {
apiKey: 'local-provider-key',
baseUrl,
model: 'local-game-creator-smoke',
apiKind: 'openai_chat',
stream: true,
},
},
null,
2,
)}\n`,
);
}
function runAgent() {
return new Promise((resolve, reject) => {
let previewReadStarted = false;
let previewUrl = '';
@@ -568,14 +610,6 @@ function runAgent(baseUrl) {
],
{
cwd: appRoot,
env: {
...process.env,
GENARRATIVE_GAME_CREATOR_LLM_API_KEY: 'local-provider-key',
GENARRATIVE_GAME_CREATOR_LLM_BASE_URL: baseUrl,
GENARRATIVE_GAME_CREATOR_LLM_MODEL: 'local-game-creator-smoke',
GENARRATIVE_GAME_CREATOR_LLM_API_KIND: 'openai_chat',
GENARRATIVE_GAME_CREATOR_LLM_STREAM: 'true',
},
stdio: ['pipe', 'pipe', 'pipe'],
},
);
@@ -757,9 +791,6 @@ function readBrowserDom(url) {
}
function resolveChromeBin() {
if (process.env.GENARRATIVE_GAME_CREATOR_CHROME_BIN) {
return process.env.GENARRATIVE_GAME_CREATOR_CHROME_BIN;
}
for (const candidate of [
'/usr/bin/google-chrome',
'/usr/bin/google-chrome-stable',
File diff suppressed because it is too large Load Diff
+318 -5
View File
@@ -10,6 +10,7 @@ import {
GAME_CREATION_APP_COMMANDS,
GAME_CREATION_APP_LIMITED_RUN_COMMANDS,
type GameCreationAppAgentGroup,
type GameCreationAppCommandDescriptor,
type GameCreationAgentRunTrace,
type GameCreationAppManifest,
type GameCreationAppPermission,
@@ -63,6 +64,30 @@ interface GameCreatorLlmConfigStatus {
error: string | null;
}
type GameCreatorLlmApiKind = 'openai_responses' | 'openai_chat' | 'anthropic';
interface GameCreatorAppConfig {
llm: {
apiKey: string;
baseUrl: string;
model: string;
apiKind: GameCreatorLlmApiKind;
stream: boolean;
requestTimeoutMs: number;
maxRetries: number;
retryBackoffMs: number;
};
editorApi: {
baseUrl: string;
apiKey: string;
};
}
interface GameCreatorAppConfigView {
path: string;
config: GameCreatorAppConfig;
}
interface UploadLocalAssetResult {
id: string;
localPath: string;
@@ -296,6 +321,23 @@ const chatCommandHelp = [
'/import-canvas-export /绝对/导出.zip 画板项目ID:导入画板素材导出包',
];
const defaultRuntimeConfigDraft: GameCreatorAppConfig = {
llm: {
apiKey: '',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4.1',
apiKind: 'openai_responses',
stream: false,
requestTimeoutMs: 180000,
maxRetries: 0,
retryBackoffMs: 500,
},
editorApi: {
baseUrl: 'http://127.0.0.1:8082',
apiKey: '',
},
};
function taskRowsFromManifest(
manifest: GameCreationAppManifest,
): GameCreationAppTaskState[] {
@@ -1053,6 +1095,11 @@ export function App() {
LocalProjectFileEntry[]
>([]);
const [agentRunStatus, setAgentRunStatus] = useState('未运行');
const [runtimeConfigOpen, setRuntimeConfigOpen] = useState(false);
const [runtimeConfigPath, setRuntimeConfigPath] = useState('');
const [runtimeConfigStatus, setRuntimeConfigStatus] = useState('未读取');
const [runtimeConfigDraft, setRuntimeConfigDraft] =
useState<GameCreatorAppConfig>(defaultRuntimeConfigDraft);
const [messages, setMessages] = useState<ChatMessage[]>([
{
role: 'assistant',
@@ -1099,8 +1146,12 @@ export function App() {
function appendLocalPermissionLog(
projectPath: string | null,
event: 'permission.pending' | 'permission.confirm' | 'permission.cancel',
commandId: PendingCommand['id'],
event:
| 'permission.pending'
| 'permission.confirm'
| 'permission.cancel'
| 'command.auto',
commandId: GameCreationAppCommandDescriptor['id'],
) {
const invoke = resolveTauriInvoke();
if (!invoke || !projectPath) {
@@ -1160,6 +1211,80 @@ export function App() {
return true;
}
function updateRuntimeLlmConfig<K extends keyof GameCreatorAppConfig['llm']>(
key: K,
value: GameCreatorAppConfig['llm'][K],
) {
setRuntimeConfigDraft((current) => ({
...current,
llm: {
...current.llm,
[key]: value,
},
}));
}
function updateRuntimeEditorConfig<
K extends keyof GameCreatorAppConfig['editorApi'],
>(key: K, value: GameCreatorAppConfig['editorApi'][K]) {
setRuntimeConfigDraft((current) => ({
...current,
editorApi: {
...current.editorApi,
[key]: value,
},
}));
}
function handleRuntimeConfigOpen() {
setRuntimeConfigOpen(true);
void readRuntimeConfig();
}
async function readRuntimeConfig() {
const invoke = resolveTauriInvoke();
if (!invoke) {
setRuntimeConfigStatus('需要在 Tauri App 内运行');
return;
}
setRuntimeConfigStatus('正在读取');
try {
const result = await invoke<GameCreatorAppConfigView>(
'read_game_creator_app_config',
);
setRuntimeConfigPath(result.path);
setRuntimeConfigDraft(result.config);
setRuntimeConfigStatus(`已读取:${result.path}`);
setCommandLog((current) => [...current, 'runtime_config.read']);
} catch (error) {
setRuntimeConfigStatus(error instanceof Error ? error.message : String(error));
}
}
async function handleRuntimeConfigSave(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const invoke = resolveTauriInvoke();
if (!invoke) {
setRuntimeConfigStatus('需要在 Tauri App 内运行');
return;
}
setRuntimeConfigStatus('正在保存');
try {
const result = await invoke<GameCreatorAppConfigView>(
'write_game_creator_app_config',
{ config: runtimeConfigDraft },
);
setRuntimeConfigPath(result.path);
setRuntimeConfigDraft(result.config);
setRuntimeConfigStatus(`已保存:${result.path}`);
setCommandLog((current) => [...current, 'runtime_config.save']);
} catch (error) {
setRuntimeConfigStatus(error instanceof Error ? error.message : String(error));
}
}
function requireChatProjectForUserAction() {
const nextProjectPath = resolveChatProjectPath(localProject);
if (nextProjectPath) {
@@ -2211,13 +2336,24 @@ export function App() {
setAgentRunStatus(
`${result.status} · ${result.lifecycleStatus} · ${result.nextStep}`,
);
const commandId =
action === 'status'
? 'agent.run_status'
: (`agent.${action}` as GameCreationAppCommandDescriptor['id']);
setCommandLog((current) => [
...current,
`agent.${action}`,
commandId,
'file.write .agent/activity.jsonl',
'file.write .agent/output.jsonl',
'file.write .agent/context.bundle.json',
]);
if (announceToChat && action === 'status') {
appendLocalPermissionLog(
nextProjectPath,
'command.auto',
'agent.run_status',
);
}
await refreshAgentRunTrace(nextProjectPath);
if (announceToChat) {
setMessages((current) => [
@@ -2501,6 +2637,13 @@ export function App() {
setPreviewStatus('未启动');
}
setCommandLog((current) => [...current, 'preview.status']);
if (announceToChat && nextProjectPath) {
appendLocalPermissionLog(
nextProjectPath,
'command.auto',
'preview.status',
);
}
if (announceToChat) {
setMessages((current) => [
...current,
@@ -3497,8 +3640,13 @@ export function App() {
>
<section className="chat-pane" aria-label="聊天">
<header className="chat-header">
<h1>AI </h1>
<span>{devMode ? '开发模式' : seedManifest.name}</span>
<div>
<h1>AI </h1>
<span>{devMode ? '开发模式' : seedManifest.name}</span>
</div>
<button type="button" onClick={handleRuntimeConfigOpen}>
</button>
</header>
<div className="message-list">
{messages.map((message, index) => (
@@ -3545,6 +3693,171 @@ export function App() {
</form>
</section>
{runtimeConfigOpen ? (
<div className="settings-overlay" role="presentation">
<form
className="settings-panel"
role="dialog"
aria-label="运行时配置"
aria-modal="true"
onSubmit={handleRuntimeConfigSave}
>
<header className="panel-header">
<h2></h2>
<div className="panel-actions">
<button type="button" onClick={readRuntimeConfig}>
</button>
<button
type="button"
onClick={() => setRuntimeConfigOpen(false)}
>
</button>
<button type="submit"></button>
</div>
</header>
{runtimeConfigPath ? (
<p className="manifest-path">{runtimeConfigPath}</p>
) : null}
<div className="settings-grid">
<label>
LLM API Key
<input
aria-label="LLM API Key"
type="password"
value={runtimeConfigDraft.llm.apiKey}
onChange={(event) =>
updateRuntimeLlmConfig('apiKey', event.currentTarget.value)
}
/>
</label>
<label>
LLM Base URL
<input
aria-label="LLM Base URL"
value={runtimeConfigDraft.llm.baseUrl}
onChange={(event) =>
updateRuntimeLlmConfig('baseUrl', event.currentTarget.value)
}
/>
</label>
<label>
LLM
<input
aria-label="LLM 模型"
value={runtimeConfigDraft.llm.model}
onChange={(event) =>
updateRuntimeLlmConfig('model', event.currentTarget.value)
}
/>
</label>
<label>
LLM API
<select
aria-label="LLM API 类型"
value={runtimeConfigDraft.llm.apiKind}
onChange={(event) =>
updateRuntimeLlmConfig(
'apiKind',
event.currentTarget.value as GameCreatorLlmApiKind,
)
}
>
<option value="openai_responses">openai_responses</option>
<option value="openai_chat">openai_chat</option>
<option value="anthropic">anthropic</option>
</select>
</label>
<label className="settings-checkbox">
<input
aria-label="LLM 流式请求"
type="checkbox"
checked={runtimeConfigDraft.llm.stream}
onChange={(event) =>
updateRuntimeLlmConfig('stream', event.currentTarget.checked)
}
/>
LLM
</label>
<label>
LLM ms
<input
aria-label="LLM 超时 ms"
type="number"
min="1"
value={runtimeConfigDraft.llm.requestTimeoutMs}
onChange={(event) =>
updateRuntimeLlmConfig(
'requestTimeoutMs',
Number(event.currentTarget.value),
)
}
/>
</label>
<label>
LLM
<input
aria-label="LLM 重试次数"
type="number"
min="0"
value={runtimeConfigDraft.llm.maxRetries}
onChange={(event) =>
updateRuntimeLlmConfig(
'maxRetries',
Number(event.currentTarget.value),
)
}
/>
</label>
<label>
LLM 退 ms
<input
aria-label="LLM 退避 ms"
type="number"
min="1"
value={runtimeConfigDraft.llm.retryBackoffMs}
onChange={(event) =>
updateRuntimeLlmConfig(
'retryBackoffMs',
Number(event.currentTarget.value),
)
}
/>
</label>
<label>
API Base URL
<input
aria-label="画板 API Base URL"
value={runtimeConfigDraft.editorApi.baseUrl}
onChange={(event) =>
updateRuntimeEditorConfig(
'baseUrl',
event.currentTarget.value,
)
}
/>
</label>
<label>
API Key
<input
aria-label="画板 API Key"
type="password"
value={runtimeConfigDraft.editorApi.apiKey}
onChange={(event) =>
updateRuntimeEditorConfig(
'apiKey',
event.currentTarget.value,
)
}
/>
</label>
</div>
<p className="status-line">{runtimeConfigStatus}</p>
</form>
</div>
) : null}
{devMode ? (
<section className="developer-pane" aria-label="开发环境">
<section className="developer-panel task-pane" aria-label="任务">
+70 -1
View File
@@ -59,11 +59,16 @@ textarea {
.chat-header,
.panel-header {
display: flex;
align-items: baseline;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.chat-header div {
display: grid;
gap: 4px;
}
.panel-header {
margin-bottom: 10px;
}
@@ -75,6 +80,7 @@ textarea {
}
.panel-header button,
.chat-header button,
.local-project-form button,
.panel-actions select {
height: 32px;
@@ -84,6 +90,7 @@ textarea {
}
.panel-header button,
.chat-header button,
.local-project-form button {
color: #fff;
background: #1f6feb;
@@ -114,6 +121,64 @@ h2 {
color: #647084;
}
.settings-overlay {
position: fixed;
inset: 0;
z-index: 10;
display: grid;
place-items: center;
padding: 18px;
background: rgb(24 32 47 / 36%);
}
.settings-panel {
display: grid;
gap: 10px;
width: min(760px, 100%);
max-height: calc(100vh - 36px);
padding: 18px;
overflow: auto;
border: 1px solid #cfd7e6;
background: #fff;
}
.settings-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.settings-grid label {
display: grid;
gap: 6px;
min-width: 0;
color: #647084;
font-size: 13px;
}
.settings-grid input,
.settings-grid select {
min-width: 0;
height: 36px;
padding: 0 10px;
border: 1px solid #cfd7e6;
border-radius: 6px;
color: #18202f;
background: #fff;
}
.settings-checkbox {
grid-template-columns: auto 1fr;
align-items: center;
align-self: end;
}
.settings-checkbox input {
width: 18px;
height: 18px;
padding: 0;
}
.message-list {
flex: 1;
border: 1px solid #dde3ee;
@@ -453,6 +518,10 @@ iframe.preview-frame {
grid-column: auto;
}
.settings-grid {
grid-template-columns: 1fr;
}
.composer {
grid-template-columns: 1fr auto;
}
@@ -33,18 +33,121 @@ afterEach(() => {
});
describe('AI 游戏创作 App 界面边界', () => {
it('keeps the user surface to chat, upload and command confirmation', () => {
it('keeps the user surface to chat, upload, config and command confirmation', () => {
renderAppAt('/');
expect(screen.getByLabelText('聊天')).not.toBeNull();
expect(screen.getByLabelText('创作想法')).not.toBeNull();
expect(screen.getByText('上传')).not.toBeNull();
expect(screen.getByRole('button', { name: '配置' })).not.toBeNull();
expect(screen.getByText('想做什么游戏?')).not.toBeNull();
expect(screen.queryByLabelText('开发环境')).toBeNull();
expect(screen.queryByLabelText('运行时配置')).toBeNull();
expect(screen.queryByText('Agent 能力')).toBeNull();
expect(screen.queryByText('编排 Trace')).toBeNull();
});
it('edits the published runtime config without leaking API keys into chat', 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: {
llm: {
apiKey: 'unit-loaded-secret-value',
baseUrl: 'https://llm.example.test/v1',
model: 'gpt-test',
apiKind: 'openai_responses',
stream: false,
requestTimeoutMs: 180000,
maxRetries: 0,
retryBackoffMs: 500,
},
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 } };
renderAppAt('/');
fireEvent.click(screen.getByRole('button', { name: '配置' }));
expect(await screen.findByDisplayValue('gpt-test')).not.toBeNull();
expect(
screen.getByText('/home/test/AppData/game-creator.config.json'),
).not.toBeNull();
expect(screen.getByLabelText('聊天').textContent).not.toContain(
'unit-loaded-secret-value',
);
fireEvent.change(screen.getByLabelText('LLM API Key'), {
target: { value: 'unit-new-secret-value' },
});
fireEvent.change(screen.getByLabelText('LLM Base URL'), {
target: { value: 'https://new-llm.example.test/v1' },
});
fireEvent.change(screen.getByLabelText('LLM 模型'), {
target: { value: 'gpt-next' },
});
fireEvent.change(screen.getByLabelText('LLM API 类型'), {
target: { value: 'openai_chat' },
});
fireEvent.click(screen.getByLabelText('LLM 流式请求'));
fireEvent.change(screen.getByLabelText('LLM 超时 ms'), {
target: { value: '90000' },
});
fireEvent.change(screen.getByLabelText('LLM 重试次数'), {
target: { value: '3' },
});
fireEvent.change(screen.getByLabelText('LLM 退避 ms'), {
target: { value: '800' },
});
fireEvent.change(screen.getByLabelText('画板 API Base URL'), {
target: { value: 'http://127.0.0.1:8099' },
});
fireEvent.change(screen.getByLabelText('画板 API Key'), {
target: { value: 'editor-new-secret' },
});
fireEvent.click(screen.getByRole('button', { name: '保存' }));
expect(await screen.findByText(/已保存:/)).not.toBeNull();
expect(invoke).toHaveBeenCalledWith('read_game_creator_app_config');
expect(invoke).toHaveBeenCalledWith('write_game_creator_app_config', {
config: {
llm: {
apiKey: 'unit-new-secret-value',
baseUrl: 'https://new-llm.example.test/v1',
model: 'gpt-next',
apiKind: 'openai_chat',
stream: true,
requestTimeoutMs: 90000,
maxRetries: 3,
retryBackoffMs: 800,
},
editorApi: {
baseUrl: 'http://127.0.0.1:8099',
apiKey: 'editor-new-secret',
},
},
});
expect(screen.getByLabelText('聊天').textContent).not.toContain(
'unit-new-secret-value',
);
});
it('keeps multiline chat evidence readable', () => {
const styles = readFileSync(
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
@@ -1092,6 +1195,11 @@ describe('AI 游戏创作 App 界面边界', () => {
expect(invoke).toHaveBeenCalledWith('get_local_game_preview_status', {
projectPath: '/tmp/authorized-game',
});
expect(invoke).toHaveBeenCalledWith('append_local_permission_log', {
projectPath: '/tmp/authorized-game',
event: 'command.auto',
commandId: 'preview.status',
});
});
it('runs static smoke and starts preview from chat through the authorized project path', async () => {
@@ -2193,16 +2301,40 @@ describe('AI 游戏创作 App 界面边界', () => {
};
}
if (command === 'control_agent_run') {
const action = String(args?.action ?? '');
const detail = String(args?.detail ?? '');
const resultByAction = {
status: {
status: 'pending',
lifecycleStatus: 'pending',
nextStep: 'runner-claim',
message: 'run run-control-chat 当前状态:pending / pending',
},
kill: {
status: 'killed',
lifecycleStatus: 'killed',
nextStep: 'resume-or-retry',
message: 'run run-control-chat 已标记为 killed',
},
retry: {
status: 'pending',
lifecycleStatus: 'pending',
nextStep: 'runner-claim',
message: 'run run-control-chat 已重试,等待下一次 claim',
},
resume: {
status: 'pending',
lifecycleStatus: 'pending',
nextStep: 'runner-claim',
message: `run run-control-chat 已恢复:${detail}`,
},
}[action];
if (!resultByAction) {
throw new Error(`unexpected agent run action ${action}`);
}
return {
runId: 'run-control-chat',
status: args?.action === 'kill' ? 'killed' : 'pending',
lifecycleStatus: args?.action === 'kill' ? 'killed' : 'pending',
nextStep:
args?.action === 'kill' ? 'resume-or-retry' : 'runner-claim',
message:
args?.action === 'kill'
? 'run run-control-chat 已标记为 killed'
: 'run run-control-chat 当前状态:pending / pending',
...resultByAction,
activityPath: '/tmp/authorized-game/.agent/activity.jsonl',
outputPath: '/tmp/authorized-game/.agent/output.jsonl',
contextBundlePath: '/tmp/authorized-game/.agent/context.bundle.json',
@@ -2236,6 +2368,11 @@ describe('AI 游戏创作 App 界面边界', () => {
action: 'status',
detail: undefined,
});
expect(invoke).toHaveBeenCalledWith('append_local_permission_log', {
projectPath: '/tmp/authorized-game',
event: 'command.auto',
commandId: 'agent.run_status',
});
submitChat('/agent-kill');
expect(screen.getByText('agent.kill')).not.toBeNull();
@@ -2253,6 +2390,40 @@ describe('AI 游戏创作 App 界面边界', () => {
action: 'kill',
detail: undefined,
});
submitChat('/agent-retry');
expect(screen.getByText('agent.retry')).not.toBeNull();
expect(
screen.getByText(
'标记 /tmp/authorized-game/.agent/run.latest.json 为 pending,等待 runner claim',
),
).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: '确认' }));
expect(
await screen.findByText(/run run-control-chat 已重试,等待下一次 claim/),
).not.toBeNull();
expect(invoke).toHaveBeenCalledWith('control_agent_run', {
projectPath: '/tmp/authorized-game',
action: 'retry',
detail: undefined,
});
submitChat('/agent-resume 继续修复输入监听');
expect(screen.getByText('agent.resume')).not.toBeNull();
expect(
screen.getByText(
'附加用户说明并标记 /tmp/authorized-game/.agent/run.latest.json 为 pending',
),
).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: '确认' }));
expect(
await screen.findByText(/run run-control-chat 已恢复:继续修复输入监听/),
).not.toBeNull();
expect(invoke).toHaveBeenCalledWith('control_agent_run', {
projectPath: '/tmp/authorized-game',
action: 'resume',
detail: '继续修复输入监听',
});
});
it('manages long memory from chat through the authorized local project path', async () => {