41 KiB
LLM-Driven Skill Generation 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: Enhance sg_scene_generate to generate complete, runnable skill packages instead of skeleton code by deeply analyzing scene source code (index.html) with LLM to extract API endpoints, static params, column definitions, and business logic.
Architecture:
- LLM reads
index.htmlfrom scene directory - Extracts complete SceneInfo (sceneId, sceneName, apiEndpoints, staticParams, columnDefs, businessLogic)
- Web UI shows preview for user confirmation
- Rust CLI receives extracted info via
--scene-info-jsonparameter - Rust template renders complete browser_script with business logic
Tech Stack: JavaScript (Node.js), Rust, HTML/CSS, OpenAI-compatible LLM API
Scope Check
This plan covers the enhancement of existing scene skill generator to support LLM-driven deep extraction. It builds upon:
- Existing
frontend/scene-generator/files (server.js, llm-client.js, generator-runner.js) - Existing
src/generated_scene/generator.rsandsrc/bin/sg_scene_generate.rs
File Map
Modified Files
| File | Changes |
|---|---|
frontend/scene-generator/llm-client.js |
Add deep extraction prompt + analyzeSceneDeep() |
frontend/scene-generator/generator-runner.js |
Add index.html reading in readDirectory() |
frontend/scene-generator/server.js |
New /analyze-deep route, pass sceneInfo to generator |
src/bin/sg_scene_generate.rs |
Add --scene-info-json CLI parameter |
src/generated_scene/generator.rs |
Add SceneInfo struct, enhanced template rendering |
frontend/scene-generator/sg_scene_generator.html |
Add extraction preview UI |
Reference Files (not modified)
| File | Purpose |
|---|---|
docs/superpowers/specs/2026-04-17-llm-driven-skill-generation-design.md |
Design spec |
claw/skills/skill_staging/skills/tq-lineloss-report/scripts/collect_tq_lineloss_report.js |
Reference complete script (433 lines) |
claw/skills/skill_staging/skills/marketing-zero-consumer-report/scripts/collect_marketing_zero_consumer_report.js |
Reference skeleton (51 lines) |
Scope Guardrails
- Do not change existing API contracts for backward compatibility
- Do not require
index.htmlto exist (fallback to current behavior) - Do not break existing
--scene-id,--scene-nameCLI arguments - Do not add npm dependencies (only Node.js built-in modules)
Task 1: Enhance llm-client.js with Deep Extraction
Files:
- Modify:
frontend/scene-generator/llm-client.js
Goal: Add a new function analyzeSceneDeep() that reads index.html content and extracts complete SceneInfo including API endpoints, static params, column definitions, and business logic.
- Step 1: Add DEEP_SYSTEM_PROMPT constant
Add after the existing SYSTEM_PROMPT constant in llm-client.js:
const DEEP_SYSTEM_PROMPT = `你是一个场景代码分析专家。分析场景源码,提取关键业务信息。
## 分析目标
1. **API 端点**: 识别所有 HTTP 请求地址 (URL, method, 用途)
2. **静态参数**: 识别硬编码的业务参数 (key-value pairs)
3. **列定义**: 识别数据表格/导出的列配置 ([field, label] pairs)
4. **业务逻辑**: 理解数据获取和转换流程
5. **场景类型**: 判断是 report_collection 还是 monitoring
## 输出格式
请以 JSON 格式返回:
{
"sceneId": "string - 场景标识 (英文短横线)",
"sceneName": "string - 场景中文名",
"sceneKind": "report_collection | monitoring",
"sourceSystem": "string - 来源系统名 (可选)",
"expectedDomain": "string - 目标域名 (可选)",
"targetUrl": "string | null - 目标页面URL",
"apiEndpoints": [
{"name": "string", "url": "string", "method": "GET|POST", "description": "string"}
],
"staticParams": {"key": "value"},
"columnDefs": [["fieldName", "中文列名"]],
"entryMethod": "string - 入口方法名",
"businessLogic": {
"dataFetch": "string - 数据获取逻辑描述",
"dataTransform": "string - 数据转换逻辑描述"
}
}`;
- Step 2: Add buildDeepAnalyzePrompt function
Add after buildAnalyzePrompt function:
function buildDeepAnalyzePrompt(sourceDir, dirContents, indexHtmlContent) {
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"]);
}
// Include index.html content (key addition)
if (indexHtmlContent) {
parts.push(`\n=== index.html ===`);
// Limit to first 15000 chars to avoid token limits
parts.push(indexHtmlContent.substring(0, 15000));
}
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, 3000));
}
}
return `以下是场景目录 "${sourceDir}" 的内容:\n\n${parts.join("\n")}\n\n请分析以上代码,提取完整的场景信息。`;
}
- Step 3: Add extractSceneInfo function
Add after extractJsonFromResponse function:
function extractSceneInfo(text) {
// Try code block first
const codeBlockMatch = text.match(/```(?:json)?\s*\n([\s\S]*?)\n```/);
if (codeBlockMatch) {
try {
return JSON.parse(codeBlockMatch[1]);
} catch (e) {
// fall through
}
}
// Try to find JSON object with sceneId
const jsonMatch = text.match(/\{[\s\S]*"sceneId"[\s\S]*\}/);
if (jsonMatch) {
try {
return JSON.parse(jsonMatch[0]);
} catch (e) {
// fall through
}
}
// Last resort: parse entire text
try {
return JSON.parse(text);
} catch (e) {
throw new Error("Failed to extract valid SceneInfo JSON from LLM response");
}
}
- Step 4: Add analyzeSceneDeep function
Add after analyzeScene function:
function analyzeSceneDeep(sourceDir, dirContents, indexHtmlContent, { apiKey, baseUrl, model }) {
const userPrompt = buildDeepAnalyzePrompt(sourceDir, dirContents, indexHtmlContent);
const requestBody = JSON.stringify({
model,
messages: [
{ role: "system", content: DEEP_SYSTEM_PROMPT },
{ role: "user", content: userPrompt },
],
temperature: 0.1,
max_tokens: 2048, // Increased for detailed response
});
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 httpModule = url.protocol === "https:" ? https : http;
const req = httpModule.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 = extractSceneInfo(content);
// Validate required fields
if (!result.sceneId || !result.sceneName) {
return reject(new Error(`LLM response missing sceneId/sceneName: ${content}`));
}
// Set defaults for optional fields
result.sceneKind = result.sceneKind || "report_collection";
result.apiEndpoints = result.apiEndpoints || [];
result.staticParams = result.staticParams || {};
result.columnDefs = result.columnDefs || [];
result.businessLogic = result.businessLogic || {};
resolve(result);
} catch (err) {
reject(new Error(`Failed to parse LLM response: ${err.message}`));
}
});
});
req.on("error", reject);
req.setTimeout(60000, () => {
req.destroy(new Error("LLM API request timed out"));
});
req.write(requestBody);
req.end();
});
}
- Step 5: Add http module import and update exports
At the top of the file, add http import alongside https:
const http = require("http");
const https = require("https");
Update the exports at the bottom:
module.exports = {
buildAnalyzePrompt,
extractJsonFromResponse,
analyzeScene,
// New exports
buildDeepAnalyzePrompt,
extractSceneInfo,
analyzeSceneDeep,
};
- Step 6: Verify syntax
Run: node -c frontend/scene-generator/llm-client.js
Expected: No syntax errors
- Step 7: Commit
git add frontend/scene-generator/llm-client.js
git commit -m "feat(llm-client): add deep extraction with apiEndpoints, staticParams, columnDefs"
Task 2: Enhance generator-runner.js to Read index.html
Files:
- Modify:
frontend/scene-generator/generator-runner.js
Goal: Modify readDirectory() to also read index.html content.
- Step 1: Add index.html reading in readDirectory function
Locate the readDirectory function and add index.html reading after the SKILL.md section:
// After the SKILL.md reading section, add:
const indexHtmlPath = p.join(sourceDir, "index.html");
if (fs.existsSync(indexHtmlPath)) {
result.indexHtml = fs.readFileSync(indexHtmlPath, "utf-8");
}
The complete modified function should look like:
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");
}
// NEW: Read index.html
const indexHtmlPath = p.join(sourceDir, "index.html");
if (fs.existsSync(indexHtmlPath)) {
result.indexHtml = fs.readFileSync(indexHtmlPath, "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;
}
- Step 2: Verify syntax
Run: node -c frontend/scene-generator/generator-runner.js
Expected: No syntax errors
- Step 3: Commit
git add frontend/scene-generator/generator-runner.js
git commit -m "feat(generator-runner): read index.html in readDirectory()"
Task 3: Add /analyze-deep Route in server.js
Files:
- Modify:
frontend/scene-generator/server.js
Goal: Add new /analyze-deep endpoint that calls the deep extraction LLM function.
- Step 1: Update llm-client import
Change the import line at the top:
const { analyzeScene, analyzeSceneDeep } = require("./llm-client");
- Step 2: Add handleAnalyzeDeep function
Add after the existing handleAnalyze function:
async function handleAnalyzeDeep(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 indexHtmlContent = dirContents.indexHtml || null;
const result = await analyzeSceneDeep(sourceDir, dirContents, indexHtmlContent, config);
// Log extraction results for debugging
console.log(`[analyze-deep] Extracted scene: ${result.sceneId} / ${result.sceneName}`);
console.log(`[analyze-deep] API endpoints: ${result.apiEndpoints?.length || 0}`);
console.log(`[analyze-deep] Column defs: ${result.columnDefs?.length || 0}`);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(result));
} catch (err) {
console.error(`[analyze-deep] Error: ${err.message}`);
res.writeHead(502, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
error: `Deep analysis failed: ${err.message}`,
hint: "You can still use basic analysis or enter data manually",
})
);
}
}
- Step 3: Add route in server request handler
In the http.createServer handler, add the new route after /analyze:
} else if (pathname === "/analyze-deep" && req.method === "POST") {
await handleAnalyzeDeep(req, res);
- Step 4: Verify syntax
Run: node -c frontend/scene-generator/server.js
Expected: No syntax errors
- Step 5: Commit
git add frontend/scene-generator/server.js
git commit -m "feat(server): add /analyze-deep endpoint for deep extraction"
Task 4: Add --scene-info-json CLI Parameter
Files:
- Modify:
src/bin/sg_scene_generate.rs - Modify:
src/generated_scene/generator.rs
Goal: Add --scene-info-json parameter to Rust CLI to receive pre-extracted scene info from the Node.js server.
- Step 1: Add SceneInfoJson struct in generator.rs
In src/generated_scene/generator.rs, add after imports:
use std::collections::HashMap;
#[derive(Debug, Clone, serde::Deserialize)]
pub struct ApiEndpointJson {
pub name: String,
pub url: String,
#[serde(default)]
pub method: String,
#[serde(default)]
pub description: Option<String>,
}
#[derive(Debug, Clone, serde::Deserialize)]
pub struct BusinessLogicJson {
#[serde(default)]
pub data_fetch: Option<String>,
#[serde(default)]
pub data_transform: Option<String>,
}
#[derive(Debug, Clone, serde::Deserialize)]
pub struct SceneInfoJson {
#[serde(rename = "sceneId")]
pub scene_id: String,
#[serde(rename = "sceneName")]
pub scene_name: String,
#[serde(rename = "sceneKind", default)]
pub scene_kind: String,
#[serde(rename = "sourceSystem", default)]
pub source_system: Option<String>,
#[serde(rename = "expectedDomain", default)]
pub expected_domain: Option<String>,
#[serde(rename = "targetUrl", default)]
pub target_url: Option<String>,
#[serde(rename = "apiEndpoints", default)]
pub api_endpoints: Vec<ApiEndpointJson>,
#[serde(rename = "staticParams", default)]
pub static_params: HashMap<String, String>,
#[serde(rename = "columnDefs", default)]
pub column_defs: Vec<(String, String)>,
#[serde(rename = "entryMethod", default)]
pub entry_method: Option<String>,
#[serde(rename = "businessLogic", default)]
pub business_logic: Option<BusinessLogicJson>,
}
- Step 2: Add scene_info_json field to GenerateSceneRequest
In src/generated_scene/generator.rs, modify GenerateSceneRequest:
#[derive(Debug, Clone)]
pub struct GenerateSceneRequest {
pub source_dir: PathBuf,
pub scene_id: String,
pub scene_name: String,
pub scene_kind: Option<SceneKind>,
pub target_url: Option<String>,
pub output_root: PathBuf,
pub lessons_path: Option<PathBuf>,
// NEW
pub scene_info_json: Option<SceneInfoJson>,
}
- Step 3: Modify browser_script function to use SceneInfo
Replace the existing browser_script function with enhanced version:
fn browser_script(scene_id: &str, analysis: &SceneSourceAnalysis, scene_info: Option<&SceneInfoJson>) -> String {
// If we have scene info with business logic, generate enhanced script
if let Some(info) = scene_info {
if !info.api_endpoints.is_empty() || !info.column_defs.is_empty() {
return browser_script_with_business_logic(scene_id, info);
}
}
// Fallback to skeleton template
browser_script_skeleton(scene_id, analysis)
}
fn browser_script_skeleton(scene_id: &str, _analysis: &SceneSourceAnalysis) -> String {
// Keep existing skeleton template
format!(
"function normalizePayload(payload) {{
if (typeof payload === 'string') {{
try {{ return JSON.parse(payload); }} catch (_) {{ return {{}}; }}
}}
return payload && typeof payload === 'object' ? payload : {{}};
}}
async function buildBrowserEntrypointResult(args, deps = {{}}) {{
const rows = typeof deps.collectRows === 'function'
? await deps.collectRows(args)
: [{{
org_label: args.org_label || '',
org_code: args.org_code || '',
period_mode: args.period_mode || '',
period_value: args.period_value || '',
value: ''
}}];
return {{
type: 'report-artifact',
report_name: '{}',
status: rows.length > 0 ? 'ok' : 'empty',
period: {{
mode: args.period_mode,
mode_code: args.period_mode_code,
value: args.period_value,
payload: normalizePayload(args.period_payload)
}},
org: {{ label: args.org_label, code: args.org_code }},
column_defs: [
['org_label', '供电单位'],
['org_code', '供电单位编码'],
['period_mode', '统计周期类型'],
['period_value', '统计周期'],
['value', '采集值']
],
columns: ['org_label', 'org_code', 'period_mode', 'period_value', 'value'],
rows,
counts: {{ detail_rows: rows.length }},
partial_reasons: [],
reasons: []
}};
}}
if (typeof module !== 'undefined') {{
module.exports = {{ buildBrowserEntrypointResult, normalizePayload }};
}}
if (typeof args !== 'undefined') {{
return buildBrowserEntrypointResult(args);
}}
",
scene_id
)
}
fn browser_script_with_business_logic(scene_id: &str, info: &SceneInfoJson) -> String {
// Generate API endpoints constant
let api_endpoints_code = info.api_endpoints.iter()
.map(|ep| format!(" {}: '{}',", ep.name, ep.url))
.collect::<Vec<_>>()
.join("\n");
// Generate static params constant
let static_params_code = info.static_params.iter()
.map(|(k, v)| format!(" {}: '{}',", k, v))
.collect::<Vec<_>>()
.join("\n");
// Generate column defs
let column_defs_code = info.column_defs.iter()
.map(|(field, label)| format!(" ['{}', '{}'],", field, label))
.collect::<Vec<_>>()
.join("\n");
let columns_code = info.column_defs.iter()
.map(|(field, _)| format!("'{}'", field))
.collect::<Vec<_>>()
.join(", ");
let primary_api = info.api_endpoints.first()
.map(|ep| ep.url.clone())
.unwrap_or_else(|| "/api/data".to_string());
let expected_domain = info.expected_domain.as_deref().unwrap_or("");
format!(r#"// ===== 自动生成部分 =====
const REPORT_NAME = '{scene_id}';
const EXPECTED_DOMAIN = '{expected_domain}';
// API 端点
const API_ENDPOINTS = {{
{api_endpoints_code}
}};
// 静态参数
const STATIC_PARAMS = {{
{static_params_code}
}};
// 列定义
const COLUMN_DEFS = [
{column_defs_code}
];
const COLUMNS = [{columns_code}];
// ===== 标准框架 =====
function normalizePayload(payload) {{
if (typeof payload === 'string') {{
try {{ return JSON.parse(payload); }} catch (_) {{ return {{}}; }}
}}
return payload && typeof payload === 'object' ? payload : {{}};
}}
function validateArgs(args) {{
const reasons = [];
if (!args.org_code) reasons.push('missing org_code');
if (!args.period_value) reasons.push('missing period_value');
return reasons.length === 0 ? {{ ok: true }} : {{ ok: false, reasons }};
}}
function buildRequest(args) {{
return {{
orgCode: args.org_code,
periodMode: args.period_mode,
periodValue: args.period_value,
...STATIC_PARAMS
}};
}}
function normalizeRows(rawRows) {{
if (!Array.isArray(rawRows)) return [];
return rawRows.map((row, index) => ({{
org_label: row.orgLabel || row.org_label || '',
org_code: row.orgCode || row.org_code || args.org_code || '',
period_mode: args.period_mode || '',
period_value: args.period_value || '',
...row
}}));
}}
function buildArtifact(opts) {{
return {{
type: 'report-artifact',
report_name: REPORT_NAME,
status: opts.status || 'ok',
period: {{
mode: args.period_mode,
mode_code: args.period_mode_code,
value: args.period_value,
payload: normalizePayload(args.period_payload)
}},
org: {{ label: args.org_label, code: args.org_code }},
column_defs: COLUMN_DEFS,
columns: COLUMNS,
rows: opts.rows || [],
counts: {{ detail_rows: (opts.rows || []).length }},
partial_reasons: opts.partial_reasons || [],
reasons: opts.reasons || []
}};
}}
async function buildBrowserEntrypointResult(args, deps = defaultDeps()) {{
// 1. 参数验证
const validation = validateArgs(args);
if (!validation.ok) {{
return buildArtifact({{ status: 'blocked', reasons: validation.reasons }});
}}
// 2. 页面上下文验证
const pageValidation = deps.validatePageContext?.(args);
if (!pageValidation?.ok) {{
return buildArtifact({{ status: 'blocked', reasons: ['page_context_mismatch'] }});
}}
// 3. 数据获取
try {{
const request = buildRequest(args);
const response = await deps.queryData(request);
const rows = normalizeRows(response.rows || response.data || []);
return buildArtifact({{
status: rows.length > 0 ? 'ok' : 'empty',
rows
}});
}} catch (error) {{
return buildArtifact({{ status: 'error', reasons: [error.message] }});
}}
}}
// ===== 默认依赖实现 =====
function defaultDeps() {{
return {{
validatePageContext(args) {{
const host = globalThis.location?.hostname;
return host === args.expected_domain || host === EXPECTED_DOMAIN
? {{ ok: true }}
: {{ ok: false, reason: 'domain_mismatch' }};
}},
async queryData(request) {{
// 根据 API_ENDPOINTS 调用实际接口
if (typeof $ !== 'undefined' && typeof $.ajax === 'function') {{
return new Promise((resolve, reject) => {{
$.ajax({{
url: API_ENDPOINTS.primary || '{primary_api}',
type: 'POST',
data: JSON.stringify(request),
contentType: 'application/json',
success: resolve,
error: (xhr, status, err) => reject(new Error(`API failed: ${{err}}`)),
}});
}});
}}
// Fallback: fetch API
if (typeof fetch === 'function') {{
const response = await fetch(API_ENDPOINTS.primary || '{primary_api}', {{
method: 'POST',
headers: {{ 'Content-Type': 'application/json' }},
body: JSON.stringify(request)
}});
return response.json();
}}
throw new Error('No HTTP client available');
}},
}};
}}
// ===== 模块导出 =====
if (typeof module !== 'undefined') {{
module.exports = {{
buildBrowserEntrypointResult,
validateArgs,
buildRequest,
normalizeRows,
COLUMN_DEFS,
COLUMNS,
}};
}}
if (typeof args !== 'undefined') {{
return buildBrowserEntrypointResult(args);
}}
"#, scene_id = scene_id, expected_domain = expected_domain, api_endpoints_code = api_endpoints_code, static_params_code = static_params_code, column_defs_code = column_defs_code, columns_code = columns_code, primary_api = primary_api)
}
- Step 4: Update generate_scene_package function
Modify generate_scene_package in generator.rs to pass scene_info:
pub fn generate_scene_package(
request: GenerateSceneRequest,
) -> Result<PathBuf, GenerateSceneError> {
let analysis = analyze_scene_source_with_hint(&request.source_dir, request.scene_kind.clone())?;
// ... existing code ...
write_file(
&scripts_dir.join(format!("{tool_name}.js")),
&browser_script(&request.scene_id, &analysis, request.scene_info_json.as_ref()),
)?;
// ... rest of function ...
}
- Step 5: Add CLI parameter in sg_scene_generate.rs
Modify CliArgs struct:
struct CliArgs {
source_dir: PathBuf,
scene_id: String,
scene_name: String,
scene_kind: Option<SceneKind>,
target_url: Option<String>,
output_root: PathBuf,
lessons_path: Option<PathBuf>,
// NEW
scene_info_json: Option<String>,
}
Add parsing in parse_args:
fn parse_args(args: impl Iterator<Item = String>) -> Result<CliArgs, String> {
// ... existing code ...
let mut scene_info_json = None;
// ... in match block ...
"--scene-info-json" => scene_info_json = Some(arg),
// ...
}
Parse JSON in run:
fn run() -> Result<(), String> {
let args = parse_args(env::args().skip(1))?;
let scene_info = args.scene_info_json
.map(|json| serde_json::from_str(&json))
.transpose()
.map_err(|e| format!("Invalid scene-info-json: {}", e))?;
let skill_root = generate_scene_package(GenerateSceneRequest {
source_dir: args.source_dir,
scene_id: args.scene_id,
scene_name: args.scene_name,
scene_kind: args.scene_kind,
target_url: args.target_url,
output_root: args.output_root,
lessons_path: args.lessons_path,
scene_info_json: scene_info,
})
.map_err(|err| err.to_string())?;
println!("generated scene package: {}", skill_root.display());
Ok(())
}
Update usage:
fn usage() -> String {
"usage: sg_scene_generate --source-dir <scenario-dir> --scene-id <scene-id> --scene-name <display-name> [--scene-kind <report_collection|monitoring>] [--target-url <url>] --output-root <skill-staging-root> [--lessons <lessons-toml>] [--scene-info-json '<json>']".to_string()
}
- Step 6: Verify Rust compilation
Run: cargo check
Expected: No compilation errors
- Step 7: Commit
git add src/bin/sg_scene_generate.rs src/generated_scene/generator.rs
git commit -m "feat(rust): add --scene-info-json parameter for LLM extraction results"
Task 5: Update Web UI with Extraction Preview
Files:
- Modify:
frontend/scene-generator/sg_scene_generator.html
Goal: Add UI elements to show extraction results and allow user confirmation before generation.
- Step 1: Add extraction results preview section
Add after the existing form section, a new collapsible panel for extraction preview:
<!-- 提取结果预览 -->
<div id="extractionPreview" class="preview-panel" style="display: none; margin-top: 20px;">
<div class="preview-header" onclick="togglePreview()">
<h3>LLM 提取结果</h3>
<span id="previewToggleIcon">▼</span>
</div>
<div id="previewContent" class="preview-content">
<div class="preview-section">
<h4>基本信息</h4>
<div class="preview-row">
<span class="label">场景 ID:</span>
<span id="previewSceneId" class="value"></span>
</div>
<div class="preview-row">
<span class="label">场景名称:</span>
<span id="previewSceneName" class="value"></span>
</div>
<div class="preview-row">
<span class="label">场景类型:</span>
<span id="previewSceneKind" class="value"></span>
</div>
<div class="preview-row">
<span class="label">目标域名:</span>
<span id="previewExpectedDomain" class="value"></span>
</div>
</div>
<div class="preview-section">
<h4>API 端点 (<span id="previewApiCount">0</span>)</h4>
<div id="previewApiEndpoints" class="preview-list"></div>
</div>
<div class="preview-section">
<h4>列定义 (<span id="previewColumnCount">0</span>)</h4>
<div id="previewColumnDefs" class="preview-list"></div>
</div>
<div class="preview-section">
<h4>静态参数</h4>
<pre id="previewStaticParams" class="preview-code"></pre>
</div>
<div class="preview-section">
<h4>业务逻辑</h4>
<div class="preview-row">
<span class="label">数据获取:</span>
<span id="previewDataFetch" class="value"></span>
</div>
<div class="preview-row">
<span class="label">数据转换:</span>
<span id="previewDataTransform" class="value"></span>
</div>
</div>
</div>
</div>
- Step 2: Add CSS for preview panel
Add styles:
.preview-panel {
background: rgba(255, 255, 255, 0.05);
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
overflow: hidden;
}
.preview-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
cursor: pointer;
background: rgba(255, 255, 255, 0.03);
}
.preview-header h3 {
margin: 0;
font-size: 16px;
}
.preview-content {
padding: 20px;
}
.preview-section {
margin-bottom: 20px;
}
.preview-section h4 {
margin: 0 0 10px 0;
font-size: 14px;
color: #a78bfa;
}
.preview-row {
display: flex;
margin-bottom: 8px;
}
.preview-row .label {
width: 100px;
color: #888;
flex-shrink: 0;
}
.preview-row .value {
color: #fff;
}
.preview-list {
max-height: 150px;
overflow-y: auto;
background: rgba(0, 0, 0, 0.2);
border-radius: 8px;
padding: 10px;
}
.preview-list-item {
padding: 6px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.preview-code {
background: rgba(0, 0, 0, 0.3);
padding: 10px;
border-radius: 8px;
font-family: monospace;
font-size: 12px;
overflow-x: auto;
white-space: pre-wrap;
}
- Step 3: Add JavaScript for deep analysis and preview
let currentSceneInfo = null;
async function analyzeDeep() {
const sourceDir = document.getElementById('sourceDir').value;
if (!sourceDir) {
alert('请先选择场景目录');
return;
}
showStatus('正在深度分析...');
try {
const response = await fetch(`${SERVER_URL}/analyze-deep`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sourceDir })
});
const data = await response.json();
if (data.error) {
showStatus('分析失败: ' + data.error);
return;
}
currentSceneInfo = data;
// Update form fields
document.getElementById('sceneId').value = data.sceneId || '';
document.getElementById('sceneName').value = data.sceneName || '';
document.getElementById('sceneKind').value = data.sceneKind || 'report_collection';
if (data.targetUrl) {
document.getElementById('targetUrl').value = data.targetUrl;
}
// Show preview
showExtractionPreview(data);
showStatus('分析完成,请确认提取结果');
} catch (err) {
showStatus('分析失败: ' + err.message);
}
}
function showExtractionPreview(data) {
document.getElementById('previewSceneId').textContent = data.sceneId || '-';
document.getElementById('previewSceneName').textContent = data.sceneName || '-';
document.getElementById('previewSceneKind').textContent = data.sceneKind || '-';
document.getElementById('previewExpectedDomain').textContent = data.expectedDomain || '-';
// API endpoints
const apiList = document.getElementById('previewApiEndpoints');
const apiCount = (data.apiEndpoints || []).length;
document.getElementById('previewApiCount').textContent = apiCount;
apiList.innerHTML = (data.apiEndpoints || []).map(ep => `
<div class="preview-list-item">
<strong>${ep.name}</strong>: ${ep.url}
<span style="color: #888">[${ep.method || 'GET'}]</span>
</div>
`).join('') || '<div style="color: #888">无 API 端点</div>';
// Column defs
const colList = document.getElementById('previewColumnDefs');
const colCount = (data.columnDefs || []).length;
document.getElementById('previewColumnCount').textContent = colCount;
colList.innerHTML = (data.columnDefs || []).map(([field, label]) => `
<div class="preview-list-item">
<code>${field}</code> → ${label}
</div>
`).join('') || '<div style="color: #888">无列定义</div>';
// Static params
document.getElementById('previewStaticParams').textContent =
JSON.stringify(data.staticParams || {}, null, 2) || '{}';
// Business logic
document.getElementById('previewDataFetch').textContent =
data.businessLogic?.dataFetch || '-';
document.getElementById('previewDataTransform').textContent =
data.businessLogic?.dataTransform || '-';
document.getElementById('extractionPreview').style.display = 'block';
}
function togglePreview() {
const content = document.getElementById('previewContent');
const icon = document.getElementById('previewToggleIcon');
if (content.style.display === 'none') {
content.style.display = 'block';
icon.textContent = '▼';
} else {
content.style.display = 'none';
icon.textContent = '▶';
}
}
- Step 4: Add "深度分析" button
Add a new button in the button group:
<button onclick="analyzeDeep()" class="btn btn-secondary">
深度分析
</button>
- Step 5: Update generate function to pass sceneInfo
Modify the generate function to include scene info JSON:
async function generate() {
const params = {
sourceDir: document.getElementById('sourceDir').value,
sceneId: document.getElementById('sceneId').value,
sceneName: document.getElementById('sceneName').value,
sceneKind: document.getElementById('sceneKind').value,
targetUrl: document.getElementById('targetUrl').value || null,
outputRoot: document.getElementById('outputRoot').value,
lessons: document.getElementById('lessons').value || null,
};
// Add scene info JSON if available
if (currentSceneInfo) {
params.sceneInfoJson = JSON.stringify(currentSceneInfo);
}
// ... rest of generate function ...
}
- Step 6: Verify UI loads
Run the server and open the page in browser:
cd frontend/scene-generator && node server.js
Open http://127.0.0.1:3210/
Expected: Page loads without JavaScript errors
- Step 7: Commit
git add frontend/scene-generator/sg_scene_generator.html
git commit -m "feat(ui): add deep extraction preview panel with API/column/static-params display"
Task 6: Update generator-runner.js to Pass sceneInfoJson
Files:
- Modify:
frontend/scene-generator/generator-runner.js
Goal: Update runGenerator to pass sceneInfoJson parameter to Rust CLI.
- Step 1: Modify runGenerator function
Update the function to accept and pass sceneInfoJson:
function runGenerator(params, sseWriter, projectRoot) {
const { sourceDir, sceneId, sceneName, sceneKind, targetUrl, outputRoot, lessons, sceneInfoJson } = params;
const normalize = (p) => p.replace(/\\/g, "/");
const args = [
"run",
"--bin",
"sg_scene_generate",
"--",
"--source-dir",
normalize(sourceDir),
"--scene-id",
sceneId,
"--scene-name",
sceneName,
];
if (sceneKind) {
args.push("--scene-kind", sceneKind);
}
if (targetUrl) {
args.push("--target-url", targetUrl);
}
args.push("--output-root", normalize(outputRoot));
if (lessons) {
args.push("--lessons", normalize(lessons));
}
// NEW: Pass scene info JSON
if (sceneInfoJson) {
args.push("--scene-info-json", sceneInfoJson);
}
// ... rest of function unchanged ...
}
- Step 2: Update server.js handleGenerate
Ensure handleGenerate passes the new parameter:
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, sceneKind, targetUrl, outputRoot, lessons, sceneInfoJson } = body;
if (!sourceDir || !sceneId || !sceneName || !outputRoot) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
error: "All fields required: sourceDir, sceneId, sceneName, outputRoot",
})
);
return;
}
const sseWriter = initSSE(res);
try {
await runGenerator(
{ sourceDir, sceneId, sceneName, sceneKind, targetUrl, outputRoot, lessons, sceneInfoJson },
sseWriter,
config.projectRoot
);
} catch (err) {
writeSSE(sseWriter, "error", { message: `Server error: ${err.message}` });
}
sseWriter.end();
}
- Step 3: Verify syntax
Run: node -c frontend/scene-generator/generator-runner.js && node -c frontend/scene-generator/server.js
Expected: No syntax errors
- Step 4: Commit
git add frontend/scene-generator/generator-runner.js frontend/scene-generator/server.js
git commit -m "feat(runner): pass sceneInfoJson to Rust CLI for enhanced template rendering"
Task 7: End-to-End Verification
Files:
- All modified files
Goal: Verify the complete flow works from UI to Rust CLI.
- Step 1: Build Rust binary
cargo build --release --bin sg_scene_generate
Expected: Build succeeds
- Step 2: Start the server
cd frontend/scene-generator && node server.js
Expected: Server starts on port 3210
- Step 3: Test health endpoint
curl http://127.0.0.1:3210/health
Expected: {"status":"ok",...}
- Step 4: Test analyze-deep endpoint with real scene
Use a real scene directory with index.html:
curl -X POST http://127.0.0.1:3210/analyze-deep \
-H "Content-Type: application/json" \
-d '{"sourceDir": "D:/path/to/scene/with/index.html"}'
Expected: JSON response with sceneId, sceneName, apiEndpoints, columnDefs
- Step 5: Test full generation flow
- Open browser to
http://127.0.0.1:3210/ - Select a scene directory with index.html
- Click "深度分析" button
- Verify preview shows extracted API/column data
- Click "生成" button
- Verify generated script contains extracted API endpoints and column definitions
- Step 6: Compare generated script
Compare the generated script with the reference:
-
Before: 51 lines (skeleton)
-
After: Should have API_ENDPOINTS, COLUMN_DEFS constants populated
-
Step 7: Final commit
git add -A
git commit -m "feat: complete LLM-driven skill generation with deep extraction
- Add /analyze-deep endpoint for deep LLM extraction
- Extract apiEndpoints, staticParams, columnDefs from index.html
- Pass extraction results via --scene-info-json to Rust CLI
- Generate complete browser_script with business logic constants
- Add UI preview panel for extraction results
"
Self-Review
1. Spec Coverage
| Spec Requirement | Task |
|---|---|
| LLM reads index.html | Task 1 (buildDeepAnalyzePrompt), Task 2 (readDirectory) |
| Extract apiEndpoints | Task 1 (DEEP_SYSTEM_PROMPT, analyzeSceneDeep) |
| Extract staticParams | Task 1 (DEEP_SYSTEM_PROMPT, analyzeSceneDeep) |
| Extract columnDefs | Task 1 (DEEP_SYSTEM_PROMPT, analyzeSceneDeep) |
| Extract businessLogic | Task 1 (DEEP_SYSTEM_PROMPT, analyzeSceneDeep) |
| --scene-info-json CLI parameter | Task 4 |
| Enhanced template rendering | Task 4 (browser_script_with_business_logic) |
| Web UI preview | Task 5 |
| User confirmation before generation | Task 5 (extraction preview) |
All covered.
2. Placeholder Scan
No TBD/TODO/"implement later"/"add tests"/"similar to" patterns found.
3. Type Consistency
/analyze-deep:{ sourceDir }→SceneInfoJson— consistent in Tasks 1, 3, 5/generate:{ ..., sceneInfoJson }— consistent in Tasks 5, 6- SceneInfoJson struct fields match JavaScript extraction output — consistent in Task 1, 4
- Column defs:
Vec<(String, String)>matches[[field, label]]— consistent
All consistent.
4. Backward Compatibility
- Existing
/analyzeendpoint unchanged - Existing CLI arguments (
--scene-id,--scene-name) still work --scene-info-jsonis optional, falls back to skeleton templateindex.htmlreading is optional, falls back if not present