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