diff --git a/docs/superpowers/plans/2026-04-16-scene-skill-generator.md b/docs/superpowers/plans/2026-04-16-scene-skill-generator.md new file mode 100644 index 0000000..e5ed0bd --- /dev/null +++ b/docs/superpowers/plans/2026-04-16-scene-skill-generator.md @@ -0,0 +1,1121 @@ +# 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. diff --git a/docs/superpowers/specs/2026-04-16-scene-skill-generator-design.md b/docs/superpowers/specs/2026-04-16-scene-skill-generator-design.md new file mode 100644 index 0000000..5f828c5 --- /dev/null +++ b/docs/superpowers/specs/2026-04-16-scene-skill-generator-design.md @@ -0,0 +1,417 @@ +# Scene Skill Generator — Design Document + +> **Date:** 2026-04-16 +> **Status:** Draft — awaiting review +> **Author:** Qoder + +--- + +## 1. Goal + +提供一个可视化界面,让用户选择场景目录后,自动通过大模型提取 scene-id 和 scene-name,配置输出路径和 lessons 文件,一键调用 `sg_scene_generate` 生成完整的 skill 包,并实时查看生成日志。 + +--- + +## 2. Architecture + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ sg_scene_generator.html (浏览器) │ +│ ┌───────────────────┐ ┌───────────────────────────────┐ │ +│ │ 左侧:操作面板 │ │ 右侧:实时日志流 │ │ +│ │ │ │ │ │ +│ │ 📂 选择场景目录 │───────│ [状态卡片 + 实时滚动日志] │ │ +│ │ │ │ │ │ +│ │ 自动填充字段: │ │ 分析场景目录... │ │ +│ │ - scene-id │ │ 调用大模型提取场景信息... │ │ +│ │ - scene-name │ │ scene-id: tq-lineloss-report │ │ +│ │ │ │ scene-name: 台区线损报表 │ │ +│ │ 可编辑字段: │ │ 生成 skill 包... │ │ +│ │ - 输出根路径 │ │ 写入 SKILL.toml... │ │ +│ │ - lessons 路径 │ │ 写入 browser_script... │ │ +│ │ │ │ ✅ 生成完成 │ │ +│ │ [⚙ 设置] │ └───────────────────────────────┘ │ +│ │ [🚀 生成 Skill] │ │ +│ └───────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ + │ + │ 1. POST /analyze (选择目录后自动触发) + │ → 发送目录路径 + 文件内容 + │ 2. SSE /generate (点击生成按钮后触发) + │ → 推送实时进度 + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ server.js (Node.js, 默认端口 3210) │ +│ │ +│ POST /analyze │ +│ 1. 读取 source-dir 下的关键文件 │ +│ - scene.toml (如果存在) │ +│ - *.js 脚本文件 │ +│ - SKILL.md / SKILL.toml (如果存在) │ +│ - 目录结构树 │ +│ 2. 构造 prompt,调用 LLM API │ +│ - baseUrl + apiKey + model 来自 sgclaw_config.json │ +│ 3. 返回 JSON: { sceneId, sceneName } │ +│ │ +│ POST /generate │ +│ 1. 接收 { sourceDir, sceneId, sceneName, outputRoot, lessons } │ +│ 2. spawn: cargo run --bin sg_scene_generate \ │ +│ --source-dir \ │ +│ --scene-id \ │ +│ --scene-name \ │ +│ --output-root \ │ +│ --lessons │ +│ 3. 通过 SSE 实时推送 stdout/stderr │ +│ 4. 推送完成/失败事件 │ +│ │ +│ GET /health │ +│ → { status: "ok", pid: 12345 } │ +│ │ +│ GET / │ +│ → 服务 sg_scene_generator.html 静态文件 │ +└─────────────────────────────────────────────────────────────────┘ + │ + │ LLM API (OpenAI-compatible format) + │ POST {baseUrl}/v1/chat/completions + ▼ +┌──────────────────────┐ +│ LLM (DeepSeek) │ +│ │ +│ System: 你是一个场景 │ +│ 信息提取助手... │ +│ User: 以下是场景目录 │ +│ 内容... 请提取 │ +│ scene-id 和 │ +│ scene-name │ +└──────────────────────┘ +``` + +--- + +## 3. File Map + +### 新建文件 + +| 文件 | 说明 | +|------|------| +| `frontend/scene-generator/sg_scene_generator.html` | 主页面,内联 CSS + JS,复用 service-console 设计风格 | +| `frontend/scene-generator/server.js` | Node.js 轻量 HTTP 服务器(零外部依赖) | +| `frontend/scene-generator/serve.sh` | 一键启动脚本(Windows 兼容) | +| `frontend/scene-generator/serve.cmd` | Windows 一键启动脚本 | +| `frontend/scene-generator/config-loader.js` | 读取并解析 `sgclaw_config.json` | +| `frontend/scene-generator/llm-client.js` | 封装 LLM API 调用(OpenAI-compatible 格式) | +| `frontend/scene-generator/generator-runner.js` | 封装 `sg_scene_generate` 子进程调用 + SSE 推送 | + +### 引用文件(不修改) + +| 文件 | 用途 | +|------|------| +| `src/bin/sg_scene_generate.rs` | 被 server.js 通过 `cargo run` 调用 | +| `src/generated_scene/generator.rs` | 理解生成逻辑和输出结构 | +| `sgclaw_config.json` | 读取 LLM 连接配置(apiKey, baseUrl, model) | +| `docs/superpowers/references/tq-lineloss-lessons-learned.toml` | 默认 lessons 路径 | +| `frontend/service-console/sg_claw_service_console.html` | UI 风格参考 | + +--- + +## 4. UI Design + +### 4.1 整体布局 + +复用 service-console 的双栏布局: + +- **外层容器 (`.shell`)**:圆角玻璃拟态面板,与 service-console 共享 CSS 变量 +- **顶部 (`.hero`)**:标题 "场景 Skill 生成器" + 简短说明 +- **内容区 (`.content`)**:`grid` 双栏,左侧操作面板 + 右侧日志流 + +### 4.2 左侧操作面板 + +#### 场景目录选择区 + +``` +📂 场景目录 +[ 粘贴或输入路径 ____________________________ ] [ 浏览 📁 ] +当前:D:\data\ideaSpace\rust\sgClaw\claw-new\examples\generated_scene_platform\scenarios\tq-lineloss-report +``` + +使用文本输入框 + "浏览" 按钮。点击 "浏览" 时,前端调用 `POST /browse`,由 Node.js 弹出系统目录选择对话框(通过 `electron` 风格的 `open-dialog` 不可行 — 改为**用户在输入框中粘贴/输入路径**,服务端通过 `fs.stat` 校验路径合法性)。 + +为简化实现,采用更务实的方案: +- 主输入框:用户粘贴或手动输入场景目录的**绝对路径** +- 输入路径后按回车或点击 "分析" 按钮,触发 `/analyze` 请求 +- 服务端通过 `fs.statSync(sourceDir).isDirectory()` 校验路径 + +**可选增强**:如果 Node.js 安装了 `electron`,可通过 `dialog.showOpenDialog` 弹出系统选择框,但这会增加依赖。默认不采用。 + +#### 自动提取结果(只读展示,可手动修正) + +``` +scene-id +tq-lineloss-report + +scene-name +台区线损报表 +``` + +分析中显示 loading 状态,分析失败时可手动输入。 + +#### 设置按钮 + +点击弹出模态框,包含以下字段: + +| 字段 | 默认值 | 说明 | +|------|--------|------| +| 输出根路径 | `D:/data/ideaSpace/rust/sgClaw/claw-new/examples/generated_scene_platform` | skill 包输出根目录,实际输出到 `/skills//` | +| Lessons 路径 | `D:/data/ideaSpace/rust/sgClaw/claw-new/docs/superpowers/references/tq-lineloss-lessons-learned.toml` | lessons TOML 文件路径 | +| LLM 服务地址 | 来自 `sgclaw_config.json` 的 `baseUrl` | 可覆盖 | +| LLM 模型 | 来自 `sgclaw_config.json` 的 `model` | 可覆盖 | +| Node 服务端口 | `3210` | server.js 监听端口 | + +#### 生成按钮 + +``` +[ 🚀 生成 Skill ] (disabled 直到选择了目录且提取完成) +``` + +### 4.3 右侧日志流 + +与 service-console 一致的流式日志展示: + +- **空状态**:显示提示 "选择场景目录开始生成" +- **status 行**:关键阶段标记("开始分析", "提取完成", "开始生成", "生成成功") +- **log 行**:cargo run 的 stdout 输出 +- **error 行**:stderr 输出或错误信息 +- **complete 行**:最终结果,包含生成的 skill 包路径 + +### 4.4 状态卡片 + +左侧面板顶部显示当前状态: + +``` +[●] 就绪 / 分析中 / 生成中 / 完成 / 错误 +``` + +颜色编码: +- 就绪:灰色 +- 分析中:橙色 +- 生成中:青色(accent) +- 完成:绿色 +- 错误:红色 + +--- + +## 5. API Design + +### 5.1 POST /analyze + +**请求体:** + +```json +{ + "sourceDir": "D:/data/ideaSpace/rust/sgClaw/claw-new/examples/generated_scene_platform/scenarios/tq-lineloss-report" +} +``` + +服务端自行读取目录内容: +- 校验路径是否存在且为目录 +- 读取 `scene.toml`(如果存在) +- 读取 `*.js` 脚本文件 +- 读取 `SKILL.md` / `SKILL.toml`(如果存在) +- 生成目录结构树 + +**响应:** + +```json +{ + "sceneId": "tq-lineloss-report", + "sceneName": "台区线损报表" +} +``` + +**LLM Prompt 设计:** + +``` +System: 你是一个场景信息提取助手。根据场景目录的内容,提取 scene-id 和 scene-name。 + +scene-id 规则: +- 使用英文短横线连接,如 tq-lineloss-report +- 全小写,有业务含义 + +scene-name 规则: +- 使用中文,简短描述性名称 +- 如 "台区线损报表"、"知乎热榜导出" + +User: 以下是场景目录的内容: + +=== scene.toml === +[scene content here] + +=== 脚本文件 === +[script content here] + +=== 目录结构 === +[file tree here] + +请以 JSON 格式返回:{"sceneId": "...", "sceneName": "..."} +``` + +### 5.2 POST /generate (SSE) + +**请求体:** + +```json +{ + "sourceDir": "/path/to/scenario/dir", + "sceneId": "tq-lineloss-report", + "sceneName": "台区线损报表", + "outputRoot": "/path/to/output/root", + "lessons": "/path/to/lessons.toml" +} +``` + +**SSE 事件流:** + +``` +event: status +data: {"message": "开始生成 skill 包..."} + +event: status +data: {"message": "调用 sg_scene_generate..."} + +event: log +data: {"message": "generated scene package: ..."} + +event: complete +data: {"success": true, "skillRoot": "/path/to/skills/tq-lineloss-report"} + +或 + +event: error +data: {"message": "生成失败: ..."} +``` + +### 5.3 GET /health + +**响应:** + +```json +{ + "status": "ok", + "pid": 12345, + "configLoaded": true, + "configPath": "D:/data/ideaSpace/rust/sgClaw/sgclaw_config.json" +} +``` + +--- + +## 6. Server Design (server.js) + +### 6.1 模块结构 + +``` +server.js — HTTP 路由入口,SSE 连接管理 +config-loader.js — 读取 sgclaw_config.json,暴露 LLM 配置 + projectRoot +llm-client.js — 调用 LLM API,返回 JSON 提取结果 +generator-runner.js — spawn 子进程,通过 SSE 推送输出 +``` + +### 6.1.1 projectRoot 配置 + +`cargo run --bin sg_scene_generate` 需要在项目根目录下执行。`projectRoot` 的确定优先级: + +1. 环境变量 `SGCLAW_PROJECT_ROOT`(最高优先级) +2. `sgclaw_config.json` 同级目录(常见情况:配置文件在项目根目录) +3. 启动脚本所在目录 + +### 6.2 零依赖原则 + +仅使用 Node.js 内置模块: +- `http` — HTTP 服务器 +- `fs` — 文件读取 +- `path` — 路径处理 +- `child_process` — 子进程调用 +- `events` — 事件发射 + +### 6.3 启动流程 + +``` +1. 读取 sgclaw_config.json (路径通过环境变量 SGCLAW_CONFIG_PATH 或默认 ../sgclaw_config.json) +2. 验证必需字段: apiKey, baseUrl, model +3. 启动 HTTP 服务器,监听 0.0.0.0:3210 +4. 打印启动信息,包含访问地址 +``` + +### 6.4 错误处理 + +| 场景 | 处理方式 | +|------|----------| +| sgclaw_config.json 不存在 | 启动失败,提示用户设置环境变量 | +| LLM API 调用失败 | 返回 502 + 错误信息,前端允许手动输入 | +| cargo run 失败 | SSE 推送 error 事件,显示 stderr | +| source-dir 不存在 | 返回 400 | +| 端口被占用 | 启动失败,提示更换端口 | + +--- + +## 7. Security Considerations + +1. **仅监听 localhost**:server.js 默认绑定 `127.0.0.1`,不暴露到外部网络 +2. **API Key 不暴露给前端**:LLM API 调用完全在 Node.js 服务端完成,前端不接触 API Key +3. **路径校验**:`sourceDir` 和 `outputRoot` 需做基本路径合法性检查,防止路径遍历攻击 +4. **子进程超时**:`cargo run` 设置 5 分钟超时,防止挂起 + +--- + +## 8. Default Configuration + +| 配置项 | 默认值 | 来源 | +|--------|--------|------| +| LLM apiKey | `sgclaw_config.json` 中的 `apiKey` | 启动时读取 | +| LLM baseUrl | `sgclaw_config.json` 中的 `baseUrl` | 启动时读取 | +| LLM model | `sgclaw_config.json` 中的 `model` | 启动时读取 | +| 默认 lessons 路径 | `docs/superpowers/references/tq-lineloss-lessons-learned.toml` | 项目约定 | +| 默认输出根路径 | `examples/generated_scene_platform` | 项目约定 | +| Node 服务端口 | `3210` | 硬编码,可配置 | + +--- + +## 9. User Flow + +``` +1. 用户运行 bash serve.sh (或 node server.js) +2. 浏览器打开 http://127.0.0.1:3210 +3. 页面加载,显示 "就绪" 状态 +4. 用户在 "场景目录" 输入框中粘贴或输入绝对路径 +5. 用户点击 "分析" 按钮(或输入框回车),触发 /analyze 请求 +6. server.js 读取目录内容,调用 LLM 提取 scene-id/name +7. 页面自动填充 scene-id 和 scene-name 字段 +8. 用户确认/修改字段,点击 "设置" 检查输出路径和 lessons +9. 用户点击 "生成 Skill" +10. server.js 通过 SSE 推送实时进度 +11. 页面右侧日志流展示生成过程 +12. 生成完成,显示 skill 包路径 +13. 用户可前往输出目录查看生成的 skill +``` + +--- + +## 10. Windows Compatibility + +由于目标平台是 Windows: + +- `serve.sh` 同时提供 `serve.cmd` 替代方案 +- 路径分隔符统一使用 `/`(Node.js `path` 模块自动处理) +- `cargo run` 命令在 Windows 上同样可用 +- 路径输入框支持 Windows 格式路径(如 `D:\data\ideaSpace\...`) +- 服务端自动将 `\` 转换为 `/` 以兼容 Rust CLI 参数 + +--- + +## 11. Future Extensions (Not in Scope) + +- 批量生成:一次选择多个场景目录 +- 生成后自动注册到 scene.toml manifest +- 生成后自动运行 skill 测试 +- 历史记录:保存之前的生成记录 +- 生成参数模板:保存常用的输出路径/lessons 组合