-
+
+
-
-
-
-
-
-
-
-
+
+
+
+
+
Token 统计
+
按时间筛选本机调用记录(stats/*.jsonl)
+
+
-
-
-
-
-
- | 时间 |
- caller |
- model |
- prompt |
- completion |
- total |
- cached |
-
-
-
-
+
+
+
+
+
+
+
+
+
+ | 时间 |
+ caller |
+ model |
+ prompt |
+ completion |
+ total |
+ cached |
+
+
+
+
+
+ 这段时间没有调用记录
+ 换个区间,或先在对话里跑一轮再回来。
+
+
+
+
diff --git a/web/stats.js b/web/stats.js
index 6d61c91..ab7fa04 100644
--- a/web/stats.js
+++ b/web/stats.js
@@ -13,7 +13,31 @@ function formatAt(iso) {
if (!iso) return "—";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
- return d.toLocaleString();
+ return d.toLocaleString("zh-CN", {
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ hour12: false,
+ });
+}
+
+function formatNum(n) {
+ if (n == null || Number.isNaN(Number(n))) return "—";
+ return Number(n).toLocaleString("zh-CN");
+}
+
+function setStatus(text, state = "idle") {
+ const status = document.getElementById("status");
+ status.textContent = text;
+ status.dataset.state = state;
+}
+
+function setRangeActive(id) {
+ for (const btn of document.querySelectorAll(".stats-range .btn-secondary")) {
+ btn.classList.toggle("is-active", btn.id === id);
+ }
}
async function loadStats() {
@@ -25,17 +49,26 @@ async function loadStats() {
if (to) qs.set("to", to);
qs.set("limit", String(limit));
- const status = document.getElementById("status");
- status.textContent = "加载中…";
+ setStatus("加载中…", "loading");
try {
const res = await fetch(`/api/stats/tokens?${qs}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
+ const records = data.records || [];
renderSummary(data.summary);
- renderRows(data.records || []);
- status.textContent = `共 ${data.summary?.totalCalls ?? 0} 次调用 · 显示 ${(data.records || []).length} 条`;
+ renderRows(records);
+ const totalCalls = data.summary?.totalCalls ?? 0;
+ setStatus(
+ `共 ${formatNum(totalCalls)} 次调用 · 显示 ${formatNum(records.length)} 条`,
+ "idle",
+ );
} catch (err) {
- status.textContent = `加载失败:${err instanceof Error ? err.message : String(err)}`;
+ renderSummary(null);
+ renderRows([]);
+ setStatus(
+ `加载失败:${err instanceof Error ? err.message : String(err)}`,
+ "error",
+ );
}
}
@@ -46,32 +79,47 @@ function renderSummary(summary) {
return;
}
const metrics = [
- ["调用次数", summary.totalCalls],
- ["总 token", summary.totalTokens],
- ["prompt", summary.promptTokens],
- ["completion", summary.completionTokens],
- ["cached", summary.totalCached],
+ ["调用次数", summary.totalCalls, null],
+ ["总 token", summary.totalTokens, "total"],
+ ["prompt", summary.promptTokens, null],
+ ["completion", summary.completionTokens, null],
+ ["cached", summary.totalCached, null],
];
el.innerHTML = metrics
.map(
- ([label, value]) =>
- `
${label}${value ?? 0}
`,
+ ([label, value, emphasis]) =>
+ `
+ ${label}
+ ${formatNum(value ?? 0)}
+
`,
)
.join("");
}
function renderRows(records) {
const tbody = document.getElementById("rows");
+ const empty = document.getElementById("empty");
+ const table = tbody.closest("table");
+
+ if (!records.length) {
+ tbody.innerHTML = "";
+ if (table) table.hidden = true;
+ empty.hidden = false;
+ return;
+ }
+
+ if (table) table.hidden = false;
+ empty.hidden = true;
tbody.innerHTML = records
.map(
(r) => `
- | ${formatAt(r.at)} |
- ${escapeHtml(r.caller || "")} |
- ${escapeHtml(r.model || "")} |
- ${r.promptTokens ?? 0} |
- ${r.completionTokens ?? 0} |
- ${r.totalTokens ?? 0} |
- ${r.cachedTokens ?? "—"} |
+ ${formatAt(r.at)} |
+ ${escapeHtml(r.caller || "—")} |
+ ${escapeHtml(r.model || "—")} |
+ ${formatNum(r.promptTokens ?? 0)} |
+ ${formatNum(r.completionTokens ?? 0)} |
+ ${formatNum(r.totalTokens ?? 0)} |
+ ${r.cachedTokens == null ? "—" : formatNum(r.cachedTokens)} |
`,
)
.join("");
@@ -90,24 +138,33 @@ function setRange(fromDate, toDate) {
document.getElementById("to").value = toLocalInputValue(toDate);
}
-document.getElementById("btn-load").addEventListener("click", () => loadStats());
+document.getElementById("btn-load").addEventListener("click", () => {
+ setRangeActive("");
+ loadStats();
+});
document.getElementById("btn-today").addEventListener("click", () => {
const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0);
setRange(start, now);
+ setRangeActive("btn-today");
loadStats();
});
document.getElementById("btn-7d").addEventListener("click", () => {
const now = new Date();
const start = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
setRange(start, now);
+ setRangeActive("btn-7d");
loadStats();
});
+document.getElementById("from").addEventListener("change", () => setRangeActive(""));
+document.getElementById("to").addEventListener("change", () => setRangeActive(""));
+
// 默认:今天
(() => {
const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0);
setRange(start, now);
+ setRangeActive("btn-today");
loadStats();
})();
diff --git a/web/styles.css b/web/styles.css
index d900516..47f1632 100644
--- a/web/styles.css
+++ b/web/styles.css
@@ -1,13 +1,14 @@
/* Functional layout â agent-first, no production-line chrome */
:root {
- --bg: #f4f4f5;
+ /* Default = modern;实际以色主题 data-color-theme 为准 */
+ --bg: #f8f8f8;
--surface: #fff;
- --border: #d4d4d8;
- --text: #18181b;
- --muted: #71717a;
- --accent: #2563eb;
- --accent-soft: #dbeafe;
+ --border: #e0e0e0;
+ --text: #2c2c2c;
+ --muted: #999999;
+ --accent: #4a9fb0;
+ --accent-soft: rgba(74, 159, 176, 0.12);
--ok: #16a34a;
--warn: #ca8a04;
--danger: #dc2626;
@@ -22,11 +23,65 @@
--composer-control-h: 36px;
--font-ui: "Noto Sans SC", "PingFang SC", "Microsoft YaHei UI", system-ui, sans-serif;
--font-read: "Noto Serif SC", "Songti SC", "STSong", "Source Han Serif SC", Georgia, serif;
+ --shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
font-family: var(--font-ui);
font-size: 14px;
color: var(--text);
}
+/* 仿纸暖黄(护眼)*/
+html[data-color-theme="paper"] {
+ --bg: #f5ecd7;
+ --surface: #fbf8f2;
+ --border: #d9d0c1;
+ --text: #3e3a35;
+ --muted: #8b8580;
+ --accent: #8b9a6b;
+ --accent-soft: rgba(139, 154, 107, 0.15);
+ --shadow: 0 8px 24px rgba(62, 58, 53, 0.1);
+}
+
+/* 现代清爽 */
+html[data-color-theme="modern"] {
+ --bg: #f8f8f8;
+ --surface: #fff;
+ --border: #e0e0e0;
+ --text: #2c2c2c;
+ --muted: #999999;
+ --accent: #4a9fb0;
+ --accent-soft: rgba(74, 159, 176, 0.12);
+ --shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
+}
+
+/* 夜间暗色 */
+html[data-color-theme="dark"] {
+ --bg: #1e1e1e;
+ --surface: #2d2d30;
+ --border: #3e3e40;
+ --text: #bdbbb6;
+ --muted: #757575;
+ --accent: #d4a76a;
+ --accent-soft: rgba(212, 167, 106, 0.15);
+ --shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
+}
+
+/* 豆沙绿 */
+html[data-color-theme="green"] {
+ --bg: #dce5d9;
+ --surface: #f4f9f2;
+ --border: #c0cfbb;
+ --text: #2b3330;
+ --muted: #7a8580;
+ --accent: #7d9b7a;
+ --accent-soft: rgba(125, 155, 122, 0.15);
+ --shadow: 0 8px 24px rgba(43, 51, 48, 0.1);
+}
+
+html {
+ background: var(--bg);
+ color: var(--text);
+}
+
/* å¼åä½å缩è¿å·¦ä¾§æ */
body.rail-collapsed {
--books-w: var(--books-w-collapsed);
@@ -37,6 +92,8 @@ html, body {
height: 100%;
margin: 0;
overflow: hidden; /* æµè§å¨å±æ°¸ä¸åºç°é¡µé¢æ»å¨æ¡ */
+ background: var(--bg);
+ color: var(--text);
}
button, input, textarea { font: inherit; }
a.link { color: var(--accent); text-decoration: none; }
@@ -255,7 +312,18 @@ body[data-nav-level="root"] .burst-badge { display: none; }
body.rail-collapsed .panel-rail { align-items: stretch; }
/* composer æå
容èªéåºï¼ä¸é´åºåæå©ä½é«åº¦ï¼æ´é¡µéå¨è§å£å
*/
+/* panel-main:上=悬停遮盖 chrome;下=工作区(左中右在 main-body / head 内) */
.panel-main {
+ display: grid;
+ grid-template-rows: minmax(0, 1fr);
+ min-width: 0;
+ min-height: 0;
+ height: 100%;
+ overflow: hidden;
+ position: relative;
+}
+
+.main-stack {
display: grid;
grid-template-rows: minmax(0, 1fr) auto;
min-width: 0;
@@ -264,19 +332,105 @@ body.rail-collapsed .panel-rail { align-items: stretch; }
overflow: hidden;
}
+/* 顶缘热区 + 遮盖条:不占文档流,悬停/聚焦才展开 */
+.main-chrome {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ z-index: 30;
+ height: 10px;
+ pointer-events: auto;
+}
.main-head {
display: grid;
- grid-template-columns: 1fr auto 1fr;
+ grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: center;
- gap: 12px;
- padding: 0 12px;
+ gap: 8px 12px;
+ min-height: 40px;
+ padding: 4px 12px;
border-bottom: 1px solid var(--border);
+ background: color-mix(in srgb, var(--bg) 92%, transparent);
+ backdrop-filter: blur(8px);
+ box-shadow: 0 4px 16px color-mix(in srgb, var(--text) 8%, transparent);
+ transform: translateY(calc(-100% + 10px));
+ opacity: 0;
+ pointer-events: none;
+ transition:
+ transform 0.18s ease,
+ opacity 0.14s ease;
+}
+.main-chrome:hover,
+.main-chrome:focus-within,
+.main-chrome:has(.theme-menu[open]) {
+ height: auto;
+ min-height: 40px;
+}
+.main-chrome:hover .main-head,
+.main-chrome:focus-within .main-head,
+.main-chrome:has(.theme-menu[open]) .main-head {
+ transform: translateY(0);
+ opacity: 1;
+ pointer-events: auto;
}
.main-head-left { min-width: 0; }
-.main-head-right { display: flex; align-items: center; gap: 8px; justify-content: flex-end; flex-wrap: wrap; }
+.main-app-nav {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 2px;
+ flex-wrap: wrap;
+}
+.main-app-nav a {
+ padding: 5px 10px;
+ border-radius: 6px;
+ text-decoration: none;
+ color: var(--muted);
+ font-size: 13px;
+ font-weight: 500;
+ line-height: 1.2;
+ white-space: nowrap;
+}
+.main-app-nav a:hover {
+ color: var(--text);
+ background: var(--accent-soft);
+}
+.main-app-nav a:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 1px;
+}
+.main-head-right {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ justify-content: flex-end;
+ flex-wrap: wrap;
+ min-width: 0;
+}
+.main-head-action[hidden] { display: none !important; }
.work-title { margin: 0; font-size: 16px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.work-meta { font-size: 12px; color: var(--muted); }
+@media (max-width: 720px) {
+ .main-head {
+ grid-template-columns: 1fr;
+ justify-items: stretch;
+ min-height: 0;
+ padding: 6px 10px;
+ gap: 6px;
+ }
+ .main-head-left { display: none; }
+ .main-app-nav { justify-content: flex-start; }
+ .main-head-right { justify-content: flex-start; }
+}
+
+@media (hover: none) {
+ /* 触控无悬停:顶缘仍可点开,聚焦后保持 */
+ .main-chrome {
+ height: 12px;
+ }
+}
+
.main-body {
display: grid;
grid-template-columns: minmax(0, 1fr);
@@ -1774,30 +1928,6 @@ body.design-workspace .flow-step[data-run="done"] .flow-name {
}
.sidebar-empty { padding: 12px; color: var(--muted); font-size: 13px; text-align: center; }
-/* Lifecycle */
-.lifecycle-toggle {
- display: inline-flex;
- flex: 0 0 auto;
- align-self: center;
- border: 1px solid var(--border);
- border-radius: 8px;
- padding: 2px;
- background: var(--surface);
-}
-.lifecycle-toggle[hidden] { display: none; }
-.lifecycle-btn {
- border: none; background: transparent; padding: 4px 10px; border-radius: 6px;
- font-size: 12px; cursor: pointer; color: var(--muted); font-weight: 500;
-}
-.lifecycle-btn.active { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
-.lifecycle-btn:disabled { opacity: 0.4; cursor: not-allowed; }
-.lifecycle-btn:focus-visible {
- outline: 2px solid var(--accent);
- outline-offset: 1px;
-}
-body[data-lifecycle="play"] { --accent: #16a34a; --accent-soft: #dcfce7; --bg: #f0fdf4; }
-body[data-lifecycle="play"] .panel-rail { background: #f7fef9; }
-
/* Skill picker â primary action when starting */
.skill-picker {
padding: 16px;
@@ -2201,18 +2331,36 @@ body.rail-collapsed .message-feed {
.msg-review .ws-focus-hint { margin-bottom: 0; font-size: 11px; }
.msg-review .review-worker-set { margin: 0 0 6px; }
-/* 顶栏「更多」:冷入口收纳 */
+/* 顶栏主题菜单:色主题 + 选壳 */
.more-menu {
position: relative;
display: inline-block;
+ flex: 0 0 auto;
}
.more-menu-sum {
list-style: none;
cursor: pointer;
user-select: none;
+ line-height: 1;
+ height: 28px;
+ min-width: 28px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ box-sizing: border-box;
+ padding-inline: 6px;
}
.more-menu-sum::-webkit-details-marker { display: none; }
.more-menu-sum::marker { content: ""; }
+.hamburger-icon {
+ display: block;
+ width: 14px;
+ height: 11px;
+ background:
+ linear-gradient(currentColor, currentColor) 0 0 / 100% 1.5px no-repeat,
+ linear-gradient(currentColor, currentColor) 0 50% / 100% 1.5px no-repeat,
+ linear-gradient(currentColor, currentColor) 0 100% / 100% 1.5px no-repeat;
+}
.more-menu[open] .more-menu-sum {
background: var(--accent-soft);
border-color: var(--accent);
@@ -2220,22 +2368,34 @@ body.rail-collapsed .message-feed {
}
.more-menu-panel {
position: absolute;
+ left: auto;
right: 0;
top: calc(100% + 4px);
+ bottom: auto;
z-index: 80;
- min-width: 132px;
+ min-width: 188px;
+ max-height: min(70vh, 480px);
+ overflow: auto;
padding: 4px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface);
- box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
+ box-shadow: var(--shadow);
display: flex;
flex-direction: column;
gap: 1px;
}
-.composer .more-menu-panel {
- top: auto;
- bottom: calc(100% + 4px);
+.more-menu-sep {
+ height: 1px;
+ margin: 4px 6px;
+ background: var(--border);
+}
+.more-menu-label {
+ padding: 6px 10px 2px;
+ font-size: 11px;
+ font-weight: 600;
+ letter-spacing: 0.02em;
+ color: var(--muted);
}
.more-menu-item {
display: block;
@@ -2260,6 +2420,43 @@ body.rail-collapsed .message-feed {
background: var(--bg);
color: var(--accent);
}
+.more-theme-item {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 10px;
+}
+.more-theme-item.is-active {
+ background: var(--accent-soft);
+ color: var(--accent);
+}
+.more-theme-icon {
+ flex: 0 0 auto;
+ width: 1.25em;
+ text-align: center;
+ font-size: 15px;
+ line-height: 1;
+}
+.more-theme-copy {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+ min-width: 0;
+}
+.more-theme-name {
+ font-size: 13px;
+ font-weight: 600;
+ line-height: 1.2;
+}
+.more-theme-desc {
+ font-size: 11px;
+ color: var(--muted);
+ font-weight: 400;
+ line-height: 1.2;
+}
+.more-theme-item.is-active .more-theme-desc {
+ color: color-mix(in srgb, var(--accent) 70%, var(--muted));
+}
/* 提问索引气泡:决策在下方询问卡 */
.msg-bubble-index {
@@ -4487,7 +4684,7 @@ body.design-workspace .af-mosaic {
.timeline-item { padding: 6px 0; border-bottom: 1px solid var(--border); }
.timeline-empty { color: var(--muted); }
-/* Composer — 单行:状态 / 输入 / 更多;菜单向上弹出不被裁切 */
+/* Composer — emoji 更多在左 / 输入 / 发送;菜单向上弹出不被裁切 */
.composer {
border-top: 1px solid var(--border);
padding: 6px var(--workspace-pad-x, 16px) 8px;
@@ -4507,7 +4704,8 @@ body.design-workspace .af-mosaic {
align-self: center;
min-width: 0;
}
-.composer:has(.composer-mode-chip) > .status {
+.composer:has(.composer-mode-chip) > .status,
+.composer:has(.composer-input-shell) > .status {
display: none;
}
#composer-main {
@@ -4517,17 +4715,6 @@ body.design-workspace .af-mosaic {
max-height: min(50vh, 420px);
overflow: auto;
}
-.composer > .more-menu {
- flex: 0 0 auto;
- align-self: flex-end;
- margin-bottom: 2px;
-}
-.composer .more-menu-sum {
- height: calc(var(--composer-control-h) - 8px);
- display: inline-flex;
- align-items: center;
- box-sizing: border-box;
-}
/* 唯一描边:输入 + 操作同框 */
.composer-input-shell {
flex: 1 1 auto;
diff --git a/web/theme.js b/web/theme.js
new file mode 100644
index 0000000..b054ba1
--- /dev/null
+++ b/web/theme.js
@@ -0,0 +1,116 @@
+/** Color themes + present chrome — aligned with llm_workflow_engine ThemeToggle. */
+
+export const COLOR_THEMES = [
+ {
+ id: "paper",
+ name: "仿纸暖黄",
+ description: "经典护眼,像 Kindle",
+ icon: "📖",
+ },
+ {
+ id: "modern",
+ name: "现代清爽",
+ description: "干净明快,适合网文",
+ icon: "✨",
+ },
+ {
+ id: "dark",
+ name: "夜间暗色",
+ description: "黑底灰字,夜间必备",
+ icon: "🌙",
+ },
+ {
+ id: "green",
+ name: "豆沙绿",
+ description: "自然疗愈,绿色经典",
+ icon: "🍃",
+ },
+];
+
+/** 呈现壳气质(tone_chrome),与色主题同属「主题」菜单 */
+export const PRESENT_CHROMES = [
+ { id: "default", name: "默认", description: "跟随壳本身" },
+ { id: "messenger", name: "讯息", description: "气泡对话感" },
+ { id: "book", name: "书页", description: "阅读排版感" },
+ { id: "terminal", name: "终端", description: "等宽终端感" },
+];
+
+const STORAGE_KEY = "wa-color-theme";
+const CHROME_STORAGE_KEY = "wa-present-chrome";
+const THEME_IDS = new Set(COLOR_THEMES.map((t) => t.id));
+const CHROME_IDS = new Set(PRESENT_CHROMES.map((t) => t.id));
+const DEFAULT_THEME = "modern";
+const DEFAULT_CHROME = "default";
+
+export function normalizeColorTheme(id) {
+ const v = String(id || "").trim();
+ return THEME_IDS.has(v) ? v : DEFAULT_THEME;
+}
+
+export function getStoredColorTheme() {
+ try {
+ return normalizeColorTheme(localStorage.getItem(STORAGE_KEY));
+ } catch {
+ return DEFAULT_THEME;
+ }
+}
+
+export function themeById(id) {
+ const tid = normalizeColorTheme(id);
+ return COLOR_THEMES.find((t) => t.id === tid) || COLOR_THEMES[1];
+}
+
+export function applyColorTheme(id) {
+ const theme = normalizeColorTheme(id);
+ document.documentElement.setAttribute("data-color-theme", theme);
+ document.documentElement.setAttribute("data-theme", theme);
+ try {
+ localStorage.setItem(STORAGE_KEY, theme);
+ } catch {
+ /* ignore quota / private mode */
+ }
+ return theme;
+}
+
+export function initColorTheme() {
+ return applyColorTheme(getStoredColorTheme());
+}
+
+export function normalizePresentChrome(id) {
+ const v = String(id || "").trim();
+ return CHROME_IDS.has(v) ? v : DEFAULT_CHROME;
+}
+
+export function getStoredPresentChrome() {
+ try {
+ return normalizePresentChrome(localStorage.getItem(CHROME_STORAGE_KEY));
+ } catch {
+ return DEFAULT_CHROME;
+ }
+}
+
+export function chromeById(id) {
+ const cid = normalizePresentChrome(id);
+ return PRESENT_CHROMES.find((t) => t.id === cid) || PRESENT_CHROMES[0];
+}
+
+export function applyPresentChrome(id) {
+ const chrome = normalizePresentChrome(id);
+ document.documentElement.setAttribute("data-present-chrome", chrome);
+ try {
+ localStorage.setItem(CHROME_STORAGE_KEY, chrome);
+ } catch {
+ /* ignore */
+ }
+ document.querySelectorAll(".present-shell").forEach((el) => {
+ for (const cls of [...el.classList]) {
+ if (cls.startsWith("present-tone--")) el.classList.remove(cls);
+ }
+ el.classList.add(`present-tone--${chrome}`);
+ });
+ return chrome;
+}
+
+export function initPresentChrome() {
+ return applyPresentChrome(getStoredPresentChrome());
+}