482 lines
15 KiB
HTML
482 lines
15 KiB
HTML
<!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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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>
|