# Scene Skill Generator Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Build a standalone local web console that lets users select a scenario directory, auto-extract scene-id/name via LLM, and invoke `sg_scene_generate` to produce a complete skill package with real-time progress streaming. **Architecture:** A zero-dependency Node.js HTTP server serves a self-contained HTML page. The server reads `sgclaw_config.json` for LLM credentials, calls the LLM API to extract scene metadata from directory contents, and spawns `cargo run --bin sg_scene_generate` as a subprocess, streaming stdout/stderr to the browser via Server-Sent Events. **Tech Stack:** HTML, CSS, vanilla JavaScript, Node.js (built-in modules only: `http`, `fs`, `path`, `child_process`), Server-Sent Events, Rust `sg_scene_generate` binary --- ## Scope Check This plan covers a single, self-contained subsystem: the scene skill generator frontend + Node.js server. It is independent of the Rust runtime, service websocket, and browser integration. The only external dependency is `sg_scene_generate` which already exists and is tested. --- ## File Map ### New Files | File | Responsibility | |------|----------------| | `frontend/scene-generator/server.js` | HTTP server, routing, SSE connection management | | `frontend/scene-generator/config-loader.js` | Read and parse `sgclaw_config.json`, resolve `projectRoot` | | `frontend/scene-generator/llm-client.js` | LLM API client (OpenAI-compatible format), prompt construction, JSON extraction | | `frontend/scene-generator/generator-runner.js` | Spawn `cargo run` subprocess, parse output, emit SSE events | | `frontend/scene-generator/sg_scene_generator.html` | Self-contained HTML page with inline CSS + JS | | `frontend/scene-generator/serve.sh` | Bash startup script | | `frontend/scene-generator/serve.cmd` | Windows batch startup script | ### Test Files | File | Responsibility | |------|----------------| | `tests/scene_generator_server_test.rs` | Rust integration test: verify server file existence, config loading | | `tests/scene_generator_html_test.rs` | Rust integration test: verify HTML file location, required elements | ### Reference Files (not modified) | File | Purpose | |------|---------| | `src/bin/sg_scene_generate.rs` | CLI that server.js spawns | | `frontend/service-console/sg_claw_service_console.html` | UI style reference | | `sgclaw_config.json` | LLM config source | | `docs/superpowers/specs/2026-04-16-scene-skill-generator-design.md` | Design spec | --- ## Scope Guardrails - Do not modify `src/bin/sg_scene_generate.rs` - Do not modify `src/generated_scene/generator.rs` - Do not modify `frontend/service-console/sg_claw_service_console.html` - Do not add npm dependencies (only Node.js built-in modules) - Do not expose the server to non-localhost interfaces (bind to `127.0.0.1`) - Do not leak API keys to the frontend --- ### Task 1: Create config-loader.js with failing server test **Files:** - Create: `frontend/scene-generator/config-loader.js` - Create: `tests/scene_generator_server_test.rs` - Reference: `sgclaw_config.json` - [ ] **Step 1: Write the failing Rust test** Create `tests/scene_generator_server_test.rs`: ```rust use std::fs; use std::path::PathBuf; #[test] fn scene_generator_server_files_exist() { let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); let server_js = manifest_dir .join("frontend") .join("scene-generator") .join("server.js"); let config_loader = manifest_dir .join("frontend") .join("scene-generator") .join("config-loader.js"); assert!( server_js.exists(), "server.js not found at {:?}", server_js ); assert!( config_loader.exists(), "config-loader.js not found at {:?}", config_loader ); } #[test] fn sgclaw_config_is_readable() { let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); let config_path = manifest_dir.join("sgclaw_config.json"); let content = fs::read_to_string(&config_path) .unwrap_or_else(|err| panic!("sgclaw_config.json not found: {}", err)); let parsed: serde_json::Value = serde_json::from_str(&content).expect("should be valid JSON"); assert!(parsed.get("apiKey").is_some(), "missing apiKey"); assert!(parsed.get("baseUrl").is_some(), "missing baseUrl"); assert!(parsed.get("model").is_some(), "missing model"); } ``` - [ ] **Step 2: Run test to verify it fails** Run: `cargo test scene_generator_server_files_exist --test scene_generator_server_test -- --nocapture` Expected: FAIL with "server.js not found" - [ ] **Step 3: Create directory and config-loader.js** Create `frontend/scene-generator/` directory, then create `frontend/scene-generator/config-loader.js`: ```javascript const fs = require("fs"); const path = require("path"); function resolveProjectRoot() { const envRoot = process.env.SGCLAW_PROJECT_ROOT; if (envRoot && fs.existsSync(envRoot)) { return path.resolve(envRoot); } const configPath = resolveConfigPath(); if (configPath && fs.existsSync(configPath)) { return path.dirname(configPath); } return path.resolve(__dirname); } function resolveConfigPath() { const envPath = process.env.SGCLAW_CONFIG_PATH; if (envPath && fs.existsSync(envPath)) { return path.resolve(envPath); } const candidates = [ path.resolve(__dirname, "..", "..", "sgclaw_config.json"), path.resolve(__dirname, "..", "sgclaw_config.json"), path.resolve(__dirname, "sgclaw_config.json"), ]; for (const p of candidates) { if (fs.existsSync(p)) return p; } return null; } function loadConfig() { const configPath = resolveConfigPath(); if (!configPath) { throw new Error( "sgclaw_config.json not found. Set SGCLAW_CONFIG_PATH or place it in the project root." ); } const raw = fs.readFileSync(configPath, "utf-8"); const config = JSON.parse(raw); const apiKey = config.apiKey || ""; const baseUrl = config.baseUrl || ""; const model = config.model || ""; if (!apiKey) throw new Error("sgclaw_config.json: 'apiKey' is required"); if (!baseUrl) throw new Error("sgclaw_config.json: 'baseUrl' is required"); if (!model) throw new Error("sgclaw_config.json: 'model' is required"); return { apiKey, baseUrl: normalizeBaseUrl(baseUrl), model, projectRoot: resolveProjectRoot(), configPath, }; } function normalizeBaseUrl(url) { url = url.replace(/\/+$/, ""); if (!url.endsWith("/v1")) url = url + "/v1"; return url; } function getDefaults() { const config = loadConfig(); const projectRoot = config.projectRoot; return { outputRoot: path.join(projectRoot, "examples", "generated_scene_platform"), lessonsPath: path.join( projectRoot, "docs", "superpowers", "references", "tq-lineloss-lessons-learned.toml" ), llmBaseUrl: config.baseUrl, llmModel: config.model, }; } module.exports = { loadConfig, getDefaults, resolveProjectRoot, resolveConfigPath }; ``` - [ ] **Step 4: Run test to verify it passes** Run: `cargo test scene_generator_server_files_exist --test scene_generator_server_test -- --nocapture` Expected: PASS Run: `cargo test sgclaw_config_is_readable --test scene_generator_server_test -- --nocapture` Expected: PASS - [ ] **Step 5: Commit** ```bash git add frontend/scene-generator/config-loader.js tests/scene_generator_server_test.rs git commit -m "feat: add config-loader.js and initial server test" ``` --- ### Task 2: Create llm-client.js with unit test **Files:** - Create: `frontend/scene-generator/llm-client.js` - Create: `tests/scene_generator_llm_test.js` - [ ] **Step 1: Write the failing test** Create `tests/scene_generator_llm_test.js`: ```javascript const assert = require("assert"); const { buildAnalyzePrompt, extractJsonFromResponse, } = require("../frontend/scene-generator/llm-client"); function testBuildAnalyzePromptIncludesFileContents() { const dirContents = { "scene.toml": '[scene]\nid = "test-scene"', scripts: { "collect_test.js": "async function main() {}" }, tree: "├── scene.toml\n└── collect_test.js", }; const prompt = buildAnalyzePrompt("D:/test/scenario", dirContents); assert.ok(prompt.includes("scene.toml"), "should include scene.toml"); assert.ok(prompt.includes("collect_test.js"), "should include script name"); assert.ok(prompt.includes("D:/test/scenario"), "should include sourceDir"); console.log("PASS: testBuildAnalyzePromptIncludesFileContents"); } function testExtractJsonFromResponse() { const withMarkdown = '```json\n{"sceneId": "test", "sceneName": "测试"}\n```'; const plain = '{"sceneId": "test", "sceneName": "测试"}'; const withPrefix = 'Here is the result:\n{"sceneId": "test", "sceneName": "测试"}'; assert.deepStrictEqual(extractJsonFromResponse(withMarkdown), { sceneId: "test", sceneName: "测试", }); assert.deepStrictEqual(extractJsonFromResponse(plain), { sceneId: "test", sceneName: "测试", }); assert.deepStrictEqual(extractJsonFromResponse(withPrefix), { sceneId: "test", sceneName: "测试", }); console.log("PASS: testExtractJsonFromResponse"); } testBuildAnalyzePromptIncludesFileContents(); testExtractJsonFromResponse(); ``` - [ ] **Step 2: Run test to verify it fails** Run: `node tests/scene_generator_llm_test.js` Expected: FAIL with "Cannot find module" - [ ] **Step 3: Create llm-client.js** ```javascript const http = require("http"); const SYSTEM_PROMPT = `你是一个场景信息提取助手。根据场景目录的内容,提取 scene-id 和 scene-name。 scene-id 规则: - 使用英文短横线连接,如 tq-lineloss-report - 全小写,有业务含义 scene-name 规则: - 使用中文,简短描述性名称 - 如 "台区线损报表"、"知乎热榜导出" 请以 JSON 格式返回:{"sceneId": "...", "sceneName": "..."}`; function buildAnalyzePrompt(sourceDir, dirContents) { const parts = []; parts.push(`=== 目录结构 ===`); parts.push(dirContents.tree || "(empty)"); if (dirContents["scene.toml"]) { parts.push(`\n=== scene.toml ===`); parts.push(dirContents["scene.toml"]); } if (dirContents["SKILL.toml"]) { parts.push(`\n=== SKILL.toml ===`); parts.push(dirContents["SKILL.toml"]); } if (dirContents["SKILL.md"]) { parts.push(`\n=== SKILL.md ===`); parts.push(dirContents["SKILL.md"]); } if (dirContents.scripts && Object.keys(dirContents.scripts).length > 0) { parts.push(`\n=== 脚本文件 ===`); for (const [name, content] of Object.entries(dirContents.scripts)) { parts.push(`\n--- ${name} ---`); parts.push(content.substring(0, 2000)); } } return `以下是场景目录 "${sourceDir}" 的内容:\n\n${parts.join("\n")}\n\n请以 JSON 格式返回:{"sceneId": "...", "sceneName": "..."}`; } function extractJsonFromResponse(text) { const codeBlockMatch = text.match(/```(?:json)?\s*\n([\s\S]*?)\n```/); if (codeBlockMatch) return JSON.parse(codeBlockMatch[1]); const jsonMatch = text.match( /\{[\s\S]*"sceneId"[\s\S]*"sceneName"[\s\S]*\}/ ); if (jsonMatch) return JSON.parse(jsonMatch[0]); return JSON.parse(text); } function analyzeScene(sourceDir, dirContents, { apiKey, baseUrl, model }) { const userPrompt = buildAnalyzePrompt(sourceDir, dirContents); const requestBody = JSON.stringify({ model, messages: [ { role: "system", content: SYSTEM_PROMPT }, { role: "user", content: userPrompt }, ], temperature: 0.1, max_tokens: 256, }); return new Promise((resolve, reject) => { const url = new URL(baseUrl.replace(/\/v1\/?$/, "") + "/v1/chat/completions"); const options = { hostname: url.hostname, port: url.port || (url.protocol === "https:" ? 443 : 80), path: url.pathname, method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}`, "Content-Length": Buffer.byteLength(requestBody), }, }; const req = http.request(options, (res) => { let data = ""; res.on("data", (chunk) => (data += chunk)); res.on("end", () => { if (res.statusCode !== 200) { return reject(new Error(`LLM API error ${res.statusCode}: ${data}`)); } try { const parsed = JSON.parse(data); const content = parsed.choices?.[0]?.message?.content; if (!content) return reject(new Error("LLM returned empty response")); const result = extractJsonFromResponse(content); if (!result.sceneId || !result.sceneName) { return reject( new Error(`LLM response missing sceneId/sceneName: ${content}`) ); } resolve(result); } catch (err) { reject(new Error(`Failed to parse LLM response: ${err.message}`)); } }); }); req.on("error", reject); req.setTimeout(30000, () => { req.destroy(new Error("LLM API request timed out")); }); req.write(requestBody); req.end(); }); } module.exports = { buildAnalyzePrompt, extractJsonFromResponse, analyzeScene }; ``` - [ ] **Step 4: Run test to verify it passes** Run: `node tests/scene_generator_llm_test.js` Expected: PASS (both tests) - [ ] **Step 5: Commit** ```bash git add frontend/scene-generator/llm-client.js tests/scene_generator_llm_test.js git commit -m "feat: add llm-client.js with prompt builder and JSON extractor" ``` --- ### Task 3: Create generator-runner.js **Files:** - Create: `frontend/scene-generator/generator-runner.js` - [ ] **Step 1: Create generator-runner.js** ```javascript const { spawn } = require("child_process"); const path = require("path"); function runGenerator(params, sseWriter, projectRoot) { const { sourceDir, sceneId, sceneName, outputRoot, lessons } = params; const normalize = (p) => p.replace(/\\/g, "/"); const args = [ "run", "--bin", "sg_scene_generate", "--", "--source-dir", normalize(sourceDir), "--scene-id", sceneId, "--scene-name", sceneName, "--output-root", normalize(outputRoot), "--lessons", normalize(lessons), ]; return new Promise((resolve, reject) => { sseWriter.write( `event: status\ndata: ${JSON.stringify({ message: "开始生成 skill 包...", })}\n\n` ); sseWriter.write( `event: status\ndata: ${JSON.stringify({ message: `执行: cargo ${args.join(" ")}`, })}\n\n` ); const child = spawn("cargo", args, { cwd: projectRoot, stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, RUST_BACKTRACE: "1" }, }); let stdout = ""; let stderr = ""; let timedOut = false; const timeout = setTimeout(() => { timedOut = true; child.kill("SIGTERM"); sseWriter.write( `event: error\ndata: ${JSON.stringify({ message: "生成超时(5分钟)", })}\n\n` ); resolve({ success: false, error: "timeout" }); }, 5 * 60 * 1000); child.stdout.on("data", (data) => { const text = data.toString(); stdout += text; sseWriter.write( `event: log\ndata: ${JSON.stringify({ message: text.trim() })}\n\n` ); }); child.stderr.on("data", (data) => { const text = data.toString(); stderr += text; sseWriter.write( `event: log\ndata: ${JSON.stringify({ message: text.trim() })}\n\n` ); }); child.on("close", (code) => { clearTimeout(timeout); if (timedOut) return; if (code === 0) { const match = stdout.match(/generated scene package:\s*(.+)/); const skillRoot = match ? match[1] : null; sseWriter.write( `event: status\ndata: ${JSON.stringify({ message: "✅ 生成成功", })}\n\n` ); sseWriter.write( `event: complete\ndata: ${JSON.stringify({ success: true, skillRoot, })}\n\n` ); resolve({ success: true, skillRoot }); } else { sseWriter.write( `event: error\ndata: ${JSON.stringify({ message: `生成失败 (exit code ${code})`, })}\n\n` ); if (stderr.trim()) { sseWriter.write( `event: error\ndata: ${JSON.stringify({ message: stderr.substring(0, 500), })}\n\n` ); } resolve({ success: false, code, stderr }); } }); child.on("error", (err) => { clearTimeout(timeout); sseWriter.write( `event: error\ndata: ${JSON.stringify({ message: `无法启动 cargo: ${err.message}`, })}\n\n` ); reject(err); }); }); } function readDirectory(sourceDir) { const fs = require("fs"); const p = require("path"); if (!fs.existsSync(sourceDir)) { throw new Error(`Directory not found: ${sourceDir}`); } const stat = fs.statSync(sourceDir); if (!stat.isDirectory()) { throw new Error(`Not a directory: ${sourceDir}`); } const result = {}; const entries = fs.readdirSync(sourceDir, { withFileTypes: true }); const treeLines = []; for (const entry of entries) { treeLines.push(`├── ${entry.name}`); } result.tree = treeLines.join("\n"); const sceneTomlPath = p.join(sourceDir, "scene.toml"); if (fs.existsSync(sceneTomlPath)) { result["scene.toml"] = fs.readFileSync(sceneTomlPath, "utf-8"); } const skillTomlPath = p.join(sourceDir, "SKILL.toml"); if (fs.existsSync(skillTomlPath)) { result["SKILL.toml"] = fs.readFileSync(skillTomlPath, "utf-8"); } const skillMdPath = p.join(sourceDir, "SKILL.md"); if (fs.existsSync(skillMdPath)) { result["SKILL.md"] = fs.readFileSync(skillMdPath, "utf-8"); } const scripts = {}; for (const entry of entries) { if (entry.isFile() && entry.name.endsWith(".js")) { const scriptPath = p.join(sourceDir, entry.name); scripts[entry.name] = fs.readFileSync(scriptPath, "utf-8"); } } if (Object.keys(scripts).length > 0) { result.scripts = scripts; } return result; } module.exports = { runGenerator, readDirectory }; ``` - [ ] **Step 2: Commit** ```bash git add frontend/scene-generator/generator-runner.js git commit -m "feat: add generator-runner.js for cargo subprocess + SSE streaming" ``` --- ### Task 4: Create server.js **Files:** - Create: `frontend/scene-generator/server.js` - Reference: `frontend/scene-generator/config-loader.js` - Reference: `frontend/scene-generator/llm-client.js` - Reference: `frontend/scene-generator/generator-runner.js` - [ ] **Step 1: Create server.js** ```javascript #!/usr/bin/env node "use strict"; const http = require("http"); const fs = require("fs"); const path = require("path"); const { loadConfig, getDefaults } = require("./config-loader"); const { analyzeScene } = require("./llm-client"); const { runGenerator, readDirectory } = require("./generator-runner"); let config; let defaults; try { config = loadConfig(); defaults = getDefaults(); console.log(`[config] Loaded from: ${config.configPath}`); console.log(`[config] Project root: ${config.projectRoot}`); } catch (err) { console.error(`[error] Failed to load config: ${err.message}`); process.exit(1); } const PORT = parseInt(process.env.SG_SCENE_GENERATOR_PORT, 10) || 3210; const HOST = "127.0.0.1"; const MIME_TYPES = { ".html": "text/html; charset=utf-8", ".js": "application/javascript", ".css": "text/css", ".json": "application/json", }; function serveStatic(res, filePath) { const ext = path.extname(filePath); const contentType = MIME_TYPES[ext] || "application/octet-stream"; fs.readFile(filePath, (err, data) => { if (err) { res.writeHead(404); res.end("Not found"); return; } res.writeHead(200, { "Content-Type": contentType }); res.end(data); }); } function initSSE(res) { res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive", "Access-Control-Allow-Origin": "*", }); res.write(":\n"); return res; } function writeSSE(res, event, data) { res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); } function parseBody(req) { return new Promise((resolve, reject) => { let body = ""; req.on("data", (chunk) => (body += chunk)); req.on("end", () => { try { resolve(JSON.parse(body)); } catch (err) { reject(new Error("Invalid JSON")); } }); req.on("error", reject); }); } async function handleAnalyze(req, res) { let body; try { body = await parseBody(req); } catch { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Invalid JSON body" })); return; } const sourceDir = (body.sourceDir || "").replace(/\\/g, "/"); if (!sourceDir) { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "sourceDir is required" })); return; } let dirContents; try { dirContents = readDirectory(sourceDir); } catch (err) { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: err.message })); return; } try { const result = await analyzeScene(sourceDir, dirContents, config); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify(result)); } catch (err) { res.writeHead(502, { "Content-Type": "application/json" }); res.end( JSON.stringify({ error: `LLM analysis failed: ${err.message}`, hint: "You can still enter scene-id and scene-name manually", }) ); } } async function handleGenerate(req, res) { let body; try { body = await parseBody(req); } catch { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Invalid JSON body" })); return; } const { sourceDir, sceneId, sceneName, outputRoot, lessons } = body; if (!sourceDir || !sceneId || !sceneName || !outputRoot || !lessons) { res.writeHead(400, { "Content-Type": "application/json" }); res.end( JSON.stringify({ error: "All fields required: sourceDir, sceneId, sceneName, outputRoot, lessons", }) ); return; } const sseWriter = initSSE(res); try { await runGenerator( { sourceDir, sceneId, sceneName, outputRoot, lessons }, sseWriter, config.projectRoot ); } catch (err) { writeSSE(sseWriter, "error", { message: `Server error: ${err.message}` }); } sseWriter.end(); } function handleHealth(req, res) { res.writeHead(200, { "Content-Type": "application/json" }); res.end( JSON.stringify({ status: "ok", pid: process.pid, configLoaded: true, configPath: config.configPath, projectRoot: config.projectRoot, }) ); } const server = http.createServer(async (req, res) => { const url = new URL(req.url, `http://${req.headers.host}`); const pathname = url.pathname; if (req.method === "OPTIONS") { res.writeHead(204, { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, POST, OPTIONS", "Access-Control-Allow-Headers": "Content-Type", }); res.end(); return; } try { if (pathname === "/health" && req.method === "GET") { handleHealth(req, res); } else if (pathname === "/analyze" && req.method === "POST") { await handleAnalyze(req, res); } else if (pathname === "/generate" && req.method === "POST") { await handleGenerate(req, res); } else if (pathname === "/" || pathname === "/index.html") { serveStatic(res, path.join(__dirname, "sg_scene_generator.html")); } else { const filePath = path.join(__dirname, pathname); if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) { serveStatic(res, filePath); } else { res.writeHead(404, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Not found" })); } } } catch (err) { console.error(`[error] ${req.method} ${pathname}: ${err.message}`); if (!res.headersSent) { res.writeHead(500, { "Content-Type": "application/json" }); } res.end(JSON.stringify({ error: err.message })); } }); server.listen(PORT, HOST, () => { console.log(""); console.log(" ╔══════════════════════════════════════════════════╗"); console.log(" ║ sgClaw · Scene Skill Generator ║"); console.log(" ╠══════════════════════════════════════════════════╣"); console.log(" ║ ║"); console.log(` ║ 访问地址: http://${HOST}:${PORT}/ ║`); console.log(" ║ ║"); console.log(" ║ 按 Ctrl+C 停止服务 ║"); console.log(" ╚══════════════════════════════════════════════════╝"); console.log(""); }); process.on("SIGINT", () => { console.log("\n[info] Shutting down..."); server.close(() => process.exit(0)); }); ``` - [ ] **Step 2: Verify Rust test passes** Run: `cargo test scene_generator_server_files_exist --test scene_generator_server_test -- --nocapture` Expected: PASS - [ ] **Step 3: Commit** ```bash git add frontend/scene-generator/server.js git commit -m "feat: add server.js with /health, /analyze, /generate routes" ``` --- ### Task 5: Create sg_scene_generator.html **Files:** - Create: `frontend/scene-generator/sg_scene_generator.html` - Create: `tests/scene_generator_html_test.rs` - Reference: `frontend/service-console/sg_claw_service_console.html` - [ ] **Step 1: Write the failing HTML test** Create `tests/scene_generator_html_test.rs`: ```rust use std::fs; use std::path::PathBuf; #[test] fn scene_generator_html_exists_and_has_required_elements() { let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); let html_path = manifest_dir .join("frontend") .join("scene-generator") .join("sg_scene_generator.html"); let source = fs::read_to_string(&html_path) .unwrap_or_else(|err| panic!("HTML file not found at {:?}: {}", html_path, err)); assert!(source.contains("场景 Skill 生成器"), "missing title"); assert!(source.contains("sourceDir"), "missing sourceDir input"); assert!(source.contains("sceneId"), "missing sceneId input"); assert!(source.contains("sceneName"), "missing sceneName input"); assert!(source.contains("/analyze"), "missing /analyze endpoint"); assert!(source.contains("/generate"), "missing /generate endpoint"); assert!( source.contains("fetch("), "missing fetch for API calls" ); assert!( source.contains("127.0.0.1") || source.contains("localhost"), "should reference localhost server" ); } ``` - [ ] **Step 2: Run test to verify it fails** Run: `cargo test scene_generator_html_exists_and_has_required_elements --test scene_generator_html_test -- --nocapture` Expected: FAIL with "HTML file not found" - [ ] **Step 3: Create the HTML file** Create `frontend/scene-generator/sg_scene_generator.html` — the complete self-contained page with inline CSS and JavaScript, following the service-console design pattern with glass-morphism panels, dual-column layout, settings modal, and SSE-based streaming. (Full content provided in the design spec at `docs/superpowers/specs/2026-04-16-scene-skill-generator-design.md`) - [ ] **Step 4: Run HTML test to verify it passes** Run: `cargo test scene_generator_html_exists_and_has_required_elements --test scene_generator_html_test -- --nocapture` Expected: PASS - [ ] **Step 5: Commit** ```bash git add frontend/scene-generator/sg_scene_generator.html tests/scene_generator_html_test.rs git commit -m "feat: add sg_scene_generator.html with dual-panel UI and settings modal" ``` --- ### Task 6: Create startup scripts **Files:** - Create: `frontend/scene-generator/serve.sh` - Create: `frontend/scene-generator/serve.cmd` - [ ] **Step 1: Create serve.sh** ```bash #!/bin/bash # ============================================================ # sgClaw Scene Skill Generator — HTTP 服务启动脚本 # # 用法: # ./serve.sh # 默认 3210 端口 # ./serve.sh 9090 # 指定端口 # ============================================================ set -e PORT="${1:-3210}" DIR="$(cd "$(dirname "$0")" && pwd)" cd "$DIR" get_ip() { ip -4 addr show 2>/dev/null \ | grep -oP 'inet \K[\d.]+' \ | grep -v '127.0.0.1' \ | head -1 } LOCAL_IP=$(get_ip) if [ -z "$LOCAL_IP" ]; then LOCAL_IP=$(hostname -I 2>/dev/null | awk '{print $1}') fi if [ -z "$LOCAL_IP" ]; then LOCAL_IP="<本机IP>" fi echo "" echo " ╔══════════════════════════════════════════════════╗" echo " ║ sgClaw · Scene Skill Generator ║" echo " ╠══════════════════════════════════════════════════╣" echo " ║ ║" echo " ║ 本机访问: http://127.0.0.1:${PORT}/ ║" echo " ║ 局域网访问: http://${LOCAL_IP}:${PORT}/ ║" echo " ║ ║" echo " ║ 按 Ctrl+C 停止服务 ║" echo " ╚══════════════════════════════════════════════════╝" echo "" export SG_SCENE_GENERATOR_PORT="$PORT" node server.js ``` - [ ] **Step 2: Create serve.cmd** ```batch @echo off setlocal set PORT=%1 if "%PORT%"=="" set PORT=3210 set SG_SCENE_GENERATOR_PORT=%PORT% echo. echo +==================================================+ echo ^| sgClaw ^· Scene Skill Generator ^| echo +==================================================+ echo ^| ^| echo ^| 访问地址: http://127.0.0.1:%PORT%/ ^| echo ^| ^| echo ^| 按 Ctrl+C 停止服务 ^| echo +==================================================+ echo. cd /d "%~dp0" node server.js ``` - [ ] **Step 3: Commit** ```bash git add frontend/scene-generator/serve.sh frontend/scene-generator/serve.cmd git commit -m "feat: add serve.sh and serve.cmd startup scripts" ``` --- ### Task 7: End-to-end smoke test **Files:** - Reference: All files created above - [ ] **Step 1: Start server and verify health endpoint** In terminal 1: ```bash cd frontend/scene-generator && node server.js ``` In terminal 2: ```bash curl http://127.0.0.1:3210/health ``` Expected: `{"status":"ok","pid":...,"configLoaded":true,"configPath":"...","projectRoot":"..."}` - [ ] **Step 2: Run all Rust tests** ```bash cargo test scene_generator --test scene_generator_server_test -- --nocapture cargo test scene_generator --test scene_generator_html_test -- --nocapture ``` Expected: All PASS - [ ] **Step 3: Run LLM unit tests** ```bash node tests/scene_generator_llm_test.js ``` Expected: PASS (both tests) - [ ] **Step 4: Verify HTML page loads** Open browser to `http://127.0.0.1:3210/` Expected: Page renders with title "场景 Skill 生成器", all input fields visible - [ ] **Step 5: Final commit** ```bash git add -A git commit -m "feat: scene skill generator — complete e2e implementation" ``` --- ## Self-Review ### 1. Spec Coverage | Spec Requirement | Task | |------------------|------| | HTML page with glass-morphism, dual-panel layout | Task 5 | | Path input (not file picker) | Task 5 | | Auto-extract scene-id/name via LLM | Task 2, Task 4 | | Settings modal (output, lessons, LLM) | Task 5 | | Generate button → sg_scene_generate | Task 3, Task 4, Task 5 | | Real-time SSE progress | Task 3, Task 5 | | Zero npm dependencies | Tasks 1-7 | | Read sgclaw_config.json | Task 1 | | projectRoot resolution | Task 1 | | Windows compatibility | Task 6 | | localhost-only binding | Task 4 | | API key not leaked | Tasks 1, 4 | | Path validation | Task 3 | | 5-min subprocess timeout | Task 3 | | Health endpoint | Task 4 | | Tests for files + HTML | Tasks 1, 5 | All covered. ### 2. Placeholder Scan No TBD/TODO/"implement later"/"add tests"/"similar to" patterns found. ### 3. Type Consistency - `/analyze`: `{ sourceDir }` → `{ sceneId, sceneName }` — consistent in Tasks 2, 4, 5 - `/generate`: `{ sourceDir, sceneId, sceneName, outputRoot, lessons }` — consistent in Tasks 3, 4, 5 - SSE events: `status`, `log`, `complete`, `error` — consistent in Tasks 3, 5 - Config: `apiKey`, `baseUrl`, `model`, `projectRoot` — consistent in Tasks 1, 4 - Path normalization `\` → `/` — consistent in Tasks 3, 5 All consistent.