Files
claw/frontend/scene-generator/sg_scene_generator.html
木炎 f268668713 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]
2026-04-17 00:23:09 +08:00

396 lines
18 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>场景 Skill 生成器</title>
<style>
:root {
--bg: #f3efe4;
--panel: rgba(255, 252, 247, 0.88);
--panel-strong: #fffaf2;
--text: #1f2329;
--muted: #636b74;
--line: rgba(31, 35, 41, 0.12);
--accent: #0f766e;
--accent-strong: #115e59;
--warn: #b45309;
--error: #b42318;
--success: #166534;
--shadow: 0 24px 60px rgba(34, 42, 53, 0.14);
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
font-family: "Microsoft YaHei", "PingFang SC", "Segoe UI", sans-serif;
color: var(--text);
background:
radial-gradient(circle at top left, rgba(15, 118, 110, 0.18), transparent 32%),
radial-gradient(circle at right, rgba(180, 83, 9, 0.14), transparent 28%),
linear-gradient(160deg, #f5f0e6 0%, #eef5f4 56%, #f7f3eb 100%);
padding: 24px;
}
.shell {
width: min(1040px, 100%);
margin: 0 auto;
background: var(--panel);
backdrop-filter: blur(14px);
border: 1px solid rgba(255, 255, 255, 0.7);
border-radius: 28px;
box-shadow: var(--shadow);
overflow: hidden;
}
.hero {
padding: 28px 28px 18px;
border-bottom: 1px solid var(--line);
background: linear-gradient(135deg, rgba(255, 250, 242, 0.96), rgba(237, 246, 243, 0.92));
}
.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, 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: 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 .browse-btn { width: auto; min-width: 60px; padding: 10px 14px; font-size: 0.85rem; }
input, button {
width: 100%;
border: 1px solid var(--line);
border-radius: 16px;
font: inherit;
}
input {
background: rgba(255, 255, 255, 0.92);
color: var(--text);
padding: 12px 14px;
outline: none;
transition: border-color 140ms ease, box-shadow 140ms ease;
}
input:focus { border-color: rgba(15, 118, 110, 0.5); box-shadow: 0 0 0 4px rgba(15, 118, 110, 0.12); }
select {
width: 100%;
border: 1px solid var(--line);
border-radius: 16px;
background: rgba(255, 255, 255, 0.92);
color: var(--text);
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: 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: 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: 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; }
.stream-head p { margin: 6px 0 0; color: var(--muted); font-size: 0.94rem; }
.stream { display: grid; gap: 12px; align-content: start; min-height: 320px; max-height: 70vh; overflow: auto; padding: 4px; }
.empty-state { padding: 22px; border-radius: 20px; background: rgba(255, 255, 255, 0.52); border: 1px dashed rgba(31, 35, 41, 0.16); color: var(--muted); line-height: 1.6; }
.row { display: grid; grid-template-columns: auto 1fr; gap: 12px; align-items: start; padding: 14px 16px; border-radius: 18px; background: rgba(255, 255, 255, 0.76); border: 1px solid rgba(31, 35, 41, 0.08); animation: rise 180ms ease; }
.row-badge { display: inline-flex; align-items: center; justify-content: center; min-width: 76px; padding: 6px 10px; border-radius: 999px; font-size: 0.76rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; background: rgba(99, 107, 116, 0.14); color: var(--muted); }
.row.status .row-badge { background: rgba(15, 118, 110, 0.14); color: var(--accent-strong); }
.row.log .row-badge { background: rgba(57, 91, 163, 0.14); color: #315aa2; }
.row.complete .row-badge { background: rgba(22, 101, 52, 0.14); color: var(--success); }
.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.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>
<body>
<div class="shell">
<div class="hero">
<h1>场景 Skill 生成器</h1>
<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>
</div>
<p class="section-label">Source</p>
<div class="field">
<label>场景目录路径</label>
<div class="input-row">
<input id="sourceDir" placeholder="点击浏览选择目录..." readonly />
<button id="browseSourceDir" class="ghost-btn browse-btn">浏览</button>
</div>
</div>
<div class="field">
<label for="sceneId">scene-id</label>
<input id="sceneId" placeholder="例如tq-lineloss-report" />
</div>
<div class="field">
<label for="sceneName">scene-name</label>
<input id="sceneName" placeholder="例如:台区线损报表" />
</div>
<div class="field">
<label for="sceneKind">场景类型</label>
<select id="sceneKind">
<option value="report_collection" selected>报表收集类</option>
<option value="monitoring">监测类</option>
</select>
</div>
<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>
</div>
<div class="stream-panel">
<div class="stream-head">
<div>
<p class="section-label">Generation Log</p>
<h2>实时日志</h2>
<p>显示生成过程的完整输出</p>
</div>
</div>
<div id="messageStream" class="stream">
<div class="empty-state" id="emptyState">选择场景目录并点击"生成 Skill"开始。</div>
</div>
</div>
</div>
</div>
<script>
const SERVER_URL = "http://127.0.0.1:3210";
const els = {
sourceDir: document.getElementById("sourceDir"),
sceneId: document.getElementById("sceneId"),
sceneName: document.getElementById("sceneName"),
sceneKind: document.getElementById("sceneKind"),
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"),
validationText: document.getElementById("validationText"),
stateChip: document.getElementById("stateChip"),
statusText: document.getElementById("statusText"),
messageStream: document.getElementById("messageStream"),
emptyState: document.getElementById("emptyState"),
};
let defaultsLoaded = false;
function setState(state, text) {
els.stateChip.textContent = text;
els.stateChip.dataset.state = state;
els.statusText.textContent = text;
}
function setValidation(msg) { els.validationText.textContent = msg; }
function updateGenerateBtn() {
const ready = els.sourceDir.value.trim() && els.sceneId.value.trim() && els.sceneName.value.trim() && defaultsLoaded;
els.generateBtn.disabled = !ready;
}
function appendRow(kind, text) {
if (els.emptyState) { els.emptyState.remove(); els.emptyState = null; }
const row = document.createElement("div");
row.className = "row " + kind;
const badge = document.createElement("span");
badge.className = "row-badge";
badge.textContent = kind;
const content = document.createElement("p");
content.className = "row-text";
content.textContent = text;
row.appendChild(badge);
row.appendChild(content);
els.messageStream.appendChild(row);
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`);
if (!res.ok) throw new Error("health check failed");
defaultsLoaded = true;
const health = await res.json();
if (health.projectRoot) {
const root = health.projectRoot.replace(/\\/g, "/");
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", "无法连接服务器");
appendRow("error", `服务器连接失败: ${err.message}`);
}
}
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;
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, targetUrl: targetUrl || null, outputRoot, lessons }),
});
if (!res.ok) { const err = await res.json(); throw new Error(err.error || "Generation failed"); }
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let lastEvent = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (line.startsWith("event:")) { lastEvent = line.slice(6).trim(); }
else if (line.startsWith("data:") && line.trim()) {
const dataStr = line.slice(5).trim();
if (!dataStr) continue;
try {
const data = JSON.parse(dataStr);
switch (lastEvent) {
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 || ""}`); }
else { setState("error", "生成失败"); appendRow("error", data.message || "生成失败"); }
break;
case "error": setState("error", "生成失败"); appendRow("error", data.message); break;
default: appendRow("log", JSON.stringify(data));
}
} catch (_) {}
}
}
}
} catch (err) {
setState("error", "生成失败");
appendRow("error", err.message);
} finally {
els.generateBtn.disabled = false;
updateGenerateBtn();
}
}
// 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.generateBtn.addEventListener("click", generate);
els.sceneId.addEventListener("input", updateGenerateBtn);
els.sceneName.addEventListener("input", updateGenerateBtn);
loadDefaults();
</script>
</body>
</html>