Files
claw/frontend/scene-generator/sg_scene_generator.html
木炎 b5131c858a feat: add scene kind dropdown to Web UI
Add scene type selector to the generator form:
- CSS styles for select element matching existing input style
- Dropdown with "报表收集类" (report_collection) and "监测类" (monitoring) options
- Pass sceneKind to /generate API endpoint

🤖 Generated with [Qoder][https://qoder.com]
2026-04-17 00:02:17 +08:00

396 lines
19 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, 320px) 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); }
.input-row { display: flex; gap: 8px; }
.input-row input { flex: 1; }
.input-row button { width: auto; min-width: 80px; }
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: 14px 16px;
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: 14px 16px;
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: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); }
.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; }
.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.82rem; color: var(--muted); margin-top: 4px; }
@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>输入场景目录路径,自动提取 scene-id 和 scene-name一键生成 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 for="sourceDir">📂 场景目录路径</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>
</div>
<p class="hint">输入路径后点击"分析"或按回车,自动提取 scene-id 和 scene-name</p>
</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>
<button id="settingsBtn" class="ghost-btn" style="margin-bottom: 12px;">⚙ 设置</button>
<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>
<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 = {
sourceDir: document.getElementById("sourceDir"),
sceneId: document.getElementById("sceneId"),
sceneName: document.getElementById("sceneName"),
sceneKind: document.getElementById("sceneKind"),
analyzeBtn: document.getElementById("analyzeBtn"),
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;
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 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";
}
} catch (err) {
console.error("Failed to load defaults:", err);
setState("error", "无法连接服务器");
appendRow("error", `服务器连接失败: ${err.message}`);
}
}
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 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 }),
});
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;
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", "设置已保存");
}
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);
loadDefaults();
</script>
</body>
</html>