diff --git a/src/web/js/settings.js b/src/web/js/settings.js
index 4202bb1..3d68cb8 100644
--- a/src/web/js/settings.js
+++ b/src/web/js/settings.js
@@ -632,9 +632,7 @@ const ENV_DESC = {
RAG_CHUNK_OVERLAP: "RAG 分块重叠大小(字符)。",
GROUNDING_WIDTH: "GUI 定位图像宽度(像素)。",
GROUNDING_HEIGHT: "GUI 定位图像高度(像素)。",
- LOOP_DETECT_TODOLIST_STALE_ROUNDS: "TodoList 过期检测轮数,超过该轮数未更新视为失效。",
LOOP_DETECT_REPEATED_TOOL_WARN: "同一工具连续重复调用超过该次数时输出警告。",
- LOOP_DETECT_REPEATED_TOOL_END: "同一工具连续重复调用超过该次数时强制终止任务。",
SEND_FILE_SIZE_LIMIT: "发送文件大小上限(MB)。",
STORAGE_BACKEND: "存储后端:json(本地文件)或 mysql(数据库)。",
MYSQL_HOST: "MySQL 主机地址。",
diff --git a/src/web/js/themes.js b/src/web/js/themes.js
index 3374e9d..7ae7be5 100644
--- a/src/web/js/themes.js
+++ b/src/web/js/themes.js
@@ -718,6 +718,9 @@ export function applyTheme(themeId) {
const isDark = theme.dark ?? !(themeId.includes("light") || themeId.includes("solarized"));
document.body.classList.toggle("theme-dark", isDark);
document.body.classList.toggle("theme-light", !isDark);
+
+ // 通知依赖主题色的组件重绘(如 Token 活跃度矩阵)
+ document.dispatchEvent(new CustomEvent("theme-changed"));
}
/** 初始化:先用默认主题渲染,再从服务端 theme_config.json 恢复全部设置 */
diff --git a/src/web/js/think-level.js b/src/web/js/think-level.js
index 771edc8..6cc3f26 100644
--- a/src/web/js/think-level.js
+++ b/src/web/js/think-level.js
@@ -3,7 +3,7 @@
// low=agent+不思考 high=plan+不思考 xhigh=agent+思考 max=plan+思考
// ultra 为未来 react→审批图预留占位(暂按 agent+思考 处理)
// 持久化在后端 env_config.json 的 THINKING_LEVEL(/api/config/thinking-level),
-// 由 agent_runtime._build_llm_for_agent 决定 ChatQwen 的 extra_body。
+// 由 agent_runtime._build_llm_for_agent 决定 Multimodel_LLM 的 extra_body。
// 交互通过 document 级事件委托实现(触发器开关 + 档位点击 + 滑条拖动)。
import { $, showToast, closeInlinePanelsExcept } from './utils.js';
@@ -84,6 +84,9 @@ export function syncThinkLevelUI() {
if (trigger) {
trigger.title = THINK_LEVEL_TIPS[state.thinkingLevel] || "思考模式";
trigger.dataset.value = state.thinkingLevel;
+ // 按钮右侧小字:当前思考等级
+ const badge = trigger.querySelector(".think-mode-badge");
+ if (badge) badge.textContent = state.thinkingLevel;
}
}
diff --git a/src/web/js/usage-stats.js b/src/web/js/usage-stats.js
new file mode 100644
index 0000000..c2b6d6e
--- /dev/null
+++ b/src/web/js/usage-stats.js
@@ -0,0 +1,354 @@
+// usage-stats.js -- Token 用量活跃度卡
+// GitHub 风格矩阵(横=周一到周日,竖=周,近半年)+ 点击方块切换为
+// 该日按小时柱状图(同一柱内按模型分深浅)。颜色随主题:0 级灰色(--border),
+// 1~5 级基于 --accent 的透明度阶梯。
+
+import { api } from './api.js';
+
+const GRID_DAYS = 182; // 最近半年
+const WEEKS = 26; // 182 / 7
+
+let _rgb = null; // 缓存的 --accent RGB
+let _usage = null; // 最近一次 /api/stats/usage 数据
+let _view = "matrix"; // matrix | bars
+let _selectedDate = ""; // 柱状图选中的日期 YYYY-MM-DD
+let _models = []; // 全局模型列表(按用量降序,用于柱内分色稳定)
+
+function _accentRgb() {
+ if (_rgb) return _rgb;
+ let hex = "";
+ try {
+ hex = getComputedStyle(document.documentElement).getPropertyValue("--accent").trim();
+ } catch { /* ignore */ }
+ if (!hex) hex = "#4f8ff7";
+ hex = hex.replace("#", "");
+ if (hex.length === 3) hex = hex.split("").map((c) => c + c).join("");
+ const n = parseInt(hex, 16);
+ if (Number.isNaN(n)) { _rgb = [79, 143, 247]; return _rgb; }
+ _rgb = [(n >> 16) & 255, (n >> 8) & 255, n & 255];
+ return _rgb;
+}
+
+/** 等级颜色:0 返回 null(走 CSS 灰色 var(--border)),1~5 为 accent 透明度阶梯 */
+function _levelColor(level) {
+ if (level <= 0) return null;
+ const [r, g, b] = _accentRgb();
+ const alpha = 0.18 + level * 0.16; // 0.34 ~ 0.98
+ return `rgba(${r}, ${g}, ${b}, ${Math.min(alpha, 0.98)})`;
+}
+
+/** 模型在柱内的深浅(0.35 ~ 0.95,模型越多区分度越大) */
+function _modelColor(index, count) {
+ const [r, g, b] = _accentRgb();
+ const alpha = count <= 1 ? 0.85 : 0.35 + (index / Math.max(count - 1, 1)) * 0.6;
+ return `rgba(${r}, ${g}, ${b}, ${Math.min(alpha, 0.95)})`;
+}
+
+function _fmtDate(d) {
+ const m = String(d.getMonth() + 1).padStart(2, "0");
+ const day = String(d.getDate()).padStart(2, "0");
+ return `${d.getFullYear()}-${m}-${day}`;
+}
+
+/** 所在周的周一(JS getDay 0=周日,转周一=0) */
+function _startOfWeek(d) {
+ const x = new Date(d.getFullYear(), d.getMonth(), d.getDate());
+ const dow = (x.getDay() + 6) % 7;
+ x.setDate(x.getDate() - dow);
+ return x;
+}
+
+/** 按非 0 用量的分位数计算 total -> level(0..5) 的映射函数 */
+function _buildLeveler(totals) {
+ const vals = [...new Set(totals.filter((t) => t > 0))].sort((a, b) => a - b);
+ if (!vals.length) return () => 0;
+ const p = (q) => vals[Math.min(vals.length - 1, Math.floor(vals.length * q))];
+ // 去重后取 5 个严格递增的阈值
+ const thresholds = [];
+ for (const q of [0.25, 0.5, 0.75, 0.9, 1]) {
+ const v = p(q);
+ if (!thresholds.length || v > thresholds[thresholds.length - 1]) thresholds.push(v);
+ }
+ return (t) => {
+ if (t <= 0) return 0;
+ let lvl = 1;
+ for (let i = 0; i < thresholds.length; i++) if (t >= thresholds[i]) lvl = i + 2;
+ return Math.min(lvl, 5);
+ };
+}
+
+const DOW_NAMES = ["一", "二", "三", "四", "五", "六", "日"];
+
+/** 按周组织 182 天网格(GitHub 风格):columns[0] 最老、columns[25] 最新(最右),
+ * 每列 7 格 = 周一到周日(周一在最上)。每列 label 为月份(每月首周显示 "M月")。 */
+function _buildColumns(days) {
+ const dayMap = {};
+ (days || []).forEach((d) => { dayMap[d.date] = d.total || 0; });
+ const today = new Date();
+ const todayKey = _fmtDate(today);
+ const lastMonday = _startOfWeek(today);
+ const columns = [];
+ let lastMonth = -1;
+ for (let w = WEEKS - 1; w >= 0; w--) {
+ const weekStart = new Date(lastMonday.getTime() - w * 7 * 86400000);
+ const cells = [];
+ for (let dow = 0; dow < 7; dow++) {
+ const d = new Date(weekStart.getTime() + dow * 86400000);
+ const key = _fmtDate(d);
+ cells.push({ date: key, total: dayMap[key] || 0, future: key > todayKey });
+ }
+ const m = weekStart.getMonth();
+ columns.push({
+ label: m !== lastMonth ? `${m + 1}月` : "",
+ startDate: _fmtDate(weekStart),
+ cells,
+ });
+ lastMonth = m;
+ }
+ return columns;
+}
+
+function _card() {
+ return document.getElementById("usageCard");
+}
+
+async function _loadUsage() {
+ try {
+ const data = await api("/api/stats/usage");
+ _usage = data || {};
+ _models = Object.entries((data && data.by_model) || {})
+ .sort((a, b) => b[1] - a[1])
+ .map(([m]) => m);
+ } catch {
+ _usage = null;
+ }
+}
+
+function _renderMatrix() {
+ const card = _card();
+ if (!card) return;
+ _view = "matrix";
+ card.innerHTML = "";
+
+ const header = document.createElement("div");
+ header.className = "usage-card-header";
+ const title = document.createElement("span");
+ title.textContent = "Token 活跃度(近半年,点击方块查看每小时用量)";
+ header.appendChild(title);
+
+ const legend = document.createElement("div");
+ legend.className = "usage-legend";
+ legend.innerHTML = '
少' +
+ '
' +
+ '
' +
+ '
' +
+ '
' +
+ '
多';
+ header.appendChild(legend);
+ card.appendChild(header);
+
+ const body = document.createElement("div");
+ body.className = "usage-matrix-body";
+
+ if (!_usage || !Array.isArray(_usage.days) || _usage.days.length === 0) {
+ const empty = document.createElement("div");
+ empty.className = "usage-empty";
+ empty.textContent = "暂无 Token 用量记录,发送消息后这里会出现你的活跃度。";
+ body.appendChild(empty);
+ card.appendChild(body);
+ return;
+ }
+
+ // 等级色样(legend 与格子一致,随主题)
+ card.querySelectorAll(".legend-cell").forEach((el) => {
+ const lvl = el.classList.contains("legend-cell--l2") ? 2 : el.classList.contains("legend-cell--l4") ? 4 : el.classList.contains("legend-cell--l5") ? 5 : 1;
+ const color = _levelColor(lvl);
+ if (color) el.style.background = color;
+ });
+
+ const leveler = _buildLeveler(_usage.days.map((d) => d.total || 0));
+ const columns = _buildColumns(_usage.days);
+
+ // 网格:第一列星期标签 + 26 列周;第一行月标签
+ const grid = document.createElement("div");
+ grid.className = "usage-grid";
+
+ // 第一行:空角 + 每列月标签
+ const corner = document.createElement("div");
+ corner.className = "usage-dow-label";
+ grid.appendChild(corner);
+ columns.forEach((col) => {
+ const lbl = document.createElement("div");
+ lbl.className = "usage-col-label";
+ lbl.textContent = col.label;
+ lbl.title = col.startDate;
+ grid.appendChild(lbl);
+ });
+
+ // 7 行:星期标签 + 26 格(横=周,竖=周一到周日,周一在最上)
+ for (let dow = 0; dow < 7; dow++) {
+ const dowLabel = document.createElement("div");
+ dowLabel.className = "usage-dow-label";
+ dowLabel.textContent = DOW_NAMES[dow];
+ grid.appendChild(dowLabel);
+ columns.forEach((col) => {
+ const cell = col.cells[dow];
+ const el = document.createElement("div");
+ el.className = "usage-cell" + (cell.future ? " usage-cell--empty" : "");
+ if (cell.future) {
+ grid.appendChild(el);
+ return;
+ }
+ const lvl = leveler(cell.total);
+ const color = _levelColor(lvl);
+ if (color) el.style.background = color;
+ // 任意日期都可点击查看当天每小时柱状图(0 值日期显示空态)
+ el.title = `${cell.date} · ${cell.total.toLocaleString()} tokens`;
+ el.addEventListener("click", () => _renderBars(cell.date));
+ grid.appendChild(el);
+ });
+ }
+
+ body.appendChild(grid);
+ card.appendChild(body);
+}
+
+function _renderBars(date) {
+ const card = _card();
+ if (!card) return;
+ _view = "bars";
+ _selectedDate = date;
+ card.innerHTML = "";
+
+ const hours = (_usage && _usage.hours && _usage.hours[date]) || {};
+ const header = document.createElement("div");
+ header.className = "usage-card-header";
+
+ const title = document.createElement("span");
+ title.textContent = `${date} · 每小时 Token 用量(同柱按模型分色)`;
+
+ const backBtn = document.createElement("button");
+ backBtn.type = "button";
+ backBtn.className = "usage-back-btn";
+ backBtn.title = "返回矩阵";
+ backBtn.style.display = "inline-flex"; // 柱状图视图下显示(CSS 默认隐藏)
+ const backIcon = document.createElement("img");
+ backIcon.src = "/image/撤回.svg";
+ backIcon.alt = "返回矩阵";
+ backIcon.className = "usage-back-icon";
+ backBtn.appendChild(backIcon);
+ backBtn.addEventListener("click", () => _renderMatrix());
+ header.appendChild(title);
+ header.appendChild(backBtn);
+ card.appendChild(header);
+
+ const body = document.createElement("div");
+ body.className = "usage-bars-wrap";
+
+ const entries = Object.entries(hours).map(([h, byModel]) => {
+ let total = 0;
+ const parts = [];
+ // 柱内模型顺序与全局一致(用量多的排下),深浅递增
+ _models.forEach((m) => {
+ const v = byModel[m] || 0;
+ if (v > 0) parts.push({ model: m, value: v });
+ });
+ // 未在全局列表中的模型(异常)兜底
+ Object.entries(byModel).forEach(([m, v]) => {
+ if (v > 0 && !_models.includes(m)) parts.push({ model: m, value: v });
+ });
+ total = parts.reduce((s, p) => s + p.value, 0);
+ return { hour: h, total, parts };
+ });
+
+ if (!entries.length) {
+ const empty = document.createElement("div");
+ empty.className = "usage-empty";
+ empty.textContent = "这一天没有 Token 用量记录。";
+ body.appendChild(empty);
+ card.appendChild(body);
+ return;
+ }
+
+ const maxTotal = Math.max(...entries.map((e) => e.total), 1);
+ const barsRow = document.createElement("div");
+ barsRow.className = "usage-bars";
+
+ // 24 小时补齐空柱(视觉连续)
+ for (let h = 0; h < 24; h++) {
+ const key = String(h).padStart(2, "0");
+ const e = entries.find((x) => x.hour === key);
+ const bar = document.createElement("div");
+ bar.className = "usage-bar";
+ if (!e) {
+ bar.style.background = "transparent";
+ bar.title = `${key}:00 · 0 tokens`;
+ barsRow.appendChild(bar);
+ continue;
+ }
+ bar.style.background = "rgba(0,0,0,0.03)";
+ const segTotal = e.total;
+ const heightPct = (segTotal / maxTotal) * 100;
+ const segContainer = document.createElement("div");
+ segContainer.style.cssText = `height:${Math.max(heightPct, 1)}%;display:flex;flex-direction:column;justify-content:flex-end;`;
+ e.parts.forEach((p, idx) => {
+ const seg = document.createElement("div");
+ seg.style.cssText = `background:${_modelColor(idx, e.parts.length)};flex:${p.value};`;
+ seg.title = `${key}:00 · ${p.model} ${p.value.toLocaleString()} tokens`;
+ segContainer.appendChild(seg);
+ });
+ bar.appendChild(segContainer);
+ bar.title = `${key}:00 · 共 ${segTotal.toLocaleString()} tokens`;
+ barsRow.appendChild(bar);
+ }
+ body.appendChild(barsRow);
+
+ const axis = document.createElement("div");
+ axis.className = "usage-bar-axis";
+ [0, 6, 12, 18, 23].forEach((h) => {
+ const s = document.createElement("span");
+ s.textContent = `${h}:00`;
+ axis.appendChild(s);
+ });
+ body.appendChild(axis);
+
+ const legend = document.createElement("div");
+ legend.className = "usage-legend";
+ legend.style.marginTop = "0.5rem";
+ if (_models.length > 1) {
+ legend.innerHTML = _models.map((m, i) =>
+ `
${m}`
+ ).join("
");
+ } else {
+ legend.textContent = "模型:" + (_models[0] || "未知");
+ }
+ body.appendChild(legend);
+
+ card.appendChild(body);
+}
+
+/**
+ * 加载并渲染活跃度卡(保持当前视图:矩阵 / 柱状图的选中日期)。
+ * 仅当欢迎区可见时渲染;渲染完成后取消卡的 hidden,使其显示。
+ */
+export async function refreshUsageCard() {
+ const card = _card();
+ if (!card) return;
+ const welcome = document.getElementById("welcomeView");
+ if (welcome && welcome.hidden) return;
+ await _loadUsage();
+ if (_view === "bars" && _selectedDate) _renderBars(_selectedDate);
+ else _renderMatrix();
+ card.hidden = false;
+}
+
+/**
+ * 初始化:绑定主题切换重绘;若欢迎区可见则加载一次。
+ */
+export async function initUsageCard() {
+ document.addEventListener("theme-changed", () => {
+ _rgb = null;
+ refreshUsageCard();
+ });
+ refreshUsageCard();
+}
diff --git a/src/web/js/welcome-slogans.js b/src/web/js/welcome-slogans.js
new file mode 100644
index 0000000..b33af0c
--- /dev/null
+++ b/src/web/js/welcome-slogans.js
@@ -0,0 +1,105 @@
+// welcome-slogans.js -- 分时段欢迎标语
+// 按当前小时分组随机展示一条;支持 {h}(如 "23")与 {hh}(如 "23")占位符,
+// 渲染时替换为当前小时,实现随实时时间变化的标语。
+
+const SLOGANS = {
+ dawn: { // 00:00 - 04:59
+ range: [0, 5],
+ items: [
+ "夜深了,注意休息,让灵感在梦里继续生长 🌙",
+ "现在是 {h} 点,深夜的代码最安静,但也要记得睡觉",
+ "深夜模式开启:理性思考,理性入睡",
+ "月亮都困了,任务可以留给明天",
+ "万籁俱寂,此刻的专注格外珍贵",
+ "深夜不是赶工的借口,休息也是效率的一部分",
+ "把难题留给白天的自己,晚安",
+ ],
+ },
+ morning: { // 05:00 - 07:59
+ range: [5, 8],
+ items: [
+ "早上好,新的一天从一杯水开始 ☀️",
+ "清晨 {h} 点,头脑最清醒的时刻,适合处理最难的问题",
+ "早起的鸟儿有虫吃,早起的你有 Agent 帮忙",
+ "今天的效率,从清晨的一杯咖啡开始",
+ "早安,世界和你都值得被温柔以待",
+ "清晨的阳光和待办清单,都排好队了",
+ "新的一天,新的任务,新的可能",
+ ],
+ },
+ forenoon: { // 08:00 - 10:59
+ range: [8, 11],
+ items: [
+ "上午好,活力满满的黄金时段 ✨",
+ "现在是 {h} 点,专注的上午适合攻坚",
+ "上午的效率决定一天的节奏,加油",
+ "把最重要的事放在上午做",
+ "元气满满的上午,适合处理大事",
+ "上午好!Agent 已经就绪,随时可以开工",
+ "趁精力充沛,先把硬骨头啃下来",
+ ],
+ },
+ noon: { // 11:00 - 12:59
+ range: [11, 13],
+ items: [
+ "中午好,记得吃午饭 🍚",
+ "现在是 {h} 点,午休一下,下午更高效",
+ "午饭时间到,工作暂时放一放",
+ "中午好!吃饱才有力气干活",
+ "忙碌一上午,是时候犒劳自己了",
+ "午间小憩 20 分钟,下午满血复活",
+ "中午好,别忘了喝水",
+ ],
+ },
+ afternoon: { // 13:00 - 16:59
+ range: [13, 17],
+ items: [
+ "下午好,继续加油 💪",
+ "现在是 {h} 点,下午的节奏由你掌控",
+ "下午茶时间,效率与悠闲可以并存",
+ "下午好!午睡醒来又是一条好汉",
+ "午后时光,适合处理琐碎但重要的小事",
+ "阳光正好,任务进度也要跟上",
+ "下午好,坚持就是胜利",
+ ],
+ },
+ evening: { // 17:00 - 18:59
+ range: [17, 19],
+ items: [
+ "傍晚好,今天辛苦啦 🌆",
+ "现在是 {h} 点,日落时分适合复盘今天",
+ "傍晚好!总结一下今天的收获吧",
+ "夕阳无限好,只是任务还没完",
+ "傍晚是一天中最温柔的时分",
+ "辛苦一天了,晚餐值得好好吃",
+ ],
+ },
+ night: { // 19:00 - 23:59
+ range: [19, 24],
+ items: [
+ "晚上好,夜生活从此刻开始 🌃",
+ "现在是 {h} 点,晚上的灵感常常不请自来",
+ "晚上好!可以处理一些轻松的收尾工作",
+ "夜幕降临,专注力回笼",
+ "晚上好,记得留点时间给自己",
+ "深夜将至,任务进度过半了吗?",
+ "晚上好!Agent 陪你把事情做完",
+ ],
+ },
+};
+
+/**
+ * 按当前小时返回一条随机标语(已替换时间占位符)。
+ * @param {Date} [now]
+ * @returns {string}
+ */
+export function getSlogan(now = new Date()) {
+ const h = now.getHours();
+ const groups = Object.values(SLOGANS);
+ const group = groups.find((g) => h >= g.range[0] && h < g.range[1]) || SLOGANS.night;
+ const items = group.items;
+ const text = items[Math.floor(Math.random() * items.length)];
+ return text
+ .replace(/\{hh\}/g, String(h).padStart(2, "0"))
+ .replace(/\{h\}/g, String(h));
+}
diff --git a/src/web/server.py b/src/web/server.py
index 01a7362..1ca09bf 100644
--- a/src/web/server.py
+++ b/src/web/server.py
@@ -1668,6 +1668,20 @@ def api_get_session_tokens(session_id: str) -> dict[str, Any]:
}
+# ---------- 全局 Token 用量统计 API ----------
+@app.get("/api/stats/usage")
+def api_get_usage_stats(days: int = 182) -> dict[str, Any]:
+ """获取全局 token 用量统计(跨会话累加,欢迎区活跃度矩阵 / 柱状图数据源)。
+
+ 输出:
+ {"total": int, "by_model": {model: int},
+ "days": [{"date": str, "total": int, "by_model": {model: int}}],
+ "hours": {"YYYY-MM-DD": {"HH": {model: int}}}}
+ """
+ from agent.core.usage_stats import get_usage_summary
+ return get_usage_summary(days=max(1, min(days, 366)))
+
+
# ---------- 工具调用历史 API ----------
@app.get("/api/tool-calls/{session_id}/live")
def api_get_live_tool_calls(session_id: str) -> dict[str, Any]: