init
This commit is contained in:
322
src/laya_router/dashboard.html
Normal file
322
src/laya_router/dashboard.html
Normal file
@@ -0,0 +1,322 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Laya 路由看板</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,520;9..144,680&family=IBM+Plex+Mono:wght@400;500&family=Source+Sans+3:wght@400;600&display=swap" rel="stylesheet" />
|
||||
<style>
|
||||
:root {
|
||||
--ink: #12140f;
|
||||
--panel: #1c2118;
|
||||
--paper: #efe7d6;
|
||||
--muted: #b4ad9c;
|
||||
--line: rgba(239, 231, 214, 0.14);
|
||||
--amber: #e2a322;
|
||||
--moss: #8eae6a;
|
||||
--cinnabar: #e06a45;
|
||||
--simple: #8eae6a;
|
||||
--normal: #e2a322;
|
||||
--complex: #e06a45;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; height: 100%; }
|
||||
body {
|
||||
background:
|
||||
radial-gradient(900px 420px at 0% 0%, rgba(226, 163, 34, 0.16), transparent 55%),
|
||||
linear-gradient(180deg, #171b14 0%, var(--ink) 40%);
|
||||
color: var(--paper);
|
||||
font-family: "Source Sans 3", "Songti SC", serif;
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 22px 28px 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
h1 {
|
||||
font-family: Fraunces, "Songti SC", serif;
|
||||
font-weight: 520;
|
||||
font-size: 34px;
|
||||
letter-spacing: -0.03em;
|
||||
margin: 0;
|
||||
}
|
||||
.sub { color: var(--muted); margin: 4px 0 0; font-size: 14px; }
|
||||
.live {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.dot {
|
||||
width: 9px; height: 9px; border-radius: 50%;
|
||||
background: var(--moss);
|
||||
box-shadow: 0 0 0 0 rgba(142, 174, 106, 0.7);
|
||||
animation: pulse 1.8s infinite;
|
||||
}
|
||||
.dot.off { background: var(--cinnabar); animation: none; }
|
||||
@keyframes pulse {
|
||||
70% { box-shadow: 0 0 0 8px rgba(142, 174, 106, 0); }
|
||||
}
|
||||
button.toggle {
|
||||
background: transparent;
|
||||
color: var(--paper);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
padding: 6px 12px;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
button.toggle.on { border-color: var(--amber); color: var(--amber); }
|
||||
main {
|
||||
display: grid;
|
||||
grid-template-columns: 320px 1fr;
|
||||
height: calc(100% - 92px);
|
||||
}
|
||||
aside, section.stage { overflow: auto; }
|
||||
aside { border-right: 1px solid var(--line); padding: 12px; }
|
||||
.session {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
margin-bottom: 8px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
.session:hover, .session.active { background: var(--panel); border-color: var(--line); }
|
||||
.session .top, .meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.snippet {
|
||||
margin: 8px 0 0;
|
||||
font-size: 15px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.stage { padding: 22px 28px 48px; }
|
||||
.empty { color: var(--muted); padding-top: 12vh; max-width: 36em; }
|
||||
.turn {
|
||||
display: grid;
|
||||
grid-template-columns: 28px 1fr;
|
||||
gap: 14px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
.rail { position: relative; }
|
||||
.rail i {
|
||||
display: block;
|
||||
width: 12px; height: 12px;
|
||||
border-radius: 50%;
|
||||
margin-top: 8px;
|
||||
background: var(--amber);
|
||||
}
|
||||
.rail::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 5px; top: 24px; bottom: -22px;
|
||||
width: 1px;
|
||||
background: var(--line);
|
||||
}
|
||||
.turn:last-child .rail::before { display: none; }
|
||||
.card {
|
||||
background: rgba(28, 33, 24, 0.88);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
padding: 16px 18px 18px;
|
||||
}
|
||||
.card.arrive { animation: rise 0.45s ease; }
|
||||
@keyframes rise {
|
||||
from { transform: translateY(8px); opacity: 0; }
|
||||
to { transform: none; opacity: 1; }
|
||||
}
|
||||
.prompt { margin: 8px 0 14px; white-space: pre-wrap; line-height: 1.45; }
|
||||
.level {
|
||||
font-family: Fraunces, serif;
|
||||
font-size: 28px;
|
||||
margin: 0;
|
||||
text-transform: lowercase;
|
||||
}
|
||||
.bars, .meters { display: grid; gap: 8px; }
|
||||
.bars { margin-bottom: 14px; }
|
||||
.row { display: grid; grid-template-columns: 92px 1fr 52px; gap: 8px; align-items: center; font-size: 13px; }
|
||||
.track { height: 8px; background: rgba(239, 231, 214, 0.08); border-radius: 99px; overflow: hidden; }
|
||||
.fill { height: 100%; width: 0; border-radius: inherit; transition: width 0.45s ease; }
|
||||
.fill.simple { background: var(--simple); }
|
||||
.fill.normal { background: var(--normal); }
|
||||
.fill.complex { background: var(--complex); }
|
||||
.simple { color: var(--simple); }
|
||||
.normal { color: var(--normal); }
|
||||
.complex { color: var(--complex); }
|
||||
.advice { margin: 12px 0 0; color: var(--amber); font-size: 14px; }
|
||||
.num { font-family: "IBM Plex Mono", monospace; text-align: right; color: var(--muted); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div>
|
||||
<h1>路由值班室</h1>
|
||||
<p class="sub" id="status">正在连接本地 Laya…</p>
|
||||
</div>
|
||||
<div class="live">
|
||||
<span class="dot" id="dot"></span>
|
||||
<span id="clock">等待事件</span>
|
||||
<button class="toggle on" id="follow" type="button">跟随最新</button>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<aside id="sessions"></aside>
|
||||
<section class="stage" id="stage"></section>
|
||||
</main>
|
||||
<script>
|
||||
const LEVELS = ["simple", "normal", "complex"];
|
||||
const METERS = [
|
||||
["needs_plan", "需要计划"],
|
||||
["needs_repo", "需要读仓库"],
|
||||
["needs_web", "需要查文档"],
|
||||
["needs_deep_reasoning", "需要深推理"],
|
||||
];
|
||||
const state = { records: [], follow: true, selected: null, connected: false };
|
||||
|
||||
function keyOf(record) {
|
||||
if (record.session_id) return record.session_id;
|
||||
return `loose:${record.client || "unknown"}:${record.cwd || ""}`;
|
||||
}
|
||||
function labelOf(record) {
|
||||
const client = record.client || "未知客户端";
|
||||
if (!record.session_id) return `${client} · 未带会话号`;
|
||||
return client;
|
||||
}
|
||||
function when(ts) {
|
||||
if (!ts) return "";
|
||||
return new Date(ts).toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
function pct(value) {
|
||||
return `${Math.round(Number(value || 0) * 100)}%`;
|
||||
}
|
||||
function advice(record) {
|
||||
const bits = [];
|
||||
if (record.level === "simple") bits.push("直接处理,不必铺开");
|
||||
if (record.level === "normal") bits.push("按常规查看相关代码");
|
||||
if (record.level === "complex") bits.push("先调查,必要时再做计划");
|
||||
if (record.needs_plan >= 0.7) bits.push("计划分数高");
|
||||
if (record.needs_repo >= 0.7) bits.push("先读仓库");
|
||||
if (record.needs_web >= 0.7) bits.push("查当前文档");
|
||||
if (record.needs_deep_reasoning >= 0.7) bits.push("先权衡再改");
|
||||
return bits.join(" · ");
|
||||
}
|
||||
function sessions() {
|
||||
const groups = new Map();
|
||||
for (const record of state.records) {
|
||||
const key = keyOf(record);
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key).push(record);
|
||||
}
|
||||
return [...groups.entries()].sort((a, b) => {
|
||||
const ta = a[1][a[1].length - 1].ts || "";
|
||||
const tb = b[1][b[1].length - 1].ts || "";
|
||||
return ta < tb ? 1 : -1;
|
||||
});
|
||||
}
|
||||
function bar(name, value, klass) {
|
||||
const width = Math.max(0, Math.min(100, Number(value || 0) * 100));
|
||||
return `<div class="row"><span>${name}</span><div class="track"><div class="fill ${klass}" style="width:${width}%"></div></div><span class="num">${pct(value)}</span></div>`;
|
||||
}
|
||||
function render() {
|
||||
const groups = sessions();
|
||||
if (state.follow && groups.length) state.selected = groups[0][0];
|
||||
const aside = document.getElementById("sessions");
|
||||
aside.innerHTML = groups.map(([key, items]) => {
|
||||
const last = items[items.length - 1];
|
||||
const snippet = (last.prompt || "未记录原文").replace(/\s+/g, " ").slice(0, 72);
|
||||
return `<button class="session ${key === state.selected ? "active" : ""}" data-key="${encodeURIComponent(key)}" type="button">
|
||||
<div class="top"><span>${labelOf(last)}</span><span class="${last.level}">${last.level}</span></div>
|
||||
<p class="snippet">${escapeHtml(snippet)}</p>
|
||||
<div class="top"><span>${items.length} 次判断</span><span>${when(last.ts)}</span></div>
|
||||
</button>`;
|
||||
}).join("") || `<p class="empty">还没有记录。</p>`;
|
||||
|
||||
const stage = document.getElementById("stage");
|
||||
const group = groups.find(([key]) => key === state.selected);
|
||||
if (!group) {
|
||||
stage.innerHTML = `<p class="empty">在 Codex 或 Claude Code 里提交一条任务后,这里会按会话展开每一次判断。概率没有温度校准,只作为现场提示。</p>`;
|
||||
return;
|
||||
}
|
||||
const [key, items] = group;
|
||||
const head = items[items.length - 1];
|
||||
stage.innerHTML = `<p class="meta"><span>${escapeHtml(labelOf(head))}</span><span>${escapeHtml(head.cwd || "")}</span></p>
|
||||
<p class="sub">${escapeHtml(key)}</p>
|
||||
${items.map((record, index) => `
|
||||
<article class="turn">
|
||||
<div class="rail"><i class="${record.level}" style="background:var(--${record.level})"></i></div>
|
||||
<div class="card">
|
||||
<div class="meta"><span>第 ${index + 1} 步 · ${when(record.ts)}</span><span>${record.elapsed_ms ?? "?"} ms · ${record.device || ""}</span></div>
|
||||
<h2 class="level ${record.level}">${record.level}</h2>
|
||||
<p class="prompt">${escapeHtml(record.prompt || "原文未写入日志")}</p>
|
||||
<div class="bars">
|
||||
${LEVELS.map((level) => bar(level, (record.level_probabilities || {})[level], level)).join("")}
|
||||
</div>
|
||||
<div class="meters">
|
||||
${METERS.map(([field, name]) => bar(name, record[field], record[field] >= 0.7 ? "complex" : "normal")).join("")}
|
||||
</div>
|
||||
<p class="advice">${escapeHtml(advice(record))}</p>
|
||||
</div>
|
||||
</article>`).join("")}`;
|
||||
if (state.follow) stage.scrollTop = stage.scrollHeight;
|
||||
}
|
||||
function escapeHtml(value) {
|
||||
return String(value).replace(/[&<>"']/g, (ch) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[ch]));
|
||||
}
|
||||
function remember(record) {
|
||||
const id = `${record.ts}|${record.prompt_sha256}|${record.session_id || ""}`;
|
||||
if (state.records.some((item) => `${item.ts}|${item.prompt_sha256}|${item.session_id || ""}` === id)) return;
|
||||
state.records.push(record);
|
||||
document.getElementById("clock").textContent = when(record.ts);
|
||||
render();
|
||||
}
|
||||
document.getElementById("follow").addEventListener("click", (event) => {
|
||||
state.follow = !state.follow;
|
||||
event.currentTarget.classList.toggle("on", state.follow);
|
||||
render();
|
||||
});
|
||||
document.getElementById("sessions").addEventListener("click", (event) => {
|
||||
const button = event.target.closest("button[data-key]");
|
||||
if (!button) return;
|
||||
state.follow = false;
|
||||
document.getElementById("follow").classList.remove("on");
|
||||
state.selected = decodeURIComponent(button.dataset.key);
|
||||
render();
|
||||
});
|
||||
async function boot() {
|
||||
const response = await fetch("/api/feed");
|
||||
const body = await response.json();
|
||||
state.records = body.records || [];
|
||||
document.getElementById("status").textContent = `${body.model || "laya"} · ${body.device || "设备未知"} · 问题版本 ${body.questions_version || "?"}`;
|
||||
render();
|
||||
const source = new EventSource("/events");
|
||||
source.onopen = () => {
|
||||
state.connected = true;
|
||||
document.getElementById("dot").classList.remove("off");
|
||||
};
|
||||
source.onerror = () => {
|
||||
state.connected = false;
|
||||
document.getElementById("dot").classList.add("off");
|
||||
};
|
||||
source.onmessage = (event) => remember(JSON.parse(event.data));
|
||||
}
|
||||
boot();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user