feat: add folder picker and target_url input to Web UI

- Add /select-folder and /select-file APIs using PowerShell dialogs
- Add --target-url parameter to CLI for explicit target URL override
- Redesign Web UI with folder browse buttons for all path inputs
- Add target_url optional input field for specifying target page URL
- Auto-fill scene-id from selected folder name

🤖 Generated with [Qoder][https://qoder.com]
This commit is contained in:
木炎
2026-04-17 00:23:09 +08:00
parent ce072c2ebe
commit f268668713
6 changed files with 252 additions and 118 deletions

View File

@@ -2,7 +2,7 @@ const { spawn } = require("child_process");
const path = require("path");
function runGenerator(params, sseWriter, projectRoot) {
const { sourceDir, sceneId, sceneName, sceneKind, outputRoot, lessons } = params;
const { sourceDir, sceneId, sceneName, sceneKind, targetUrl, outputRoot, lessons } = params;
const normalize = (p) => p.replace(/\\/g, "/");
@@ -24,6 +24,11 @@ function runGenerator(params, sseWriter, projectRoot) {
args.push("--scene-kind", sceneKind);
}
// 如果提供了 targetUrl添加参数
if (targetUrl) {
args.push("--target-url", targetUrl);
}
args.push(
"--output-root",
normalize(outputRoot),

View File

@@ -4,6 +4,7 @@
const http = require("http");
const fs = require("fs");
const path = require("path");
const { spawn } = require("child_process");
const { loadConfig, getDefaults } = require("./config-loader");
const { analyzeScene } = require("./llm-client");
const { runGenerator, readDirectory } = require("./generator-runner");
@@ -126,7 +127,7 @@ async function handleGenerate(req, res) {
return;
}
const { sourceDir, sceneId, sceneName, sceneKind, outputRoot, lessons } = body;
const { sourceDir, sceneId, sceneName, sceneKind, targetUrl, outputRoot, lessons } = body;
if (!sourceDir || !sceneId || !sceneName || !outputRoot || !lessons) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(
@@ -142,7 +143,7 @@ async function handleGenerate(req, res) {
try {
await runGenerator(
{ sourceDir, sceneId, sceneName, sceneKind, outputRoot, lessons },
{ sourceDir, sceneId, sceneName, sceneKind, targetUrl, outputRoot, lessons },
sseWriter,
config.projectRoot
);
@@ -166,6 +167,116 @@ function handleHealth(req, res) {
);
}
/**
* Open a native Windows folder selection dialog using PowerShell.
* Returns the selected folder path or null if cancelled.
*/
function openFolderDialog(defaultPath) {
return new Promise((resolve) => {
const psScript = `
Add-Type -AssemblyName System.Windows.Forms
$dialog = New-Object System.Windows.Forms.FolderBrowserDialog
$dialog.Description = "选择文件夹"
$dialog.ShowNewFolderButton = true
${defaultPath ? `$dialog.SelectedPath = '${defaultPath.replace(/'/g, "''")}'` : ""}
if ($dialog.ShowDialog() -eq 'OK') {
Write-Output $dialog.SelectedPath
}
`.trim();
const ps = spawn("powershell.exe", [
"-NoProfile",
"-NonInteractive",
"-Command",
psScript,
]);
let output = "";
let error = "";
ps.stdout.on("data", (data) => {
output += data.toString();
});
ps.stderr.on("data", (data) => {
error += data.toString();
});
ps.on("close", (code) => {
if (code === 0 && output.trim()) {
resolve(output.trim());
} else {
resolve(null);
}
});
ps.on("error", () => {
resolve(null);
});
});
}
async function handleSelectFolder(req, res) {
let body = {};
try {
body = await parseBody(req);
} catch {
// ignore parse error, use empty body
}
const selectedPath = await openFolderDialog(body.defaultPath || "");
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ path: selectedPath }));
}
async function handleSelectFile(req, res) {
let body = {};
try {
body = await parseBody(req);
} catch {
// ignore parse error
}
const filter = body.filter || "所有文件 (*.*)|*.*";
const psScript = `
Add-Type -AssemblyName System.Windows.Forms
$dialog = New-Object System.Windows.Forms.OpenFileDialog
$dialog.Filter = '${filter}'
$dialog.Title = "选择文件"
${body.defaultPath ? `$dialog.InitialDirectory = '${body.defaultPath.replace(/'/g, "''")}'` : ""}
if ($dialog.ShowDialog() -eq 'OK') {
Write-Output $dialog.FileName
}
`.trim();
return new Promise((resolve) => {
const ps = spawn("powershell.exe", [
"-NoProfile",
"-NonInteractive",
"-Command",
psScript,
]);
let output = "";
ps.stdout.on("data", (data) => {
output += data.toString();
});
ps.on("close", (code) => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ path: code === 0 && output.trim() ? output.trim() : null }));
resolve();
});
ps.on("error", () => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ path: null }));
resolve();
});
});
}
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`);
const pathname = url.pathname;
@@ -187,6 +298,10 @@ const server = http.createServer(async (req, res) => {
await handleAnalyze(req, res);
} else if (pathname === "/generate" && req.method === "POST") {
await handleGenerate(req, res);
} else if (pathname === "/select-folder" && req.method === "POST") {
await handleSelectFolder(req, res);
} else if (pathname === "/select-file" && req.method === "POST") {
await handleSelectFile(req, res);
} else if (pathname === "/" || pathname === "/index.html") {
serveStatic(res, path.join(__dirname, "sg_scene_generator.html"));
} else {

View File

@@ -48,15 +48,15 @@
}
.hero h1 { margin: 0; font-size: clamp(1.8rem, 4vw, 2.6rem); line-height: 1.05; letter-spacing: 0.02em; }
.hero p { margin: 10px 0 0; max-width: 60ch; color: var(--muted); line-height: 1.6; }
.content { display: grid; grid-template-columns: minmax(280px, 320px) minmax(0, 1fr); gap: 0; }
.content { display: grid; grid-template-columns: minmax(280px, 360px) minmax(0, 1fr); gap: 0; }
.sidebar, .stream-panel { padding: 24px; }
.sidebar { border-right: 1px solid var(--line); background: rgba(255, 255, 255, 0.38); }
.section-label { margin: 0 0 14px; font-size: 0.83rem; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; color: var(--muted); }
.field { margin-bottom: 18px; }
.field label { display: block; margin-bottom: 8px; font-size: 0.92rem; color: var(--muted); }
.field { margin-bottom: 16px; }
.field label { display: block; margin-bottom: 6px; font-size: 0.92rem; color: var(--muted); }
.input-row { display: flex; gap: 8px; }
.input-row input { flex: 1; }
.input-row button { width: auto; min-width: 80px; }
.input-row .browse-btn { width: auto; min-width: 60px; padding: 10px 14px; font-size: 0.85rem; }
input, button {
width: 100%;
border: 1px solid var(--line);
@@ -66,7 +66,7 @@
input {
background: rgba(255, 255, 255, 0.92);
color: var(--text);
padding: 14px 16px;
padding: 12px 14px;
outline: none;
transition: border-color 140ms ease, box-shadow 140ms ease;
}
@@ -77,26 +77,26 @@
border-radius: 16px;
background: rgba(255, 255, 255, 0.92);
color: var(--text);
padding: 14px 16px;
padding: 12px 14px;
font: inherit;
outline: none;
cursor: pointer;
transition: border-color 140ms ease, box-shadow 140ms ease;
}
select:focus { border-color: rgba(15, 118, 110, 0.5); box-shadow: 0 0 0 4px rgba(15, 118, 110, 0.12); }
button { border: 0; padding: 14px 16px; font-weight: 700; cursor: pointer; transition: transform 140ms ease, opacity 140ms ease; }
button { border: 0; padding: 12px 14px; font-weight: 700; cursor: pointer; transition: transform 140ms ease, opacity 140ms ease; }
button:hover:not(:disabled) { transform: translateY(-1px); }
button:disabled { cursor: not-allowed; opacity: 0.45; }
.primary-btn { background: linear-gradient(135deg, var(--accent), var(--accent-strong)); color: #f6fffd; box-shadow: 0 14px 28px rgba(15, 118, 110, 0.18); }
.ghost-btn { background: rgba(255, 255, 255, 0.9); color: var(--text); border: 1px solid var(--line); }
.status-card { display: grid; gap: 8px; padding: 16px; border-radius: 20px; background: var(--panel-strong); border: 1px solid var(--line); margin-bottom: 18px; }
.state-chip { display: inline-flex; align-items: center; width: fit-content; padding: 6px 10px; border-radius: 999px; font-size: 0.85rem; font-weight: 700; background: rgba(99, 107, 116, 0.12); color: var(--muted); }
.status-card { display: grid; gap: 8px; padding: 14px; border-radius: 20px; background: var(--panel-strong); border: 1px solid var(--line); margin-bottom: 16px; }
.state-chip { display: inline-flex; align-items: center; width: fit-content; padding: 5px 10px; border-radius: 999px; font-size: 0.85rem; font-weight: 700; background: rgba(99, 107, 116, 0.12); color: var(--muted); }
.state-chip[data-state="ready"] { background: rgba(99, 107, 116, 0.12); color: var(--muted); }
.state-chip[data-state="analyzing"] { background: rgba(180, 83, 9, 0.12); color: var(--warn); }
.state-chip[data-state="generating"] { background: rgba(15, 118, 110, 0.12); color: var(--accent); }
.state-chip[data-state="complete"] { background: rgba(22, 101, 52, 0.12); color: var(--success); }
.state-chip[data-state="error"] { background: rgba(180, 35, 24, 0.12); color: var(--error); }
.validation { min-height: 1.4em; margin: 10px 0 14px; color: var(--error); font-size: 0.92rem; }
.validation { min-height: 1.4em; margin: 8px 0 12px; color: var(--error); font-size: 0.92rem; }
.stream-panel { display: grid; grid-template-rows: auto minmax(320px, 1fr); gap: 18px; }
.stream-head { display: flex; justify-content: space-between; align-items: end; gap: 16px; }
.stream-head h2 { margin: 0; font-size: 1.35rem; }
@@ -111,7 +111,8 @@
.row.error .row-badge { background: rgba(180, 35, 24, 0.14); color: var(--error); }
.row-text { margin: 0; line-height: 1.6; white-space: pre-wrap; word-break: break-word; }
@keyframes rise { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
.hint { font-size: 0.82rem; color: var(--muted); margin-top: 4px; }
.hint { font-size: 0.8rem; color: var(--muted); margin-top: 4px; }
.divider { height: 1px; background: var(--line); margin: 12px 0; }
@media (max-width: 900px) { body { padding: 16px; } .content { grid-template-columns: 1fr; } .sidebar { border-right: 0; border-bottom: 1px solid var(--line); } .stream { max-height: none; } }
</style>
</head>
@@ -119,23 +120,22 @@
<div class="shell">
<div class="hero">
<h1>场景 Skill 生成器</h1>
<p>输入场景目录路径,自动提取 scene-id 和 scene-name,一键生成 skill 包并实时查看进度</p>
<p>选择场景目录,配置参数,一键生成 skill 包。</p>
</div>
<div class="content">
<div class="sidebar">
<p class="section-label">Status</p>
<div class="status-card">
<span id="stateChip" class="state-chip" data-state="ready">就绪</span>
<span id="statusText">输入场景目录路径</span>
<span id="statusText">选择场景目录</span>
</div>
<p class="section-label">Source</p>
<div class="field">
<label for="sourceDir">📂 场景目录路径</label>
<label>场景目录路径</label>
<div class="input-row">
<input id="sourceDir" placeholder="例如D:\data\ideaSpace\rust\sgClaw\claw-new\examples\generated_scene_platform\scenarios\tq-lineloss-report" />
<button id="analyzeBtn" class="ghost-btn">分析</button>
<input id="sourceDir" placeholder="点击浏览选择目录..." readonly />
<button id="browseSourceDir" class="ghost-btn browse-btn">浏览</button>
</div>
<p class="hint">输入路径后点击"分析"或按回车,自动提取 scene-id 和 scene-name</p>
</div>
<div class="field">
<label for="sceneId">scene-id</label>
@@ -152,16 +152,36 @@
<option value="monitoring">监测类</option>
</select>
</div>
<button id="settingsBtn" class="ghost-btn" style="margin-bottom: 12px;">⚙ 设置</button>
<div class="field">
<label for="targetUrl">目标 URL (可选)</label>
<input id="targetUrl" placeholder="例如http://20.76.57.61:18080/report" />
<p class="hint">场景要访问的目标页面地址,留空则使用自动提取的域名</p>
</div>
<div class="divider"></div>
<p class="section-label">Settings</p>
<div class="field">
<label>输出根路径</label>
<div class="input-row">
<input id="settingOutputRoot" placeholder="点击浏览选择目录..." readonly />
<button id="browseOutputRoot" class="ghost-btn browse-btn">浏览</button>
</div>
</div>
<div class="field">
<label>Lessons 路径</label>
<div class="input-row">
<input id="settingLessons" placeholder="点击浏览选择文件..." readonly />
<button id="browseLessons" class="ghost-btn browse-btn">浏览</button>
</div>
</div>
<div id="validationText" class="validation"></div>
<button id="generateBtn" class="primary-btn" disabled>🚀 生成 Skill</button>
<button id="generateBtn" class="primary-btn" disabled>生成 Skill</button>
</div>
<div class="stream-panel">
<div class="stream-head">
<div>
<p class="section-label">Generation Log</p>
<h2>实时日志</h2>
<p>显示分析和生成过程的完整输出</p>
<p>显示生成过程的完整输出</p>
</div>
</div>
<div id="messageStream" class="stream">
@@ -171,33 +191,6 @@
</div>
</div>
<div id="settingsModal" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 1000; align-items: center; justify-content: center;">
<div style="background: var(--panel); border-radius: 20px; padding: 28px; width: min(520px, 90%); max-height: 85vh; overflow-y: auto; box-shadow: var(--shadow);">
<h3 style="margin: 0 0 20px; font-size: 1.2rem;">生成器设置</h3>
<div class="field">
<label for="settingOutputRoot">输出根路径</label>
<input id="settingOutputRoot" type="text" />
</div>
<div class="field">
<label for="settingLessons">Lessons 路径</label>
<input id="settingLessons" type="text" />
</div>
<div class="field">
<label for="settingLlmBaseUrl">LLM 服务地址</label>
<input id="settingLlmBaseUrl" type="text" />
</div>
<div class="field">
<label for="settingLlmModel">LLM 模型</label>
<input id="settingLlmModel" type="text" />
</div>
<div id="settingsValidation" style="color: var(--error); font-size: 0.92rem; min-height: 1.4em; margin: 10px 0;"></div>
<div style="display: flex; gap: 12px; margin-top: 16px;">
<button id="settingsSaveBtn" class="primary-btn" style="flex: 1;">保存</button>
<button id="settingsCancelBtn" class="ghost-btn" style="flex: 1;">取消</button>
</div>
</div>
</div>
<script>
const SERVER_URL = "http://127.0.0.1:3210";
const els = {
@@ -205,22 +198,18 @@
sceneId: document.getElementById("sceneId"),
sceneName: document.getElementById("sceneName"),
sceneKind: document.getElementById("sceneKind"),
analyzeBtn: document.getElementById("analyzeBtn"),
targetUrl: document.getElementById("targetUrl"),
browseSourceDir: document.getElementById("browseSourceDir"),
browseOutputRoot: document.getElementById("browseOutputRoot"),
browseLessons: document.getElementById("browseLessons"),
settingOutputRoot: document.getElementById("settingOutputRoot"),
settingLessons: document.getElementById("settingLessons"),
generateBtn: document.getElementById("generateBtn"),
settingsBtn: document.getElementById("settingsBtn"),
validationText: document.getElementById("validationText"),
stateChip: document.getElementById("stateChip"),
statusText: document.getElementById("statusText"),
messageStream: document.getElementById("messageStream"),
emptyState: document.getElementById("emptyState"),
settingsModal: document.getElementById("settingsModal"),
settingOutputRoot: document.getElementById("settingOutputRoot"),
settingLessons: document.getElementById("settingLessons"),
settingLlmBaseUrl: document.getElementById("settingLlmBaseUrl"),
settingLlmModel: document.getElementById("settingLlmModel"),
settingsValidation: document.getElementById("settingsValidation"),
settingsSaveBtn: document.getElementById("settingsSaveBtn"),
settingsCancelBtn: document.getElementById("settingsCancelBtn"),
};
let defaultsLoaded = false;
@@ -253,6 +242,36 @@
els.messageStream.scrollTop = els.messageStream.scrollHeight;
}
async function selectFolder(defaultPath) {
try {
const res = await fetch(`${SERVER_URL}/select-folder`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ defaultPath }),
});
const data = await res.json();
return data.path;
} catch (err) {
console.error("Failed to select folder:", err);
return null;
}
}
async function selectFile(defaultPath, filter) {
try {
const res = await fetch(`${SERVER_URL}/select-file`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ defaultPath, filter }),
});
const data = await res.json();
return data.path;
} catch (err) {
console.error("Failed to select file:", err);
return null;
}
}
async function loadDefaults() {
try {
const res = await fetch(`${SERVER_URL}/health`);
@@ -264,6 +283,7 @@
els.settingOutputRoot.value = root + "/examples/generated_scene_platform";
els.settingLessons.value = root + "/docs/superpowers/references/tq-lineloss-lessons-learned.toml";
}
updateGenerateBtn();
} catch (err) {
console.error("Failed to load defaults:", err);
setState("error", "无法连接服务器");
@@ -271,55 +291,25 @@
}
}
async function analyze() {
const sourceDir = els.sourceDir.value.trim();
if (!sourceDir) { setValidation("请输入场景目录路径"); return; }
setValidation("");
setState("analyzing", "正在分析场景目录...");
els.analyzeBtn.disabled = true;
appendRow("status", `开始分析: ${sourceDir}`);
try {
const res = await fetch(`${SERVER_URL}/analyze`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sourceDir }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Analysis failed");
els.sceneId.value = data.sceneId || "";
els.sceneName.value = data.sceneName || "";
setState("ready", "分析完成");
appendRow("complete", `scene-id: ${data.sceneId}, scene-name: ${data.sceneName}`);
updateGenerateBtn();
} catch (err) {
setState("error", "分析失败");
appendRow("error", err.message);
if (err.message.includes("LLM")) appendRow("status", "你可以手动输入 scene-id 和 scene-name");
} finally {
els.analyzeBtn.disabled = false;
}
}
async function generate() {
const sourceDir = els.sourceDir.value.trim().replace(/\\/g, "/");
const sceneId = els.sceneId.value.trim();
const sceneName = els.sceneName.value.trim();
const sceneKind = els.sceneKind.value;
const targetUrl = els.targetUrl.value.trim();
const outputRoot = els.settingOutputRoot.value.trim().replace(/\\/g, "/");
const lessons = els.settingLessons.value.trim().replace(/\\/g, "/");
if (!sourceDir || !sceneId || !sceneName || !outputRoot || !lessons) { setValidation("所有字段均为必填"); return; }
setValidation("");
setState("generating", "正在生成 skill 包...");
els.generateBtn.disabled = true;
els.analyzeBtn.disabled = true;
appendRow("status", "开始生成 skill 包...");
try {
const res = await fetch(`${SERVER_URL}/generate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sourceDir, sceneId, sceneName, sceneKind, outputRoot, lessons }),
body: JSON.stringify({ sourceDir, sceneId, sceneName, sceneKind, targetUrl: targetUrl || null, outputRoot, lessons }),
});
if (!res.ok) { const err = await res.json(); throw new Error(err.error || "Generation failed"); }
@@ -345,7 +335,7 @@
case "status": appendRow("status", data.message); break;
case "log": appendRow("log", data.message); break;
case "complete":
if (data.success) { setState("complete", "生成完成"); appendRow("complete", `生成完成: ${data.skillRoot || ""}`); }
if (data.success) { setState("complete", "生成完成"); appendRow("complete", `生成完成: ${data.skillRoot || ""}`); }
else { setState("error", "生成失败"); appendRow("error", data.message || "生成失败"); }
break;
case "error": setState("error", "生成失败"); appendRow("error", data.message); break;
@@ -360,32 +350,42 @@
appendRow("error", err.message);
} finally {
els.generateBtn.disabled = false;
els.analyzeBtn.disabled = false;
updateGenerateBtn();
}
}
function openSettings() { els.settingsValidation.textContent = ""; els.settingsModal.style.display = "flex"; }
function closeSettings() { els.settingsModal.style.display = "none"; }
function saveSettings() {
const outputRoot = els.settingOutputRoot.value.trim();
const lessons = els.settingLessons.value.trim();
if (!outputRoot || !lessons) { els.settingsValidation.textContent = "输出路径和 Lessons 路径不能为空"; return; }
els.settingsValidation.textContent = "";
defaultsLoaded = true;
closeSettings();
updateGenerateBtn();
appendRow("status", "设置已保存");
}
// Browse buttons
els.browseSourceDir.addEventListener("click", async () => {
const path = await selectFolder(els.sourceDir.value || null);
if (path) {
els.sourceDir.value = path;
// Auto-fill scene-id from folder name
const parts = path.replace(/\\/g, "/").split("/");
const folderName = parts[parts.length - 1];
if (folderName && !els.sceneId.value) {
els.sceneId.value = folderName;
}
updateGenerateBtn();
}
});
els.browseOutputRoot.addEventListener("click", async () => {
const path = await selectFolder(els.settingOutputRoot.value || null);
if (path) {
els.settingOutputRoot.value = path;
updateGenerateBtn();
}
});
els.browseLessons.addEventListener("click", async () => {
const path = await selectFile(els.settingLessons.value || null, "TOML 文件 (*.toml)|*.toml|所有文件 (*.*)|*.*");
if (path) {
els.settingLessons.value = path;
updateGenerateBtn();
}
});
els.analyzeBtn.addEventListener("click", analyze);
els.sourceDir.addEventListener("keydown", (e) => { if (e.key === "Enter") analyze(); });
els.generateBtn.addEventListener("click", generate);
els.settingsBtn.addEventListener("click", openSettings);
els.settingsCancelBtn.addEventListener("click", closeSettings);
els.settingsSaveBtn.addEventListener("click", saveSettings);
els.settingsModal.addEventListener("click", (e) => { if (e.target === els.settingsModal) closeSettings(); });
els.sourceDir.addEventListener("input", updateGenerateBtn);
els.sceneId.addEventListener("input", updateGenerateBtn);
els.sceneName.addEventListener("input", updateGenerateBtn);

View File

@@ -18,6 +18,7 @@ fn run() -> Result<(), String> {
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,
})
@@ -32,6 +33,7 @@ struct CliArgs {
scene_id: String,
scene_name: String,
scene_kind: Option<SceneKind>,
target_url: Option<String>,
output_root: PathBuf,
lessons_path: PathBuf,
}
@@ -41,6 +43,7 @@ fn parse_args(args: impl Iterator<Item = String>) -> Result<CliArgs, String> {
let mut scene_id = None;
let mut scene_name = None;
let mut scene_kind = None;
let mut target_url = None;
let mut output_root = None;
let mut lessons_path = None;
let mut pending_flag: Option<String> = None;
@@ -57,6 +60,7 @@ fn parse_args(args: impl Iterator<Item = String>) -> Result<CliArgs, String> {
.ok_or_else(|| format!("invalid scene kind: {}", arg))?,
);
}
"--target-url" => target_url = Some(arg),
"--output-root" => output_root = Some(PathBuf::from(arg)),
"--lessons" => lessons_path = Some(PathBuf::from(arg)),
_ => return Err(format!("unsupported argument {flag}")),
@@ -65,8 +69,8 @@ fn parse_args(args: impl Iterator<Item = String>) -> Result<CliArgs, String> {
}
match arg.as_str() {
"--source-dir" | "--scene-id" | "--scene-name" | "--scene-kind" | "--output-root"
| "--lessons" => {
"--source-dir" | "--scene-id" | "--scene-name" | "--scene-kind" | "--target-url"
| "--output-root" | "--lessons" => {
pending_flag = Some(arg);
}
"--help" | "-h" => return Err(usage()),
@@ -83,11 +87,12 @@ fn parse_args(args: impl Iterator<Item = String>) -> Result<CliArgs, String> {
scene_id: scene_id.ok_or_else(usage)?,
scene_name: scene_name.ok_or_else(usage)?,
scene_kind,
target_url,
output_root: output_root.ok_or_else(usage)?,
lessons_path: lessons_path.ok_or_else(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>] --output-root <skill-staging-root> --lessons <lessons-toml>".to_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>".to_string()
}

View File

@@ -13,6 +13,7 @@ pub struct GenerateSceneRequest {
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: PathBuf,
}
@@ -153,7 +154,10 @@ fn scene_toml_report_collection(
tool_name: &str,
) -> String {
let expected_domain = analysis.bootstrap.expected_domain.as_deref().unwrap_or_default();
let target_url = analysis.bootstrap.target_url.as_deref().unwrap_or_default();
// Use request.target_url if provided, otherwise fall back to analysis
let target_url = request.target_url.as_deref()
.or(analysis.bootstrap.target_url.as_deref())
.unwrap_or_default();
format!(
"[scene]\nid = \"{}\"\nskill = \"{}\"\ntool = \"{}\"\nkind = \"browser_script\"\nversion = \"0.1.0\"\ncategory = \"report_collection\"\n\n[manifest]\nschema_version = \"1\"\n\n[bootstrap]\nexpected_domain = \"{}\"\ntarget_url = \"{}\"\npage_title_keywords = [\"报表\", \"线损\"]\nrequires_target_page = true\n\n[deterministic]\nsuffix = \"。。。\"\ninclude_keywords = [\"{}\", \"报表\", \"统计\"]\nexclude_keywords = [\"知乎\"]\n\n[[params]]\nname = \"org\"\nresolver = \"dictionary_entity\"\nrequired = true\nprompt_missing = \"已命中{},但缺少供电单位。\"\nprompt_ambiguous = \"已命中{},但供电单位存在歧义。\"\n\n[params.resolver_config]\ndictionary_ref = \"references/org-dictionary.json\"\noutput_label_field = \"org_label\"\noutput_code_field = \"org_code\"\n\n[[params]]\nname = \"period\"\nresolver = \"month_week_period\"\nrequired = true\nprompt_missing = \"已命中{},但缺少统计周期。\"\nprompt_ambiguous = \"已命中{},但统计周期存在歧义。\"\n\n[artifact]\ntype = \"report-artifact\"\nsuccess_status = [\"ok\", \"partial\", \"empty\"]\nfailure_status = [\"blocked\", \"error\"]\n\n[postprocess]\nexporter = \"xlsx_report\"\nauto_open = \"excel\"\n",
request.scene_id,
@@ -175,7 +179,10 @@ fn scene_toml_monitoring(
tool_name: &str,
) -> String {
let expected_domain = analysis.bootstrap.expected_domain.as_deref().unwrap_or_default();
let target_url = analysis.bootstrap.target_url.as_deref().unwrap_or_default();
// Use request.target_url if provided, otherwise fall back to analysis
let target_url = request.target_url.as_deref()
.or(analysis.bootstrap.target_url.as_deref())
.unwrap_or_default();
format!(
"[scene]\nid = \"{}\"\nskill = \"{}\"\ntool = \"{}\"\nkind = \"browser_script\"\nversion = \"0.1.0\"\ncategory = \"monitoring\"\n\n[manifest]\nschema_version = \"1\"\n\n[bootstrap]\nexpected_domain = \"{}\"\ntarget_url = \"{}\"\npage_title_keywords = [\"监测\", \"状态\"]\nrequires_target_page = true\n\n[deterministic]\nsuffix = \"。。。\"\ninclude_keywords = [\"{}\", \"监测\", \"状态\"]\nexclude_keywords = [\"知乎\"]\n\n# 监测类场景参数留空,由用户手动编辑\n\n[artifact]\ntype = \"report-artifact\"\nsuccess_status = [\"ok\", \"partial\", \"empty\"]\nfailure_status = [\"blocked\", \"error\"]\n\n[postprocess]\nexporter = \"xlsx_report\"\nauto_open = \"excel\"\n",
request.scene_id,

View File

@@ -38,6 +38,7 @@ fn generator_writes_registration_ready_package_with_scene_toml() {
scene_id: "sample-report-scene".to_string(),
scene_name: "示例报表场景".to_string(),
scene_kind: None,
target_url: None,
output_root: output_root.clone(),
lessons_path: PathBuf::from("docs/superpowers/references/tq-lineloss-lessons-learned.toml"),
})
@@ -152,6 +153,7 @@ fn generator_emits_monitoring_template() {
scene_id: "sample-monitor-scene".to_string(),
scene_name: "示例监测场景".to_string(),
scene_kind: Some(SceneKind::Monitoring),
target_url: None,
output_root: output_root.clone(),
lessons_path: PathBuf::from("docs/superpowers/references/tq-lineloss-lessons-learned.toml"),
})