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]
374 lines
18 KiB
HTML
374 lines
18 KiB
HTML
<!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>
|