Files
claw/frontend/scene-generator/sg_scene_generator.html
木炎 d00086a70b feat: add sg_scene_generator.html with dual-panel UI and settings modal
Add self-contained HTML page for the Scene Skill Generator frontend:
- Dual-column glass-morphism layout matching service-console style
- Left sidebar: status card, sourceDir input with analyze button,
  sceneId/sceneName inputs, settings button, generate button
- Right panel: streaming log display with SSE event rendering
- Settings modal: outputRoot, lessons, llmBaseUrl, llmModel fields
- JavaScript: connects to http://127.0.0.1:3210, implements analyze()
  via fetch POST /analyze, generate() via fetch POST /generate with
  SSE stream reading, settings modal open/close
- Rust test verifying HTML file exists and contains required elements

🤖 Generated with [Qoder][https://qoder.com]
2026-04-16 22:23:33 +08:00

374 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, 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); }
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>
<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"),
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 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, 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>