Files
claw/frontend/scene-generator/sg_scene_generator.html

686 lines
27 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; } }
/* Preview panel styles */
.preview-panel {
background: rgba(255, 255, 255, 0.6);
border-radius: 16px;
border: 1px solid var(--line);
overflow: hidden;
margin-top: 16px;
}
.preview-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 16px;
cursor: pointer;
background: rgba(255, 255, 255, 0.5);
}
.preview-header h3 {
margin: 0;
font-size: 0.95rem;
font-weight: 700;
color: var(--text);
}
.preview-header:hover {
background: rgba(255, 255, 255, 0.7);
}
.preview-content {
padding: 16px;
}
.preview-section {
margin-bottom: 16px;
}
.preview-section:last-child {
margin-bottom: 0;
}
.preview-section h4 {
margin: 0 0 8px 0;
font-size: 0.85rem;
font-weight: 700;
color: var(--accent);
}
.preview-row {
display: flex;
margin-bottom: 6px;
}
.preview-row .label {
min-width: 80px;
color: var(--muted);
flex-shrink: 0;
font-size: 0.88rem;
}
.preview-row .value {
color: var(--text);
font-size: 0.88rem;
}
.preview-list {
max-height: 120px;
overflow-y: auto;
background: rgba(255, 255, 255, 0.8);
border-radius: 12px;
padding: 8px 12px;
border: 1px solid var(--line);
}
.preview-list-item {
padding: 5px 0;
border-bottom: 1px solid var(--line);
font-size: 0.85rem;
}
.preview-list-item:last-child {
border-bottom: none;
}
.preview-code {
background: rgba(0, 0, 0, 0.04);
padding: 10px;
border-radius: 10px;
font-family: "Consolas", "Monaco", monospace;
font-size: 0.8rem;
overflow-x: auto;
white-space: pre-wrap;
color: var(--text);
}
.btn-group {
display: flex;
gap: 8px;
margin-top: 12px;
}
.btn-group button {
flex: 1;
}
.secondary-btn {
background: rgba(15, 118, 110, 0.1);
color: var(--accent);
border: 1px solid rgba(15, 118, 110, 0.3);
}
.secondary-btn:hover:not(:disabled) {
background: rgba(15, 118, 110, 0.15);
}
</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="btn-group" style="margin-top: 8px; margin-bottom: 16px;">
<button id="analyzeBtn" class="secondary-btn" onclick="analyzeDeep()">深度分析</button>
</div>
<!-- 提取结果预览 -->
<div id="extractionPreview" class="preview-panel" style="display: none;">
<div class="preview-header" onclick="togglePreview()">
<h3>LLM 提取结果</h3>
<span id="previewToggleIcon"></span>
</div>
<div id="previewContent" class="preview-content">
<div class="preview-section">
<h4>基本信息</h4>
<div class="preview-row">
<span class="label">场景 ID:</span>
<span id="previewSceneId" class="value"></span>
</div>
<div class="preview-row">
<span class="label">场景名称:</span>
<span id="previewSceneName" class="value"></span>
</div>
<div class="preview-row">
<span class="label">场景类型:</span>
<span id="previewSceneKind" class="value"></span>
</div>
<div class="preview-row">
<span class="label">目标域名:</span>
<span id="previewExpectedDomain" class="value"></span>
</div>
</div>
<div class="preview-section">
<h4>API 端点 (<span id="previewApiCount">0</span>)</h4>
<div id="previewApiEndpoints" class="preview-list"></div>
</div>
<div class="preview-section">
<h4>列定义 (<span id="previewColumnCount">0</span>)</h4>
<div id="previewColumnDefs" class="preview-list"></div>
</div>
<div class="preview-section">
<h4>静态参数</h4>
<pre id="previewStaticParams" class="preview-code"></pre>
</div>
<div class="preview-section">
<h4>业务逻辑</h4>
<div class="preview-row">
<span class="label">数据获取:</span>
<span id="previewDataFetch" class="value"></span>
</div>
<div class="preview-row">
<span class="label">数据转换:</span>
<span id="previewDataTransform" class="value"></span>
</div>
</div>
</div>
</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 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"),
settingOutputRoot: document.getElementById("settingOutputRoot"),
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;
let currentSceneInfo = null; // Stores deep extraction results
let previewExpanded = 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";
}
updateGenerateBtn();
} catch (err) {
console.error("Failed to load defaults:", err);
setState("error", "无法连接服务器");
appendRow("error", `服务器连接失败: ${err.message}`);
}
}
async function analyzeSourceDir(sourceDir) {
if (!sourceDir) return;
setState("analyzing", "正在分析场景目录...");
appendRow("status", "开始分析场景目录...");
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 || "Analyze failed");
if (data.sceneId) {
els.sceneId.value = data.sceneId;
}
if (data.sceneName) {
els.sceneName.value = data.sceneName;
}
appendRow("status", `分析完成: ${data.sceneId || ""} ${data.sceneName || ""}`.trim());
} catch (err) {
appendRow("error", `分析失败: ${err.message}`);
} finally {
setState("ready", "就绪");
updateGenerateBtn();
}
}
function togglePreview() {
const content = document.getElementById("previewContent");
const icon = document.getElementById("previewToggleIcon");
previewExpanded = !previewExpanded;
content.style.display = previewExpanded ? "block" : "none";
icon.textContent = previewExpanded ? "▲" : "▼";
}
function showExtractionPreview(data) {
const panel = document.getElementById("extractionPreview");
panel.style.display = "block";
previewExpanded = true;
document.getElementById("previewContent").style.display = "block";
document.getElementById("previewToggleIcon").textContent = "▲";
// Basic info
document.getElementById("previewSceneId").textContent = data.sceneId || "-";
document.getElementById("previewSceneName").textContent = data.sceneName || "-";
document.getElementById("previewSceneKind").textContent = data.sceneKind || "-";
document.getElementById("previewExpectedDomain").textContent = data.expectedDomain || "-";
// API endpoints
const apiList = document.getElementById("previewApiEndpoints");
const apiCount = document.getElementById("previewApiCount");
if (data.apiEndpoints && data.apiEndpoints.length > 0) {
apiCount.textContent = data.apiEndpoints.length;
apiList.innerHTML = data.apiEndpoints.map(ep =>
`<div class="preview-list-item">${escapeHtml(ep)}</div>`
).join("");
} else {
apiCount.textContent = "0";
apiList.innerHTML = '<div class="preview-list-item" style="color: var(--muted);">无</div>';
}
// Column definitions
const colList = document.getElementById("previewColumnDefs");
const colCount = document.getElementById("previewColumnCount");
if (data.columnDefs && data.columnDefs.length > 0) {
colCount.textContent = data.columnDefs.length;
colList.innerHTML = data.columnDefs.map(col =>
`<div class="preview-list-item">${escapeHtml(col)}</div>`
).join("");
} else {
colCount.textContent = "0";
colList.innerHTML = '<div class="preview-list-item" style="color: var(--muted);">无</div>';
}
// Static params
const staticParams = document.getElementById("previewStaticParams");
if (data.staticParams) {
staticParams.textContent = JSON.stringify(data.staticParams, null, 2);
} else {
staticParams.textContent = "{}";
}
// Business logic
const biz = data.businessLogic || {};
document.getElementById("previewDataFetch").textContent = biz.dataFetch || "-";
document.getElementById("previewDataTransform").textContent = biz.dataTransform || "-";
}
function escapeHtml(str) {
if (!str) return "";
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
async function analyzeDeep() {
const sourceDir = els.sourceDir.value.trim().replace(/\\/g, "/");
if (!sourceDir) {
setValidation("请先选择场景目录");
return;
}
setValidation("");
setState("analyzing", "正在深度分析...");
appendRow("status", "开始深度分析场景...");
try {
const res = await fetch(`${SERVER_URL}/analyze-deep`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sourceDir }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Deep analysis failed");
// Store the scene info for generation
currentSceneInfo = data;
// Fill in form fields if not already set
if (data.sceneId && !els.sceneId.value.trim()) {
els.sceneId.value = data.sceneId;
}
if (data.sceneName && !els.sceneName.value.trim()) {
els.sceneName.value = data.sceneName;
}
// Show preview
showExtractionPreview(data);
appendRow("status", `深度分析完成: 找到 ${data.apiEndpoints?.length || 0} 个 API 端点, ${data.columnDefs?.length || 0} 个列定义`);
} catch (err) {
appendRow("error", `深度分析失败: ${err.message}`);
} finally {
setState("ready", "就绪");
updateGenerateBtn();
}
}
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, "/");
if (!sourceDir || !sceneId || !sceneName || !outputRoot) { setValidation("场景目录、scene-id、scene-name、输出根路径为必填"); return; }
setValidation("");
setState("generating", "正在生成 skill 包...");
els.generateBtn.disabled = true;
appendRow("status", "开始生成 skill 包...");
try {
const requestBody = {
sourceDir,
sceneId,
sceneName,
sceneKind,
targetUrl: targetUrl || null,
outputRoot,
};
// Include deep extraction results if available
if (currentSceneInfo) {
requestBody.sceneInfoJson = JSON.stringify(currentSceneInfo);
}
const res = await fetch(`${SERVER_URL}/generate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(requestBody),
});
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;
const parts = path.replace(/\\/g, "/").split("/");
const folderName = parts[parts.length - 1];
if (folderName && !els.sceneId.value) {
els.sceneId.value = folderName;
}
updateGenerateBtn();
await analyzeSourceDir(path.replace(/\\/g, "/"));
}
});
els.browseOutputRoot.addEventListener("click", async () => {
const path = await selectFolder(els.settingOutputRoot.value || null);
if (path) {
els.settingOutputRoot.value = path;
updateGenerateBtn();
}
});
els.generateBtn.addEventListener("click", generate);
els.sceneId.addEventListener("input", updateGenerateBtn);
els.sceneName.addEventListener("input", updateGenerateBtn);
loadDefaults();
</script>
</body>
</html>