Files
laya-hook/src/laya_router/dashboard.html
2026-09-24 14:27:07 +08:00

482 lines
15 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>Laya 路由</title>
<style>
:root {
--canvas: #f5f5f7;
--surface: rgba(255, 255, 255, 0.78);
--label: #1d1d1f;
--secondary: #6e6e73;
--tertiary: #86868b;
--line: rgba(0, 0, 0, 0.08);
--fill: rgba(0, 0, 0, 0.06);
--blue: #0071e3;
--simple: #248a3d;
--normal: #0071e3;
--complex: #c93400;
--shadow: 0 12px 40px rgba(0, 0, 0, 0.06);
color-scheme: light;
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
body {
background: var(--canvas);
color: var(--label);
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "PingFang SC", "Helvetica Neue", sans-serif;
font-size: 15px;
line-height: 1.45;
letter-spacing: -0.011em;
}
button, input { font: inherit; color: inherit; }
button { cursor: pointer; }
:focus-visible {
outline: 2px solid var(--blue);
outline-offset: 2px;
}
.app {
display: grid;
grid-template-columns: 300px 1fr;
height: 100%;
}
.sidebar {
display: flex;
flex-direction: column;
min-height: 0;
background: rgba(255, 255, 255, 0.55);
backdrop-filter: saturate(180%) blur(20px);
border-right: 1px solid var(--line);
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 18px 16px 10px;
}
h1 {
margin: 0;
font-size: 22px;
font-weight: 650;
letter-spacing: -0.022em;
}
.status {
margin: 2px 0 0;
color: var(--secondary);
font-size: 12px;
line-height: 1.35;
}
.live {
width: 8px;
height: 8px;
border-radius: 50%;
background: #34c759;
flex: none;
}
.live.off { background: #ff3b30; }
.list {
overflow: auto;
padding: 4px 8px 20px;
min-height: 0;
}
.session {
display: block;
width: 100%;
text-align: left;
background: transparent;
border: 0;
border-radius: 10px;
padding: 10px 10px 11px;
margin-bottom: 2px;
}
.session:hover { background: rgba(0, 0, 0, 0.04); }
.session.active { background: var(--blue); color: white; }
.session.active .quiet,
.session.active .level { color: rgba(255, 255, 255, 0.82); }
.rowline {
display: flex;
justify-content: space-between;
gap: 8px;
align-items: baseline;
}
.quiet { color: var(--tertiary); font-size: 12px; }
.snippet {
margin: 3px 0 0;
font-size: 14px;
line-height: 1.35;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.stage {
min-width: 0;
min-height: 0;
overflow: auto;
padding: 28px 8vw 64px;
}
.stage-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
margin-bottom: 22px;
}
.stage-head h2 {
margin: 0;
font-size: 28px;
font-weight: 650;
letter-spacing: -0.025em;
}
.path {
margin: 6px 0 0;
color: var(--secondary);
font-size: 13px;
word-break: break-all;
}
.switch {
display: inline-flex;
align-items: center;
gap: 8px;
background: white;
border: 1px solid var(--line);
border-radius: 999px;
padding: 6px 10px 6px 12px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}
.switch input { accent-color: var(--blue); width: 16px; height: 16px; }
.stage-controls {
display: inline-flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.segmented {
display: inline-flex;
background: white;
border: 1px solid var(--line);
border-radius: 999px;
padding: 3px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}
.segmented button {
border: 0;
background: transparent;
padding: 5px 12px;
border-radius: 999px;
font-size: 13px;
color: var(--secondary);
transition: background 0.18s, color 0.18s;
}
.segmented button.active {
background: var(--blue);
color: white;
}
.empty {
max-width: 36rem;
color: var(--secondary);
font-size: 17px;
line-height: 1.5;
padding-top: 12vh;
}
.turn { margin: 0 0 28px; }
.card {
background: var(--surface);
backdrop-filter: saturate(180%) blur(20px);
border-radius: 18px;
box-shadow: var(--shadow);
padding: 18px 18px 16px;
}
.card.arrive { animation: rise 0.38s cubic-bezier(0.2, 0.8, 0.2, 1); }
@keyframes rise {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: none; }
}
.when { margin: 0; color: var(--tertiary); font-size: 12px; }
.tag {
display: inline-block;
margin-right: 6px;
padding: 0 6px;
border-radius: 999px;
background: var(--fill);
color: var(--secondary);
font-size: 12px;
}
.prompt {
margin: 10px 0 16px;
font-size: 17px;
line-height: 1.45;
letter-spacing: -0.018em;
white-space: pre-wrap;
}
.split {
display: flex;
height: 10px;
border-radius: 99px;
overflow: hidden;
background: var(--fill);
}
.split span { display: block; height: 100%; width: 0; transition: width 0.45s cubic-bezier(0.2, 0.8, 0.2, 1); }
.split .simple { background: #34c759; }
.split .normal { background: #007aff; }
.split .complex { background: #ff9f0a; }
.legend {
display: flex;
gap: 14px;
margin: 8px 0 16px;
color: var(--secondary);
font-size: 12px;
}
.legend b { font-weight: 600; color: var(--label); }
.meter { margin: 0 0 9px; }
.meter .rowline { margin-bottom: 4px; font-size: 13px; }
.track { height: 6px; border-radius: 99px; background: var(--fill); overflow: hidden; }
.track i {
display: block;
height: 100%;
width: 0;
border-radius: inherit;
background: var(--blue);
transition: width 0.45s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.track.hot i { background: #ff9f0a; }
.note { margin: 12px 0 0; color: var(--secondary); font-size: 14px; }
@media (max-width: 800px) {
.app { grid-template-columns: 1fr; grid-template-rows: minmax(180px, 34vh) 1fr; }
.sidebar { border-right: 0; border-bottom: 1px solid var(--line); }
.stage { padding: 20px 16px 48px; }
}
@media (prefers-reduced-motion: reduce) {
.card.arrive { animation: none; }
.split span, .track i, .segmented button { transition: none; }
}
</style>
</head>
<body>
<div class="app">
<aside class="sidebar">
<div class="toolbar">
<div>
<h1>路由</h1>
<p class="status" id="status">正在连接</p>
</div>
<span class="live" id="dot" title="实时连接"></span>
</div>
<div class="list" id="sessions"></div>
</aside>
<section class="stage" id="stage"></section>
</div>
<script>
const LEVELS = [
["simple", "简单"],
["normal", "普通"],
["complex", "复杂"],
];
const METERS = [
["needs_plan", "先做计划"],
["needs_repo", "先看仓库"],
["needs_web", "先查文档"],
["needs_deep_reasoning", "先想清楚"],
];
const EVENTS = {
UserPromptSubmit: "用户提问",
PreToolUse: "工具前",
PostToolUse: "工具后",
Stop: "结束检查",
judge: "主动判定",
};
const ACTIONS = {
inject: "注入",
deny: "拦截",
ask: "询问",
block: "拦截",
silent: "沉默",
};
const SORT_KEY = "laya-router:turn-sort";
const state = {
records: [],
follow: true,
selected: null,
fresh: new Set(),
sort: localStorage.getItem(SORT_KEY) === "asc" ? "asc" : "desc",
};
function keyOf(record) {
if (record.session_id) return record.session_id;
return `loose:${record.client || "unknown"}:${record.cwd || ""}`;
}
function titleOf(record) {
const client = { codex: "Codex", claude: "Claude", cli: "命令行" }[record.client] || record.client || "未知来源";
return record.session_id ? client : `${client},没有会话号`;
}
function when(ts) {
if (!ts) return "";
return new Date(ts).toLocaleString("zh-CN", { hour12: false, month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit" });
}
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 escapeHtml(value) {
return String(value).replace(/[&<>"']/g, (ch) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[ch]));
}
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, " ");
const levelName = LEVELS.find(([id]) => id === last.level)?.[1] || ACTIONS[last.action] || EVENTS[last.event] || last.level || "";
return `<button class="session ${key === state.selected ? "active" : ""}" data-key="${encodeURIComponent(key)}" type="button">
<div class="rowline"><span>${escapeHtml(titleOf(last))}</span><span class="level quiet">${escapeHtml(levelName)}</span></div>
<p class="snippet">${escapeHtml(snippet)}</p>
<div class="rowline"><span class="quiet">${items.length} 次判断</span><span class="quiet">${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];
const followChecked = state.follow ? "checked" : "";
const ordered = state.sort === "desc"
? items.map((r, i) => [i, r]).reverse()
: items.map((r, i) => [i, r]);
stage.innerHTML = `<div class="stage-head">
<div>
<h2>${escapeHtml(titleOf(head))}</h2>
<p class="path">${escapeHtml(head.cwd || "没有记录工作目录")}</p>
</div>
<div class="stage-controls">
<div class="segmented" role="group" aria-label="排序方式">
<button type="button" data-sort="desc" class="${state.sort === "desc" ? "active" : ""}" aria-pressed="${state.sort === "desc"}">最新在前</button>
<button type="button" data-sort="asc" class="${state.sort === "asc" ? "active" : ""}" aria-pressed="${state.sort === "asc"}">最早在前</button>
</div>
<label class="switch"><input id="follow" type="checkbox" ${followChecked} /> 跟随最新</label>
</div>
</div>
${ordered.map(([index, record]) => {
const id = recordId(record);
const arrive = state.fresh.has(id) ? "arrive" : "";
state.fresh.delete(id);
const probs = record.level_probabilities || {};
const levelName = LEVELS.find(([name]) => name === record.level)?.[1] || record.level;
const eventLabel = EVENTS[record.event] || (record.event ? record.event : "用户提问");
const actionLabel = ACTIONS[record.action] || "";
const hasLevel = Boolean(record.level);
const distribution = hasLevel ? `<div class="split" role="img" aria-label="复杂度分布">
${LEVELS.map(([name]) => `<span class="${name}" style="width:${Math.max(0, Math.min(100, Number(probs[name] || 0) * 100))}%"></span>`).join("")}
</div>
<div class="legend">
${LEVELS.map(([name, label]) => `<span>${label} <b>${pct(probs[name])}</b></span>`).join("")}
</div>
${METERS.map(([field, label]) => {
const value = Number(record[field] || 0);
return `<div class="meter"><div class="rowline"><span>${label}</span><span class="quiet">${pct(value)}</span></div><div class="track ${value >= 0.7 ? "hot" : ""}"><i style="width:${Math.max(0, Math.min(100, value * 100))}%"></i></div></div>`;
}).join("")}
<p class="note">${escapeHtml(levelName)}。${escapeHtml(advice(record))}</p>` : "";
const hint = !hasLevel && record.hint ? `<p class="note">${escapeHtml(record.hint)}</p>` : "";
return `<article class="turn">
<div class="card ${arrive}">
<p class="when"><span class="tag">${escapeHtml(eventLabel)}</span>${actionLabel ? `<span class="tag">${escapeHtml(actionLabel)}</span>` : ""}第 ${index + 1} 次 · ${when(record.ts)} · ${record.elapsed_ms ?? "?"} 毫秒</p>
<p class="prompt">${escapeHtml(record.prompt || "原文没有写入日志")}</p>
${distribution}
${hint}
</div>
</article>`;
}).join("")}`;
const follow = document.getElementById("follow");
if (follow) {
follow.addEventListener("change", () => {
state.follow = follow.checked;
render();
});
}
const segmented = stage.querySelector(".segmented");
if (segmented) {
segmented.addEventListener("click", (event) => {
const button = event.target.closest("button[data-sort]");
if (!button || button.classList.contains("active")) return;
state.sort = button.dataset.sort === "asc" ? "asc" : "desc";
localStorage.setItem(SORT_KEY, state.sort);
render();
});
}
if (state.follow) {
stage.scrollTop = state.sort === "desc" ? 0 : stage.scrollHeight;
}
}
function recordId(record) {
return `${record.seq || ""}|${record.ts}|${record.prompt_sha256}|${record.session_id || ""}|${record.event || ""}|${record.tool_name || ""}`;
}
function remember(record) {
const id = recordId(record);
if (state.records.some((item) => recordId(item) === id)) return;
state.fresh.add(id);
state.records.push(record);
render();
}
document.getElementById("sessions").addEventListener("click", (event) => {
const button = event.target.closest("button[data-key]");
if (!button) return;
state.follow = false;
state.selected = decodeURIComponent(button.dataset.key);
render();
});
async function boot() {
const response = await fetch("/api/feed");
if (!response.ok) {
document.getElementById("status").textContent = "读不到记录";
return;
}
const body = await response.json();
state.records = body.records || [];
const device = body.device || "设备未知";
document.getElementById("status").textContent = `${device} · 已连接`;
render();
const source = new EventSource("/events");
source.onopen = () => document.getElementById("dot").classList.remove("off");
source.onerror = () => {
document.getElementById("dot").classList.add("off");
document.getElementById("status").textContent = "实时连接中断,正在重试";
};
source.onmessage = (event) => {
document.getElementById("status").textContent = `${device} · 已连接`;
remember(JSON.parse(event.data));
};
}
boot();
</script>
</body>
</html>