Files
claw/docs/superpowers/plans/2026-04-16-scene-skill-generator.md
木炎 ea6be128e7 feat: scene skill generator — complete implementation
Adds a web-based UI for generating scene skill packages:
- Node.js HTTP server (zero npm dependencies) on port 3210
- HTML page with glass-morphism UI, dual-panel layout, settings modal
- LLM-powered scene-id/scene-name auto-extraction from directory contents
- Real-time SSE progress streaming during skill generation
- Spawns sg_scene_generate CLI with configurable parameters
- Windows-compatible startup scripts (serve.sh + serve.cmd)
- Rust integration tests for server files and HTML structure

Architecture:
  Browser (HTML/JS) → Node.js server → LLM API + cargo run → sg_scene_generate

Files:
  frontend/scene-generator/{server.js,config-loader.js,llm-client.js,generator-runner.js,sg_scene_generator.html,serve.sh,serve.cmd}
  tests/{scene_generator_server_test.rs,scene_generator_html_test.rs,scene_generator_llm_test.js}
  docs/superpowers/{plans,specs}/2026-04-16-scene-skill-generator*

🤖 Generated with [Qoder][https://qoder.com]
2026-04-16 22:27:41 +08:00

1122 lines
33 KiB
Markdown
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.
# 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.