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

33 KiB
Raw Blame History

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:

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:

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
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:

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
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
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

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
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

#!/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
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:

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
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

#!/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
@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
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:

cd frontend/scene-generator && node server.js

In terminal 2:

curl http://127.0.0.1:3210/health

Expected: {"status":"ok","pid":...,"configLoaded":true,"configPath":"...","projectRoot":"..."}

  • Step 2: Run all Rust tests
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
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
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.