Files
Genarrative/apps/ai-game-creator-shell/scripts/smoke-agent-run-local-provider.mjs
T
suzmii 938c37ca1b
Project CI / Repository checks (pull_request) Successful in 3m54s
Project CI / Frontend tests (pull_request) Successful in 5m1s
Project CI / Backend tests (pull_request) Successful in 8m26s
Project CI / Native shell tests (pull_request) Successful in 18m51s
修复 AGC 本地 Provider Smoke 路由
允许显式 Debug E2E 开关使用 loopback Provider

为 smoke 子进程透传环境并启用 Debug E2E 开关
2026-09-03 23:27:54 +08:00

945 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { spawn } from 'node:child_process';
import { accessSync } from 'node:fs';
import fs from 'node:fs/promises';
import http from 'node:http';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const appRoot = fileURLToPath(new URL('..', import.meta.url));
const inheritedChildEnvironment = { ...globalThis['process']['env'] };
const localConfigPath = path.join(appRoot, 'game-creator.config.local.json');
const projectRoot = path.join(
os.tmpdir(),
`genarrative-ai-game-creator-smoke-${Date.now()}`,
);
const prompt = '像素风反弹弹幕厨房';
const smokeAssetPath = 'assets/uploads/smoke-chef.png';
const smokeAssetMarker = 'SMOKE_LOCAL_ASSET:chef';
const smokeAssetBytes = Buffer.concat([
Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=',
'base64',
),
Buffer.from(smokeAssetMarker),
]);
const smokeAudioAssetPath = 'assets/uploads/smoke-bounce.mp3';
const smokeAudioAssetBytes = 'SMOKE_LOCAL_AUDIO:bounce';
const smokeProjectConversationMarker =
'SMOKE_CONVERSATION_CONTEXT:moonlight-wok';
const smokeAgentConversationMarker =
'SMOKE_AGENT_CONVERSATION_CONTEXT:neon-kitchen';
function handoffs() {
return [
[
'design',
'Gameplay',
'定义反弹厨房核心循环',
['game/game_design.md'],
'交给数值、美术、音乐、程序组',
],
[
'balance',
'Difficulty',
'给出锅盖反弹速度、生命和得分口径',
['game/balance.json'],
'交给程序组读取',
],
[
'art',
'Asset',
'规划像素厨师、夜间厨房和锅盖弹幕资产',
['assets/manifest.art.json'],
'进入画板链路',
],
[
'audio',
'SFX',
'规划锅盖反弹音效和厨房节奏 BGM',
['assets/manifest.audio.json'],
'进入音频链路',
],
[
'code',
'Code',
'生成 canvas 可玩原型',
['game/index.html'],
'交给 Playtest',
],
[
'publishing',
'Publish',
'整理标题、标签和发布说明',
['exports/README.md'],
'等待预览验收',
],
].map(([group, role, summary, outputs, next]) => ({
group,
role,
summary,
outputs,
next,
}));
}
function draft({ withInput }) {
const inputCode = withInput
? "window.addEventListener('keydown', (event) => { if (event.key.toLowerCase() === 'r') resetGame(); player.x += event.key === 'ArrowRight' ? 8 : event.key === 'ArrowLeft' ? -8 : 0; });"
: '';
return {
title: '反弹弹幕厨房',
designMarkdown:
'玩家控制像素厨师移动锅盖反弹月光弹幕,点亮三口锅后获胜,被弹幕击中耗尽生命则失败。',
balance: {
playerSpeed: 220,
playerLives: 3,
scorePerPot: 100,
difficultyRamp: '每 20 秒增加一枚弹幕',
},
artManifest: {
source: 'local-provider',
items: [
{ kind: 'character', title: '像素厨师', status: 'needs-canvas' },
{ kind: 'scene', title: '夜间厨房', status: 'needs-canvas' },
],
},
audioManifest: {
source: 'local-provider',
items: [
{
kind: 'background-music',
title: '厨房节奏 BGM',
status: 'needs-canvas',
},
{ kind: 'sound-effect', title: '锅盖反弹音', status: 'needs-canvas' },
],
},
publishReadme:
'## 标签\n\n弹幕 / 反弹 / 厨房\n\n## 下一步\n\n试玩锅盖反弹手感。',
handoffs: handoffs(),
handoffSummary:
'策划组 / Gameplay:反弹厨房核心循环\n数值组 / Difficulty:生命、速度和得分\n美术组 / Asset:像素厨房资产\n音乐组 / SFX:反弹音效\n程序组 / Codecanvas 原型\n运营组 / Publish:发布包装',
gameHtml: `<!doctype html>
<html lang="zh-CN">
<head><meta charset="UTF-8"><title>反弹弹幕厨房</title></head>
<body>
<canvas id="game" width="480" height="270"></canvas>
<script>
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const player = { x: 80, y: 150, hp: 3 };
const chef = new Image();
chef.src = '/${smokeAssetPath}';
const bounceSound = new Audio('/${smokeAudioAssetPath}');
let frameCount = 0;
let pots = 0;
let state = 'playing';
const marker = 'LOCAL_E2E_MECHANIC:reflect-kitchen';
function resetGame() { player.x = 80; player.hp = 3; pots = 0; state = 'playing'; }
function frame() {
frameCount += 1;
document.body.dataset.smokeFrame = String(frameCount);
document.body.dataset.smokeAudio = bounceSound.src.includes('${smokeAudioAssetPath}') ? 'ready' : 'missing';
ctx.fillStyle = '#151824';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#ff00ff';
ctx.fillRect(4, 4, 8, 24);
ctx.fillStyle = '#00ffff';
ctx.fillRect(12, 4, 8, 24);
ctx.fillStyle = '#ffff00';
ctx.fillRect(20, 4, 8, 24);
ctx.fillStyle = '#f8e16c';
ctx.fillText(marker, 20, 32);
if (chef.complete && chef.naturalWidth > 0) ctx.drawImage(chef, player.x, player.y, 24, 24);
ctx.fillText('目标:反弹月光弹幕,点亮三口锅', 20, 60);
ctx.fillText('胜利:点亮三口锅 / 失败:生命耗尽 / R 重开', 20, 86);
if (pots >= 3) state = '胜利';
if (player.hp <= 0) state = '失败';
const sample = ctx.getImageData(4, 4, 24, 24).data;
let litPixels = 0;
const colors = new Set();
for (let index = 0; index < sample.length; index += 4) {
const red = sample[index];
const green = sample[index + 1];
const blue = sample[index + 2];
const alpha = sample[index + 3];
if (alpha > 0 && (red > 240 || green > 240 || blue > 240)) {
litPixels += 1;
if (colors.size < 64) colors.add(red + ',' + green + ',' + blue + ',' + alpha);
}
}
document.body.dataset.smokeCanvasPixels = String(litPixels);
document.body.dataset.smokeCanvasColors = String(colors.size);
requestAnimationFrame(frame);
}
${inputCode}
requestAnimationFrame(frame);
</script>
</body>
</html>`,
};
}
const responses = [
'## 核心循环\n\n反弹弹幕点亮三口锅。\n\n## Evaluator 验收\n\n必须有输入监听、主循环、胜负状态和重开路径。',
JSON.stringify(draft({ withInput: false })),
JSON.stringify(draft({ withInput: true })),
];
let responseIndex = 0;
const requestBodies = [];
const server = http.createServer((request, response) => {
let requestBody = '';
request.setEncoding('utf8');
request.on('data', (chunk) => {
requestBody += chunk;
});
request.on('end', () => {
requestBodies.push(requestBody);
let requestJson = null;
try {
requestJson = JSON.parse(requestBody);
} catch {
// Keep the request invalid so the provider fixture can return its normal error path.
}
const content = responses[responseIndex++];
if (!content) {
response.writeHead(500, { 'content-type': 'application/json' });
response.end(JSON.stringify({ error: 'local provider exhausted' }));
return;
}
if (requestJson?.stream === true) {
writeStreamingChatCompletion(response, content);
return;
}
const body = JSON.stringify({
id: `chatcmpl-local-${responseIndex}`,
model: 'local-game-creator-smoke',
choices: [
{
message: { content },
finish_reason: 'stop',
},
],
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
});
response.writeHead(200, {
'content-type': 'application/json',
'content-length': Buffer.byteLength(body),
});
response.end(body);
});
});
function writeStreamingChatCompletion(response, content) {
response.writeHead(200, {
'content-type': 'text/event-stream; charset=utf-8',
'cache-control': 'no-cache',
});
const splitAt = Math.max(1, Math.floor(content.length / 2));
for (const chunk of [content.slice(0, splitAt), content.slice(splitAt)]) {
if (!chunk) {
continue;
}
response.write(
`data: ${JSON.stringify({
id: `chatcmpl-local-${responseIndex}`,
model: 'local-game-creator-smoke',
choices: [{ delta: { content: chunk }, finish_reason: null }],
})}\n\n`,
);
}
response.write(
`data: ${JSON.stringify({
id: `chatcmpl-local-${responseIndex}`,
model: 'local-game-creator-smoke',
choices: [{ delta: {}, finish_reason: 'stop' }],
})}\n\n`,
);
response.end('data: [DONE]\n\n');
}
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,
previewUrl,
previewAsset,
previewAssetHead,
previewAudio,
previewAudioHead,
previewDom,
} = await runAgent();
const tracePath = path.join(projectRoot, '.agent/run.latest.json');
const trace = JSON.parse(await fs.readFile(tracePath, 'utf8'));
const pass2TaskGraph = JSON.parse(
await fs.readFile(
path.join(projectRoot, '.agent/passes/pass-2/task-graph.json'),
'utf8',
),
);
const agentDbRecords = (
await fs.readFile(path.join(projectRoot, '.agent/agent.db'), 'utf8')
)
.trim()
.split('\n')
.map((line) => JSON.parse(line));
const previewLog = await fs.readFile(
path.join(projectRoot, '.agent/logs/preview.log'),
'utf8',
);
const gameHtml = await fs.readFile(
path.join(projectRoot, 'game/index.html'),
'utf8',
);
const agenda2 = await fs.readFile(
path.join(projectRoot, '.agent/passes/pass-2/agenda.md'),
'utf8',
);
assert(output.includes('agent.run.completed'), 'CLI did not complete');
assert(
previewUrl?.startsWith('http://127.0.0.1:'),
'CLI did not print local preview URL',
);
assert(
previewHtml.includes('LOCAL_E2E_MECHANIC:reflect-kitchen'),
'local preview did not serve generated game',
);
assert(
previewDom?.includes('data-smoke-frame=') &&
previewDom.includes('data-smoke-audio="ready"'),
'headless browser did not run generated preview frame',
);
const canvasPixels = extractDomNumber(previewDom, 'smoke-canvas-pixels');
const canvasColors = extractDomNumber(previewDom, 'smoke-canvas-colors');
assert(
canvasPixels > 40 && canvasColors > 2,
`headless browser preview canvas looks blank: pixels=${canvasPixels}, colors=${canvasColors}`,
);
assert(
previewAsset.includes(smokeAssetMarker),
'local preview did not serve referenced project asset',
);
assert(
previewAudio.includes(smokeAudioAssetBytes),
'local preview did not serve referenced audio asset',
);
assert(
previewAssetHead?.statusCode === 200 &&
previewAssetHead.contentLength === String(smokeAssetBytes.length) &&
previewAssetHead.contentType === 'image/png' &&
previewAssetHead.body === '',
'local preview HEAD did not preserve referenced asset content length',
);
assert(
previewAudioHead?.statusCode === 200 &&
previewAudioHead.contentLength ===
String(Buffer.byteLength(smokeAudioAssetBytes)) &&
previewAudioHead.contentType === 'audio/mpeg' &&
previewAudioHead.body === '',
'local preview HEAD did not preserve referenced audio asset metadata',
);
assert(
responseIndex === responses.length,
`provider calls ${responseIndex}/${responses.length}`,
);
assert(
requestBodies.every((body) => body.includes('"stream":true')),
'provider requests did not use streaming LLM mode',
);
assert(
requestBodies.some((body) =>
body.includes('"model":"planner-smoke-model"'),
),
'provider requests did not use planner agent LLM override',
);
assert(
requestBodies.some((body) =>
body.includes('"model":"generator-smoke-model"'),
),
'provider requests did not use generator agent LLM override',
);
assert(
requestBodies.every((body) => !body.includes('global-smoke-model-unused')),
'provider requests unexpectedly used global LLM config',
);
assert(
requestBodies.some(
(body) =>
body.includes('# 本地项目资产') &&
body.includes(smokeAssetPath) &&
body.includes(smokeAudioAssetPath),
),
'provider requests missing local asset prompt context',
);
assert(
requestBodies.some((body) =>
body.includes(smokeProjectConversationMarker),
) &&
requestBodies.some((body) => body.includes(smokeAgentConversationMarker)),
'provider requests missing recent conversation prompt context',
);
assert(
requestBodies.every((body) => !body.includes('sk-smoke-secret')),
'provider requests leaked sensitive conversation context',
);
assert(trace.status === 'preview-stopped', `trace status ${trace.status}`);
assert(trace.passes === 2, `trace passes ${trace.passes}`);
const expectedToolCallCount = trace.steps.reduce(
(total, step) => total + (step.toolCalls?.length ?? 0),
0,
);
assert(
trace.toolCallCount === expectedToolCallCount,
`trace toolCallCount ${trace.toolCallCount}/${expectedToolCallCount}`,
);
assert(
trace.toolCallCount <= trace.maxToolCalls,
`trace tool budget ${trace.toolCallCount}/${trace.maxToolCalls}`,
);
const tracedGroups = new Set(
trace.steps.map((step) => step.group).filter(Boolean),
);
for (const group of [
'design',
'balance',
'art',
'audio',
'code',
'publishing',
]) {
assert(
tracedGroups.has(group),
`trace missing professional group ${group}`,
);
}
assert(
trace.taskGraph?.repairRoutes?.some(
(route) =>
route.reason === 'code-runtime+dependency-impact' &&
route.taskIds.includes('code-prototype') &&
route.taskIds.includes('preview-readiness') &&
route.taskIds.includes('publish-package'),
),
'trace missing code-runtime dependency-impact repair route',
);
assert(
pass2TaskGraph.repairRoutes?.some(
(route) =>
route.reason === 'code-runtime+dependency-impact' &&
route.taskIds.includes('code-prototype') &&
route.taskIds.includes('preview-readiness') &&
route.taskIds.includes('publish-package'),
),
'pass task graph missing downstream-impact code repair route',
);
assert(
trace.artifacts?.some((artifact) => artifact.path === '.agent/agent.db'),
'trace artifacts missing .agent/agent.db',
);
assert(
trace.artifacts?.some(
(artifact) => artifact.path === '.agent/passes/pass-2/task-graph.json',
),
'trace artifacts missing pass-2 task graph',
);
assert(
trace.artifacts?.some(
(artifact) => artifact.path === '.agent/logs/preview.log',
),
'trace artifacts missing preview log',
);
assert(
trace.artifacts?.some(
(artifact) => artifact.path === '.agent/logs/agent.log',
) &&
trace.artifacts?.some(
(artifact) => artifact.path === '.agent/logs/command.log',
),
'trace artifacts missing agent or command logs',
);
assert(
previewLog.includes('preview.running') &&
previewLog.includes('preview.stopped'),
'preview log missing start or stop entries',
);
assert(
agentDbRecords.some((record) => record.recordType === 'project.init') &&
agentDbRecords.some(
(record) => record.recordType === 'game.generate_draft',
),
'agent.db missing init or generation records',
);
assert(
agenda2.includes('activeTasks: code-director'),
'repair agenda did not target code tasks',
);
assert(
agenda2.includes('publish-package'),
'repair agenda did not target downstream publishing tasks',
);
assert(
agenda2.includes('carriedTasks: design-director'),
'repair agenda did not carry design tasks',
);
assert(
trace.steps.some(
(step) =>
step.pass === 2 &&
step.agent === '策划组 / Director' &&
step.status === 'carried-over',
),
'trace missing carried-over design role',
);
assert(
trace.steps.some(
(step) =>
step.pass === 2 &&
step.agent === '程序组 / Director' &&
step.toolCalls?.[0]?.toolId === 'agent.role.brief.code.director',
),
'trace missing code repair role',
);
assert(
trace.steps.some(
(step) =>
step.pass === 2 &&
step.agent === '运营组 / Publish' &&
step.toolCalls?.[0]?.toolId === 'agent.role.brief.publishing.publish',
),
'trace missing downstream publishing repair role',
);
assert(
trace.steps.some((step) =>
step.toolCalls?.some(
(toolCall) =>
toolCall.toolId === 'agent.tool.suggest.canvas.project_sync' &&
toolCall.status === 'suggested',
),
),
'trace missing canvas project sync tool suggestion',
);
assert(
trace.steps.some(
(step) =>
step.agent === 'Planner' &&
step.inputPaths?.includes('memory/session.md') &&
step.inputPaths?.includes('memory/project.md') &&
step.inputPaths?.includes('memory/blackboard.md') &&
step.inputPaths?.includes('.agent/conversations/project.jsonl') &&
step.inputPaths?.includes('.agent/conversations/agents/') &&
step.inputPaths?.includes('.agent/manifest.json'),
),
'trace missing planner memory, conversation or manifest inputs',
);
assert(
trace.steps.some(
(step) =>
step.agent === '策划组 / Director' &&
step.pass === 1 &&
step.inputPaths?.includes('memory/blackboard.md') &&
step.inputPaths?.includes('.agent/conversations/project.jsonl') &&
step.inputPaths?.includes('.agent/conversations/agents/') &&
step.inputPaths?.includes('memory/agents/design/director.md') &&
step.inputPaths?.includes('.agent/manifest.json') &&
step.inputPaths?.includes('.agent/passes/pass-1/agenda.md'),
),
'trace missing role brief conversation, manifest or agenda inputs',
);
assert(
trace.steps.some(
(step) =>
step.agent === 'Generator' &&
step.pass === 2 &&
step.inputPaths?.includes('memory/blackboard.md') &&
step.inputPaths?.includes('.agent/conversations/project.jsonl') &&
step.inputPaths?.includes('.agent/conversations/agents/') &&
step.inputPaths?.includes('.agent/manifest.json') &&
step.inputPaths?.includes('.agent/passes/pass-2/agenda.md') &&
step.inputPaths?.includes('.agent/passes/pass-2/task-graph.json'),
),
'trace missing generator conversation, manifest, agenda or task graph inputs',
);
assert(
gameHtml.includes('LOCAL_E2E_MECHANIC:reflect-kitchen'),
'game html marker missing',
);
assert(
gameHtml.includes('addEventListener'),
'game html input listener missing',
);
assert(
gameHtml.includes(`/${smokeAssetPath}`),
'game html did not reference seeded local asset',
);
assert(
gameHtml.includes(`/${smokeAudioAssetPath}`),
'game html did not reference seeded local audio asset',
);
console.log('ai-game-creator-shell.agent-run.smoke=passed');
console.log(`projectPath=${projectRoot}`);
console.log(`tracePath=${tracePath}`);
} finally {
await restoreOptionalFile(localConfigPath, previousLocalConfig);
server.close();
}
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: 'global-smoke-key-unused',
baseUrl: 'http://127.0.0.1:1/v1',
model: 'global-smoke-model-unused',
apiKind: 'openai_chat',
stream: true,
},
agentLlm: {
planner: {
apiKey: 'planner-smoke-key',
baseUrl,
model: 'planner-smoke-model',
apiKind: 'openai_chat',
stream: true,
},
generator: {
apiKey: 'generator-smoke-key',
baseUrl,
model: 'generator-smoke-model',
apiKind: 'openai_chat',
stream: true,
},
},
},
null,
2,
)}\n`,
);
}
function runAgent() {
return new Promise((resolve, reject) => {
let previewReadStarted = false;
let previewUrl = '';
let previewHtml = '';
let previewAsset = '';
let previewAssetHead = null;
let previewAudio = '';
let previewAudioHead = null;
let previewDom = '';
const child = spawn(
'cargo',
[
'run',
'--manifest-path',
'src-tauri/Cargo.toml',
'--',
'--agent-run',
projectRoot,
prompt,
],
{
cwd: appRoot,
stdio: ['pipe', 'pipe', 'pipe'],
env: {
...inheritedChildEnvironment,
// 该 smoke 只使用一次性 loopback Provider;生产路由仍保持锁定。
GENARRATIVE_AGC_DEBUG_PROVIDER_E2E: '1',
},
},
);
let stdout = '';
let stderr = '';
let previewReadError = null;
child.stdout.on('data', (chunk) => {
stdout += chunk.toString();
if (previewReadStarted) {
return;
}
const line = stdout
.split('\n')
.find((entry) => entry.startsWith('previewUrl='));
if (!line) {
return;
}
previewReadStarted = true;
previewUrl = line.slice('previewUrl='.length).trim();
Promise.all([
readHttpText(previewUrl),
readHttpText(new URL(smokeAssetPath, previewUrl).toString()),
readHttpHead(new URL(smokeAssetPath, previewUrl).toString()),
readHttpText(new URL(smokeAudioAssetPath, previewUrl).toString()),
readHttpHead(new URL(smokeAudioAssetPath, previewUrl).toString()),
readBrowserDom(previewUrl),
])
.then(([html, asset, assetHead, audio, audioHead, dom]) => {
previewHtml = html;
previewAsset = asset;
previewAssetHead = assetHead;
previewAudio = audio;
previewAudioHead = audioHead;
previewDom = dom;
})
.catch((error) => {
previewReadError = error;
})
.finally(() => {
child.stdin.write('\n');
});
});
child.stderr.on('data', (chunk) => {
stderr += chunk.toString();
});
child.on('error', reject);
child.on('close', (code) => {
const output = `${stdout}${stderr}`;
if (previewReadError) {
reject(previewReadError);
return;
}
if (code === 0) {
resolve({
output,
previewHtml,
previewUrl,
previewAsset,
previewAssetHead,
previewAudio,
previewAudioHead,
previewDom,
});
} else {
reject(new Error(output || `agent run exited with ${code}`));
}
});
});
}
async function seedLocalAsset() {
await fs.mkdir(path.join(projectRoot, 'assets/uploads'), { recursive: true });
await fs.mkdir(path.join(projectRoot, '.agent'), { recursive: true });
await seedConversationContext();
await fs.writeFile(path.join(projectRoot, smokeAssetPath), smokeAssetBytes);
await fs.writeFile(
path.join(projectRoot, smokeAudioAssetPath),
smokeAudioAssetBytes,
);
await fs.writeFile(
path.join(projectRoot, '.agent/manifest.json'),
JSON.stringify(
{
schemaVersion: 'game-creation-app.manifest.v1',
projectId: 'local-provider-smoke',
name: '本地 Provider Smoke',
assets: [
{
id: 'smoke-chef',
kind: 'character',
mediaType: 'image/png',
localPath: smokeAssetPath,
source: { kind: 'uploaded' },
},
{
id: 'smoke-bounce',
kind: 'sound-effect',
mediaType: 'audio/mpeg',
localPath: smokeAudioAssetPath,
source: { kind: 'uploaded' },
},
],
},
null,
2,
),
);
}
async function seedConversationContext() {
const projectConversationPath = path.join(
projectRoot,
'.agent/conversations/project.jsonl',
);
const agentConversationPath = path.join(
projectRoot,
'.agent/conversations/agents/art-asset-plan.jsonl',
);
await fs.mkdir(path.dirname(projectConversationPath), { recursive: true });
await fs.mkdir(path.dirname(agentConversationPath), { recursive: true });
await fs.writeFile(
projectConversationPath,
`${JSON.stringify({
schemaVersion: 'game-creator-conversation.v1',
role: 'user',
content: `玩家坚持使用 ${smokeProjectConversationMarker}`,
agentId: null,
updatedAt: 1,
})}\n`,
);
await fs.writeFile(
agentConversationPath,
`${JSON.stringify({
schemaVersion: 'game-creator-conversation.v1',
role: 'assistant',
content: `API Key sk-smoke-secret\n美术方向 ${smokeAgentConversationMarker}`,
agentId: 'art-asset-plan',
updatedAt: 2,
})}\n`,
);
}
function readHttpText(url) {
return new Promise((resolve, reject) => {
http
.get(url, (response) => {
let body = '';
response.setEncoding('utf8');
response.on('data', (chunk) => {
body += chunk;
});
response.on('end', () => resolve(body));
})
.on('error', reject);
});
}
function readHttpHead(url) {
return new Promise((resolve, reject) => {
const request = http.request(url, { method: 'HEAD' }, (response) => {
let body = '';
response.setEncoding('utf8');
response.on('data', (chunk) => {
body += chunk;
});
response.on('end', () =>
resolve({
statusCode: response.statusCode,
contentLength: response.headers['content-length'],
contentType: response.headers['content-type'],
body,
}),
);
});
request.on('error', reject);
request.end();
});
}
function readBrowserDom(url) {
return new Promise((resolve, reject) => {
const chrome = resolveChromeBin();
const child = spawn(
chrome,
[
'--headless',
'--disable-gpu',
'--disable-dev-shm-usage',
'--no-sandbox',
'--virtual-time-budget=1000',
'--dump-dom',
url,
],
{ stdio: ['ignore', 'pipe', 'pipe'] },
);
let stdout = '';
let stderr = '';
child.stdout.on('data', (chunk) => {
stdout += chunk.toString();
});
child.stderr.on('data', (chunk) => {
stderr += chunk.toString();
});
child.on('error', reject);
child.on('close', (code) => {
if (code === 0) {
resolve(stdout);
} else {
reject(new Error(stderr || `headless Chrome exited with ${code}`));
}
});
});
}
function resolveChromeBin() {
const windowsRoot = path.parse(os.homedir()).root;
for (const candidate of [
path.join(
windowsRoot,
'Program Files/Google/Chrome/Application/chrome.exe',
),
path.join(
windowsRoot,
'Program Files (x86)/Google/Chrome/Application/chrome.exe',
),
path.join(
os.homedir(),
'AppData/Local/Google/Chrome/Application/chrome.exe',
),
path.join(
windowsRoot,
'Program Files/Microsoft/Edge/Application/msedge.exe',
),
path.join(
windowsRoot,
'Program Files (x86)/Microsoft/Edge/Application/msedge.exe',
),
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
'/opt/google/chrome/chrome',
'/usr/bin/google-chrome',
'/usr/bin/google-chrome-stable',
'/usr/bin/chromium',
'/usr/bin/chromium-browser',
]) {
try {
accessSync(candidate);
return candidate;
} catch {
// Try the next supported system browser path.
}
}
return 'google-chrome';
}
function extractDomNumber(dom, kebabName) {
const match = dom?.match(new RegExp(`data-${kebabName}="(\\d+)"`));
return match ? Number(match[1]) : 0;
}
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}