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:
@@ -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": ""
|
||||
}
|
||||
}
|
||||
@@ -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'",
|
||||
|
||||
-14
@@ -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
@@ -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="任务">
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user