72a6d635da
新增独立 Tauri 壳 apps/ai-game-creator-shell,只保留聊天入口,正式用户窗口不承载游戏预览画面,开发模式通过独立窗口展示任务、文件、记忆、预览和日志 新增游戏创作专业组与种子任务图契约,覆盖策划/美术/程序/数值/音乐/运营 6 组下 15 个组内角色 新增共享契约本地项目 manifest、内置命令权限枚举、run trace schema 和 ready-task 选择器 扩展平台 LLM 支持流式请求,增加 GENARRATIVE_GAME_CREATOR_LLM_STREAM 开关 新增 AI 游戏创作 App 的聊天命令集:/help /capabilities /audit /project /llm-status /status /files /assets /read /tasks /trace /smoke /run /preview /preview-status /preview-open /preview-stop /memory /remember /forget-memory /canvas /sync-canvas-project /import-canvas-asset /import-canvas-export 新增本地项目初始化、权限 gate(pending/confirm/cancel)、文件上传、受限命令白名单 game.static_smoke 和本地 HTTP 预览 新增 Planner / 6 组角色 agent / Generator / Evaluator / ArtifactWriter / Playtest 文件驱动 loop,最多 3 轮返工,结构化 repairRoutes 新增实时 run trace 写入 .agent/run.latest.json 与 .agent/runs/,包含 taskGraph、passPlans 和步级 toolCalls 新增短期/长期记忆 memory/session.md 和 memory/project.md,支持聊天读取/追加/覆盖/删除 新增画板对接:canvas.project_open、canvas.project_sync、canvas.asset_import、canvas.export_import 新增 npm run ai-game-creator-shell:check 开发验收入口,覆盖 typecheck、单元测试、端到端 smoke 新增 check:native-shells 静态守门:release 只登记 main 聊天窗口,CSP 禁止内嵌预览,开发窗口只在 debug 下打开,用户侧预览命令必须调用 open_local_game_preview
352 lines
10 KiB
JavaScript
352 lines
10 KiB
JavaScript
import fs from 'node:fs';
|
|
|
|
const packageConfig = JSON.parse(
|
|
fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
|
|
);
|
|
const tauriConfig = JSON.parse(
|
|
fs.readFileSync(
|
|
new URL('../src-tauri/tauri.conf.json', import.meta.url),
|
|
'utf8',
|
|
),
|
|
);
|
|
const rootPackageConfig = JSON.parse(
|
|
fs.readFileSync(new URL('../../../package.json', import.meta.url), 'utf8'),
|
|
);
|
|
const viteConfigSource = fs.readFileSync(
|
|
new URL('../vite.config.ts', import.meta.url),
|
|
'utf8',
|
|
);
|
|
const sourceExtensions = new Set([
|
|
'.json',
|
|
'.md',
|
|
'.mjs',
|
|
'.rs',
|
|
'.toml',
|
|
'.ts',
|
|
'.tsx',
|
|
]);
|
|
|
|
function collectFiles(path) {
|
|
const stat = fs.statSync(path);
|
|
if (stat.isDirectory()) {
|
|
return fs.readdirSync(path, { withFileTypes: true }).flatMap((entry) => {
|
|
if (entry.name === 'node_modules' || entry.name === 'target') {
|
|
return [];
|
|
}
|
|
return collectFiles(
|
|
new URL(`${entry.name}${entry.isDirectory() ? '/' : ''}`, path),
|
|
);
|
|
});
|
|
}
|
|
if (sourceExtensions.has(pathnameExtension(path.pathname))) {
|
|
return [path];
|
|
}
|
|
return [];
|
|
}
|
|
|
|
function pathnameExtension(pathname) {
|
|
const index = pathname.lastIndexOf('.');
|
|
return index === -1 ? '' : pathname.slice(index);
|
|
}
|
|
|
|
function assertNoOpenAiApiKeys(paths) {
|
|
const secretPattern = /sk-[A-Za-z0-9_-]{20,}/;
|
|
for (const path of paths.flatMap((entry) => collectFiles(entry))) {
|
|
const source = fs.readFileSync(path, 'utf8');
|
|
if (secretPattern.test(source)) {
|
|
throw new Error(
|
|
`AI game creator shell source must not contain API keys: ${path.pathname}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
assertNoOpenAiApiKeys([
|
|
new URL('../src/', import.meta.url),
|
|
new URL('../scripts/', 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),
|
|
new URL('../src-tauri/Cargo.toml', import.meta.url),
|
|
new URL('../src-tauri/tauri.conf.json', import.meta.url),
|
|
new URL(
|
|
'../../../packages/shared/src/contracts/gameCreationApp.ts',
|
|
import.meta.url,
|
|
),
|
|
new URL(
|
|
'../../../packages/shared/src/contracts/gameCreationApp.test.ts',
|
|
import.meta.url,
|
|
),
|
|
new URL(
|
|
'../../../server-rs/crates/platform-agent/src/game_creation.rs',
|
|
import.meta.url,
|
|
),
|
|
new URL(
|
|
'../../../server-rs/crates/shared-contracts/src/game_creation_app.rs',
|
|
import.meta.url,
|
|
),
|
|
new URL(
|
|
'../../../docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md',
|
|
import.meta.url,
|
|
),
|
|
]);
|
|
|
|
if (packageConfig.name !== '@genarrative/ai-game-creator-shell') {
|
|
throw new Error('AI game creator shell package name drifted');
|
|
}
|
|
|
|
if (
|
|
packageConfig.scripts?.['llm-status'] !==
|
|
'node scripts/run-cli-with-env.mjs --llm-status'
|
|
) {
|
|
throw new Error(
|
|
'AI game creator shell llm-status must load gitignored local env before checking LLM config',
|
|
);
|
|
}
|
|
|
|
if (
|
|
packageConfig.scripts?.['agent-run'] !==
|
|
'node scripts/run-cli-with-env.mjs --agent-run'
|
|
) {
|
|
throw new Error(
|
|
'AI game creator shell agent-run must load gitignored local env before running the provider path',
|
|
);
|
|
}
|
|
|
|
if (tauriConfig.productName !== 'Genarrative AI Game Creator') {
|
|
throw new Error('AI game creator shell productName drifted');
|
|
}
|
|
|
|
if (tauriConfig.identifier !== 'world.genarrative.ai-game-creator') {
|
|
throw new Error('AI game creator shell identifier drifted');
|
|
}
|
|
|
|
if (tauriConfig.app?.withGlobalTauri !== true) {
|
|
throw new Error(
|
|
'AI game creator shell must expose window.__TAURI__ for local commands',
|
|
);
|
|
}
|
|
|
|
const windows = tauriConfig.app?.windows ?? [];
|
|
if (windows.length !== 1 || windows[0]?.label !== 'main') {
|
|
throw new Error(
|
|
'AI game creator shell release config must expose only the chat main window',
|
|
);
|
|
}
|
|
|
|
const mainWindow = windows[0];
|
|
if (
|
|
mainWindow.width !== 760 ||
|
|
mainWindow.height !== 820 ||
|
|
mainWindow.minWidth !== 420 ||
|
|
mainWindow.minHeight !== 560
|
|
) {
|
|
throw new Error('AI game creator shell main window must stay chat-sized');
|
|
}
|
|
|
|
if (tauriConfig.build?.devUrl !== 'http://127.0.0.1:3080/') {
|
|
throw new Error(
|
|
'AI game creator shell Tauri devUrl must stay on the fixed Vite dev port',
|
|
);
|
|
}
|
|
|
|
if (!viteConfigSource.includes("host: '127.0.0.1'")) {
|
|
throw new Error(
|
|
'AI game creator shell Vite dev server must bind to localhost',
|
|
);
|
|
}
|
|
|
|
if (!viteConfigSource.includes('port: 3080')) {
|
|
throw new Error(
|
|
'AI game creator shell Vite dev port must match Tauri devUrl',
|
|
);
|
|
}
|
|
|
|
if (!viteConfigSource.includes('strictPort: true')) {
|
|
throw new Error(
|
|
'AI game creator shell Vite dev server must not drift away from Tauri devUrl',
|
|
);
|
|
}
|
|
|
|
if (
|
|
!tauriConfig.build?.beforeDevCommand?.includes(
|
|
'run ai-game-creator-shell:dev-server',
|
|
)
|
|
) {
|
|
throw new Error(
|
|
'AI game creator shell beforeDevCommand must reuse or start the fixed Vite dev server',
|
|
);
|
|
}
|
|
|
|
if (
|
|
!tauriConfig.build?.beforeBuildCommand?.includes('--config vite.config.ts')
|
|
) {
|
|
throw new Error(
|
|
'AI game creator shell beforeBuildCommand must resolve vite config from app root',
|
|
);
|
|
}
|
|
|
|
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),
|
|
'utf8',
|
|
);
|
|
|
|
for (const snippet of [
|
|
'const port = 3080',
|
|
"response.body.includes('<title>AI 游戏创作</title>')",
|
|
'function isPortListening()',
|
|
'reuse existing Vite dev server',
|
|
'non-HTTP or unrecognized server',
|
|
"'--config', 'vite.config.ts'",
|
|
]) {
|
|
if (!devServerSource.includes(snippet)) {
|
|
throw new Error(
|
|
`AI game creator shell dev server wrapper drifted: ${snippet}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
for (const snippet of [
|
|
"path.join(repoRoot, '.env.secrets.local')",
|
|
"path.join(appRoot, '.env.secrets.local')",
|
|
'dotenv.config({ path: envPath, override: false })',
|
|
"'--manifest-path'",
|
|
"'src-tauri/Cargo.toml'",
|
|
]) {
|
|
if (!runCliWithEnvSource.includes(snippet)) {
|
|
throw new Error(
|
|
`AI game creator shell local env CLI wrapper drifted: ${snippet}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
const tauriMainSource = fs.readFileSync(
|
|
new URL('../src-tauri/src/main.rs', import.meta.url),
|
|
'utf8',
|
|
);
|
|
|
|
for (const snippet of [
|
|
'fn load_game_creator_local_env()',
|
|
'fn load_game_creator_env_file(path: &Path)',
|
|
'directory.join(".env.secrets.local")',
|
|
'.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',
|
|
'#[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)?;',
|
|
]) {
|
|
if (!tauriMainSource.includes(snippet)) {
|
|
throw new Error(
|
|
`AI game creator shell developer window guardrail drifted: ${snippet}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
const appSource = fs.readFileSync(
|
|
new URL('../src/App.tsx', import.meta.url),
|
|
'utf8',
|
|
);
|
|
for (const snippet of [
|
|
"'game.run_local'",
|
|
"'/run:运行自检,启动本地 HTTP 预览并交给外部浏览器'",
|
|
'async function openPreviewInExternalBrowser',
|
|
"'open_local_game_preview'",
|
|
'已交给外部浏览器打开。',
|
|
'async function executeRunLocal',
|
|
'function needsInitializedChatProject',
|
|
'function resolvePendingCommandProjectPath',
|
|
'resolveChatProjectPath(localProject) ?? draftProjectPath',
|
|
'`permission.cancel ${command.id} missing-project`',
|
|
"'/remember [short|long] 内容:追加短期或长期记忆'",
|
|
"'/memory-set [short|long] 内容:覆盖保存短期或长期记忆'",
|
|
'function parseRememberInput',
|
|
"'/trace 或 /loop:查看最近一次 Agent loop trace'",
|
|
'async function executeAgentTraceChat',
|
|
"relativePath: '.agent/logs/command.log'",
|
|
"'permission.pending'",
|
|
"'permission.confirm'",
|
|
"'permission.cancel'",
|
|
'function summarizeAgentRunTrace',
|
|
'工具调用:${agentRunTrace.toolCallCount}/${agentRunTrace.maxToolCalls}',
|
|
'agentRunTrace.error ?',
|
|
'className="trace-error"',
|
|
'agentRunTrace.taskGraph.repairRoutes.map',
|
|
"in: ${step.inputPaths.join(', ') || 'none'}",
|
|
"out: ${step.outputPaths.join(', ') || 'none'}",
|
|
]) {
|
|
if (!appSource.includes(snippet)) {
|
|
throw new Error(
|
|
`AI game creator shell trace panel guardrail drifted: ${snippet}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
for (const script of [
|
|
'ai-game-creator-shell:dev',
|
|
'ai-game-creator-shell:dev-server',
|
|
'ai-game-creator-shell:build',
|
|
'ai-game-creator-shell:typecheck',
|
|
'ai-game-creator-shell:agent-run:smoke',
|
|
'ai-game-creator-shell:check',
|
|
]) {
|
|
if (!rootPackageConfig.scripts?.[script]) {
|
|
throw new Error(`root package missing ${script}`);
|
|
}
|
|
}
|
|
|
|
if (
|
|
rootPackageConfig.scripts?.['ai-game-creator-shell:build'] !==
|
|
'npm --prefix apps/ai-game-creator-shell run build --'
|
|
) {
|
|
throw new Error(
|
|
'root ai-game-creator-shell:build script must forward build args',
|
|
);
|
|
}
|
|
|
|
const agentRunSmokeSource = fs.readFileSync(
|
|
new URL('../scripts/smoke-agent-run-local-provider.mjs', import.meta.url),
|
|
'utf8',
|
|
);
|
|
|
|
for (const snippet of [
|
|
"const smokeAssetMarker = 'SMOKE_LOCAL_ASSET:chef'",
|
|
"const smokeAudioAssetPath = 'assets/uploads/smoke-bounce.mp3'",
|
|
"mediaType: 'audio/mpeg'",
|
|
'document.body.dataset.smokeFrame = String(frameCount)',
|
|
"ctx.fillStyle = '#ff00ff'",
|
|
'ctx.fillRect(4, 4, 8, 24)',
|
|
'const sample = ctx.getImageData(4, 4, 24, 24).data',
|
|
'chef.complete && chef.naturalWidth > 0',
|
|
'document.body.dataset.smokeCanvasPixels = String(litPixels)',
|
|
'document.body.dataset.smokeCanvasColors = String(colors.size)',
|
|
'readBrowserDom(previewUrl)',
|
|
"extractDomNumber(previewDom, 'smoke-canvas-pixels')",
|
|
'--dump-dom',
|
|
'readHttpHead(new URL(smokeAssetPath, previewUrl).toString())',
|
|
'readHttpHead(new URL(smokeAudioAssetPath, previewUrl).toString())',
|
|
'function writeStreamingChatCompletion',
|
|
'requestJson?.stream === true',
|
|
`requestBodies.every((body) => body.includes('"stream":true'))`,
|
|
"GENARRATIVE_GAME_CREATOR_LLM_STREAM: 'true'",
|
|
"method: 'HEAD'",
|
|
'previewAssetHead.contentLength === String(smokeAssetBytes.length)',
|
|
"previewAudioHead.contentType === 'audio/mpeg'",
|
|
"previewAssetHead.body === ''",
|
|
'trace missing professional group ${group}',
|
|
]) {
|
|
if (!agentRunSmokeSource.includes(snippet)) {
|
|
throw new Error(
|
|
`AI game creator shell agent-run smoke drifted: ${snippet}`,
|
|
);
|
|
}
|
|
}
|