agent-loft 0.2.33 → 0.2.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -54,6 +54,7 @@ Logo 就是这个意思:一大两小几颗星(多个 agent)站在同一张
54
54
  - 侧栏按页加载:「最近」先显示 10 条、每个项目目录先显示 5 条,点「展开显示」每次再拉 10 条。
55
55
  - 侧栏底部显示 Claude / Codex 的订阅额度(5 小时 / 7 天窗口,以及多久后重置);关了额度查询就整块不显示。
56
56
  - 右上角「更多」最下面显示当前会话累计消耗的 token 分布(输入 / 缓存写入 / 缓存读取 / 输出,Claude 还有按 API 价格估算的费用)。设置也在「更多」里。在桌面端里,这个菜单并进桌面端顶栏的「更多」,排在最上面。
57
+ - 设置 → 用量:按日 / 月 / 年(默认按月)统计 token 和金额,有每天(每月)的柱状图,按 agent、模型、用户(管理员才有)分开看;成员只看到自己的。金额都是按 API 价格算的参考值:Claude 用它自己报的费用,Codex 不报费用,按模型单价(LiteLLM 公开价格表,内置一份快照、联网时每天更新)× token 估算。从 0.2.34 开始记录,之前的用量没有日期,不计入。
57
58
  - 「添加到主屏幕」后当 App 用;切到后台再回来会自动重连,补上错过的内容。
58
59
  - **系统通知**:Web 会话回复完成、出错、需要授权或提问时推送到手机 / 电脑(设置 → 通知 → 「在此设备开启通知」,可以发测试通知)。飞书会话由飞书提醒,正在前台看着的会话不推。需要 HTTPS 访问,iPhone 需 iOS 16.4+ 且从主屏幕图标打开。
59
60
  - **跨渠道同步**:飞书里的会话在 Web 上也能打开接着聊。同一个授权请求同时出现在飞书卡片和网页上,哪边先点算哪边,另一边自动变成「已处理」。
@@ -94,6 +95,7 @@ agent-loft service install # 注册为开机自启服务(systemd / laun
94
95
 
95
96
  - 同时连多台服务器,顶部标签栏切换、拖动排序;每台各自登录,切走了也在后台连着,回复完成、需要授权时弹系统通知,程序坞图标显示未读数。
96
97
  - 菜单栏图标:悬停看各服务器的 Claude / Codex 额度、待处理和运行中的会话;双击打开主窗口。同一个账号的额度只查一次。(Windows 上点托盘图标弹出;Linux 上在托盘菜单里点「额度和待处理」。)
98
+ - 菜单栏图标可以换成 5 小时额度读数(像「17% Claude | 76% Codex」):桌面端设置 →「菜单栏显示 5 小时额度」,选所有服务器合起来(取最高),或者按服务器分别选 Claude / Codex(小字带服务器名);都不选就只显示图标。只有 macOS 有,Windows / Linux 的托盘图标放不下字。
97
99
  - 支持前面挡着 Cloudflare Access 的服务器:验证页直接在窗口里完成,过期会提醒;也可以配 Service Token 免验证。
98
100
  - 首次打开可以选「这台 Mac 也作为服务端」:一键在终端里安装 agent-loft(没有 Node.js 会先用 Homebrew 装)并开机自启,装好自动连上。Linux 上一样(需要先装好 Node.js 22+,开机自启用 systemd 用户服务);Windows 上服务端还没有开机自启,可以在 WSL 里装,桌面端会自动检测到 `127.0.0.1` 上的它。可选开启「本机有会话在跑时不休眠」。
99
101
  - 设置(`⌘,`)的服务器列表里展开某台服务器,能看它的 Agent 环境和版本,有新版本时点「升级服务端」;桌面端自己的更新在同一页的「更新」里点「更新并重启」。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-loft",
3
- "version": "0.2.33",
3
+ "version": "0.2.34",
4
4
  "type": "module",
5
5
  "description": "多个 agent 的工作台:把本机 Claude Code / Codex 放到一处统一管理,用飞书和 Web 页面随时驱动——会话、审批、模型切换都共用",
6
6
  "keywords": [
@@ -17,6 +17,7 @@ import {
17
17
  isAdmin,
18
18
  legacyOwnerId,
19
19
  listUsers,
20
+ ownedBy,
20
21
  removeUser,
21
22
  verifyLogin,
22
23
  verifyPassword,
@@ -24,6 +25,7 @@ import {
24
25
  type User,
25
26
  } from "../../core/users.js";
26
27
  import type { SavedAttachment } from "../../core/types.js";
28
+ import { dailyUsage } from "../../core/usage.js";
27
29
  import {
28
30
  addMemoryItem,
29
31
  clearMemory,
@@ -855,6 +857,20 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
855
857
  sendJson(res, 200, { agents: await agentHealth(url.searchParams.get("refresh") === "1") });
856
858
  return;
857
859
  }
860
+ if (p === "/api/usage" && method === "GET") {
861
+ // 按天的 token / 费用账(设置 →「用量」)。?from=&to= 是 YYYY-MM-DD(含两头)。
862
+ // 管理员看所有人的(只有数字,看不到内容),成员只看自己对话的
863
+ const day = /^\d{4}-\d{2}-\d{2}$/;
864
+ const from = url.searchParams.get("from") ?? "";
865
+ const to = url.searchParams.get("to") ?? "";
866
+ if (!day.test(from) || !day.test(to)) throw new HttpError(400, "from / to 要是 YYYY-MM-DD");
867
+ const admin = isAdmin(user);
868
+ const rows = dailyUsage(from, to, admin ? undefined : (owner) => ownedBy(user, owner));
869
+ const names: Record<string, string> = {};
870
+ for (const r of rows) if (r.owner && !(r.owner in names)) names[r.owner] = getUser(r.owner)?.name ?? "已删除的用户";
871
+ sendJson(res, 200, { rows, users: names, admin });
872
+ return;
873
+ }
858
874
  if (p === "/api/quota" && method === "GET") {
859
875
  // ?agent=claude,codex:只问这几个。桌面端连着多台同账号的服务器时,同一个账号只在一台上问
860
876
  // 设置里关了查额度:不去问任何 agent
package/src/core/hub.ts CHANGED
@@ -649,7 +649,13 @@ class Hub extends EventEmitter<{ event: [EventEnvelope]; task: [TaskTurnEnd] }>
649
649
  if (prev?.used !== ctx.used || prev?.window !== ctx.window) this.emitEvent(key, { type: "meta" });
650
650
  },
651
651
  // 不用另发 meta:一轮结束的 result / error 会把会话信息(含用量)推给页面
652
- onUsage: (usage, cumulative) => alive() && recordUsage(holder.session?.sessionId, usage, cumulative),
652
+ onUsage: (usage, cumulative) => {
653
+ if (!alive()) return;
654
+ // 模型用来按单价估算没报费用的 agent(Codex)的金额
655
+ const current = getConversation(key);
656
+ const model = current ? this.displayRuntime(current, agentBinding(current, agent)).model : undefined;
657
+ recordUsage(holder.session?.sessionId, usage, cumulative, { owner: current?.owner, agent, model });
658
+ },
653
659
  onDropped: (n) => {
654
660
  if (!alive() || n <= 0) return;
655
661
  // 丢的是排在最后、还没开始的那 n 条(Codex 先报正在跑的那轮出错、再报丢弃,那时队头已经取走了)
@@ -0,0 +1,103 @@
1
+ {
2
+ "chat-latest": [5,0.5,30],
3
+ "computer-use-preview": [3,null,12],
4
+ "computer-use-preview-2025-03-11": [3,null,12],
5
+ "daybreak-blue-latest": [4,0.4,20],
6
+ "daybreak-red-latest": [12.5,1.25,75],
7
+ "ft:gpt-3.5-turbo": [3,null,6],
8
+ "ft:gpt-3.5-turbo-0125": [3,null,6],
9
+ "ft:gpt-3.5-turbo-0613": [3,null,6],
10
+ "ft:gpt-3.5-turbo-1106": [3,null,6],
11
+ "ft:gpt-4-0613": [30,null,60],
12
+ "ft:gpt-4.1-2025-04-14": [3,0.75,12],
13
+ "ft:gpt-4.1-mini-2025-04-14": [0.8,0.2,3.2],
14
+ "ft:gpt-4.1-nano-2025-04-14": [0.2,0.05,0.8],
15
+ "ft:gpt-4o-2024-08-06": [3.75,1.875,15],
16
+ "ft:gpt-4o-2024-11-20": [3.75,null,15],
17
+ "ft:gpt-4o-mini-2024-07-18": [0.3,0.15,1.2],
18
+ "ft:o4-mini-2025-04-16": [4,1,16],
19
+ "gpt-3.5-turbo": [0.5,null,1.5],
20
+ "gpt-3.5-turbo-0125": [0.5,null,1.5],
21
+ "gpt-3.5-turbo-1106": [1,null,2],
22
+ "gpt-3.5-turbo-16k": [3,null,4],
23
+ "gpt-4": [30,null,60],
24
+ "gpt-4-0613": [30,null,60],
25
+ "gpt-4-1106-preview": [10,null,30],
26
+ "gpt-4-turbo": [10,null,30],
27
+ "gpt-4-turbo-2024-04-09": [10,null,30],
28
+ "gpt-4.1": [2,0.5,8],
29
+ "gpt-4.1-2025-04-14": [2,0.5,8],
30
+ "gpt-4.1-mini": [0.4,0.1,1.6],
31
+ "gpt-4.1-mini-2025-04-14": [0.4,0.1,1.6],
32
+ "gpt-4.1-nano": [0.1,0.025,0.4],
33
+ "gpt-4.1-nano-2025-04-14": [0.1,0.025,0.4],
34
+ "gpt-4o": [2.5,1.25,10],
35
+ "gpt-4o-2024-05-13": [5,null,15],
36
+ "gpt-4o-2024-08-06": [2.5,1.25,10],
37
+ "gpt-4o-2024-11-20": [2.5,1.25,10],
38
+ "gpt-4o-mini": [0.15,0.075,0.6],
39
+ "gpt-4o-mini-2024-07-18": [0.15,0.075,0.6],
40
+ "gpt-5": [1.25,0.125,10],
41
+ "gpt-5-2025-08-07": [1.25,0.125,10],
42
+ "gpt-5-chat": [1.25,0.125,10],
43
+ "gpt-5-chat-latest": [1.25,0.125,10],
44
+ "gpt-5-codex": [1.25,0.125,10],
45
+ "gpt-5-mini": [0.25,0.025,2],
46
+ "gpt-5-mini-2025-08-07": [0.25,0.025,2],
47
+ "gpt-5-nano": [0.05,0.005,0.4],
48
+ "gpt-5-nano-2025-08-07": [0.05,0.005,0.4],
49
+ "gpt-5-pro": [15,null,120],
50
+ "gpt-5-pro-2025-10-06": [15,null,120],
51
+ "gpt-5.1": [1.25,0.125,10],
52
+ "gpt-5.1-2025-11-13": [1.25,0.125,10],
53
+ "gpt-5.1-chat-latest": [1.25,0.125,10],
54
+ "gpt-5.1-codex": [1.25,0.125,10],
55
+ "gpt-5.1-codex-max": [1.25,0.125,10],
56
+ "gpt-5.1-codex-mini": [0.25,0.025,2],
57
+ "gpt-5.2": [1.75,0.175,14],
58
+ "gpt-5.2-2025-12-11": [1.75,0.175,14],
59
+ "gpt-5.2-chat-latest": [1.75,0.175,14],
60
+ "gpt-5.2-codex": [1.75,0.175,14],
61
+ "gpt-5.2-pro": [21,null,168],
62
+ "gpt-5.2-pro-2025-12-11": [21,null,168],
63
+ "gpt-5.3-chat-latest": [1.75,0.175,14],
64
+ "gpt-5.3-codex": [1.75,0.175,14],
65
+ "gpt-5.4": [2.5,0.25,15],
66
+ "gpt-5.4-2026-03-05": [2.5,0.25,15],
67
+ "gpt-5.4-mini": [0.75,0.075,4.5],
68
+ "gpt-5.4-mini-2026-03-17": [0.75,0.075,4.5],
69
+ "gpt-5.4-nano": [0.2,0.02,1.25],
70
+ "gpt-5.4-nano-2026-03-17": [0.2,0.02,1.25],
71
+ "gpt-5.4-pro": [30,null,180],
72
+ "gpt-5.4-pro-2026-03-05": [30,null,180],
73
+ "gpt-5.5": [5,0.5,30],
74
+ "gpt-5.5-2026-04-23": [5,0.5,30],
75
+ "gpt-5.5-cyber": [12.5,1.25,75],
76
+ "gpt-5.5-cyber-preview": [12.5,1.25,75],
77
+ "gpt-5.5-pro": [30,null,180],
78
+ "gpt-5.5-pro-2026-04-23": [30,null,180],
79
+ "gpt-5.6": [4,0.4,20],
80
+ "gpt-5.6-cyber": [12.5,1.25,75],
81
+ "gpt-5.6-luna": [0.2,0.02,1.2],
82
+ "gpt-5.6-sol": [4,0.4,20],
83
+ "gpt-5.6-terra": [2,0.2,12],
84
+ "gpt-6-astra": [10,1,50],
85
+ "gpt-6-luna": [0.1,0.01,0.5],
86
+ "gpt-6-sol": [2,0.2,10],
87
+ "gpt-6.1-sol": [2,0.1,10],
88
+ "gpt-daybreak-blue-latest": [4,0.4,20],
89
+ "gpt-daybreak-red-latest": [12.5,1.25,75],
90
+ "gpt-rosalind-discovery": [5,0.5,25],
91
+ "o1": [15,7.5,60],
92
+ "o1-2024-12-17": [15,7.5,60],
93
+ "o1-pro": [150,null,600],
94
+ "o1-pro-2025-03-19": [150,null,600],
95
+ "o3": [2,0.5,8],
96
+ "o3-2025-04-16": [2,0.5,8],
97
+ "o3-mini": [1.1,0.55,4.4],
98
+ "o3-mini-2025-01-31": [1.1,0.55,4.4],
99
+ "o3-pro": [20,null,80],
100
+ "o3-pro-2025-06-10": [20,null,80],
101
+ "o4-mini": [1.1,0.275,4.4],
102
+ "o4-mini-2025-04-16": [1.1,0.275,4.4]
103
+ }
@@ -0,0 +1,93 @@
1
+ // 按单价估算没报费用的 agent(Codex)的金额:Token × 单价。
2
+ //
3
+ // 单价来自 LiteLLM 公开维护的价格表(model_prices_and_context_window.json),只取 OpenAI 的对话模型。
4
+ // 内置一份快照(model-prices.json,[输入, 缓存读取, 输出],美元 / 百万 token),连得上 GitHub 时
5
+ // 每天更新一次存到 data/model-prices.json,连不上就一直用快照。
6
+ // 只按基础单价算:长上下文加价、priority / flex 档位都不管,所以是估算。
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import { dataDir, readJson, writeJsonAtomic } from "../home.js";
10
+ import type { TokenUsage } from "./types.js";
11
+
12
+ /** [输入, 缓存读取(null 表示按输入算), 输出],美元 / 百万 token */
13
+ type Price = [number, number | null, number];
14
+ type Table = Record<string, Price>;
15
+ type Cache = { at: number; prices: Table };
16
+
17
+ const SOURCE = "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json";
18
+ const CACHE_FILE = () => path.join(dataDir, "model-prices.json");
19
+ const MAX_AGE = 24 * 3600_000;
20
+ /** 拉失败了隔多久再试 */
21
+ const RETRY = 3600_000;
22
+
23
+ const snapshot: Table = JSON.parse(fs.readFileSync(new URL("./model-prices.json", import.meta.url), "utf8"));
24
+ let cache: Cache | null = null;
25
+ let fetching = false;
26
+ let lastTry = 0;
27
+
28
+ function loadCache(): Cache {
29
+ return (cache ??= readJson<Cache>(CACHE_FILE(), { at: 0, prices: {} }));
30
+ }
31
+
32
+ /** 从 LiteLLM 的整张表里挑出 OpenAI 对话模型,换成每百万 token 的价格 */
33
+ export function pickOpenAiPrices(raw: Record<string, unknown>): Table {
34
+ const perM = (v: unknown): number | null =>
35
+ typeof v === "number" && Number.isFinite(v) && v >= 0 ? Math.round(v * 1e12) / 1e6 : null;
36
+ const out: Table = {};
37
+ for (const [name, entry] of Object.entries(raw)) {
38
+ if (!entry || typeof entry !== "object") continue;
39
+ const e = entry as Record<string, unknown>;
40
+ if (e.litellm_provider !== "openai" || (e.mode !== "chat" && e.mode !== "responses")) continue;
41
+ const input = perM(e.input_cost_per_token);
42
+ const output = perM(e.output_cost_per_token);
43
+ if (input === null || output === null) continue;
44
+ out[name] = [input, perM(e.cache_read_input_token_cost), output];
45
+ }
46
+ return out;
47
+ }
48
+
49
+ async function refresh(): Promise<void> {
50
+ fetching = true;
51
+ lastTry = Date.now();
52
+ try {
53
+ const res = await fetch(SOURCE, { signal: AbortSignal.timeout(30_000) });
54
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
55
+ const prices = pickOpenAiPrices((await res.json()) as Record<string, unknown>);
56
+ if (Object.keys(prices).length === 0) throw new Error("价格表里没有 OpenAI 模型");
57
+ cache = { at: Date.now(), prices };
58
+ writeJsonAtomic(CACHE_FILE(), cache);
59
+ } catch (err) {
60
+ console.warn("[pricing] 更新模型单价失败,先用内置的:", err instanceof Error ? err.message : err);
61
+ } finally {
62
+ fetching = false;
63
+ }
64
+ }
65
+
66
+ /** 过期了就在后台更新一次(不等它,这次先用手上的) */
67
+ function refreshIfStale(): void {
68
+ // 跑测试时不联网
69
+ if (process.env.NODE_TEST_CONTEXT) return;
70
+ if (fetching || Date.now() - loadCache().at < MAX_AGE || Date.now() - lastTry < RETRY) return;
71
+ void refresh();
72
+ }
73
+
74
+ /** 模型的单价。认不出来返回 undefined。名字带 openai/ 前缀、日期后缀(-2026-03-05)的也认 */
75
+ export function priceOf(model: string | undefined): Price | undefined {
76
+ if (!model) return undefined;
77
+ refreshIfStale();
78
+ const table = loadCache().prices;
79
+ const base = model.replace(/^openai\//, "");
80
+ for (const name of [base, base.replace(/-\d{4}-\d{2}-\d{2}$/, "")]) {
81
+ const p = table[name] ?? snapshot[name];
82
+ if (p) return p;
83
+ }
84
+ return undefined;
85
+ }
86
+
87
+ /** 按单价估算的金额(美元)。output 已经含推理,不再单算 reasoning;认不出模型返回 undefined */
88
+ export function estimateCost(model: string | undefined, usage: TokenUsage): number | undefined {
89
+ const p = priceOf(model);
90
+ if (!p) return undefined;
91
+ const [input, cacheRead, output] = p;
92
+ return (usage.input * input + usage.cacheRead * (cacheRead ?? input) + usage.cacheWrite * input + usage.output * output) / 1e6;
93
+ }
package/src/core/usage.ts CHANGED
@@ -4,29 +4,59 @@
4
4
  // 都按会话 id 记在这里:页面重新加载、服务重启后还在。
5
5
  // 按 agent 会话算,不按对话:/new、恢复旧会话、切 agent 之后显示的都是现在这个会话的。
6
6
  // Codex 升级前跑过的轮次没有记录,从升级后开始算。
7
+ //
8
+ // 另外按天记一本账(usage-daily.json,设置 →「用量」按日 / 月 / 年统计):每次报上来的增量记到当天、
9
+ // 按「谁的对话 + 哪个 agent + 什么模型」分开。只从有这本账开始算,之前的用量没有日期,不补。
10
+ // 没报费用的(Codex)查的时候按模型单价估一个(见 pricing.ts)。
7
11
  import path from "node:path";
8
12
  import { dataDir, readJson, writeJsonAtomic } from "../home.js";
13
+ import { estimateCost } from "./pricing.js";
9
14
  import type { TokenUsage } from "./types.js";
10
15
 
11
16
  export type UsageTotals = Required<Omit<TokenUsage, "costUsd">> & { costUsd?: number; at: number };
12
17
  type Store = Record<string, UsageTotals>;
13
18
 
19
+ /** 一天里某个人用某个 agent 的合计 */
20
+ export type DailyTotals = Required<Omit<TokenUsage, "costUsd">> & { costUsd?: number };
21
+ /** 按天的账:日期(服务器本地时间 YYYY-MM-DD)→「owner\tagent\tmodel」→ 合计。没有主人、不知道模型的是空串 */
22
+ type Ledger = Record<string, Record<string, DailyTotals>>;
23
+ /** 查询结果的一行。estimated:costUsd 是按单价估的,不是 agent 报的 */
24
+ export type DailyRow = DailyTotals & { day: string; owner?: string; agent: string; model?: string; estimated?: true };
25
+
14
26
  const FILE = () => path.join(dataDir, "usage.json");
27
+ const DAILY_FILE = () => path.join(dataDir, "usage-daily.json");
15
28
  const MAX_SESSIONS = 1000;
29
+ /** 按天的账留多久(天) */
30
+ const MAX_DAYS = 3 * 366;
16
31
 
17
32
  let store: Store | null = null;
33
+ let ledger: Ledger | null = null;
18
34
  let timer: NodeJS.Timeout | null = null;
19
35
 
20
36
  function load(): Store {
21
37
  return (store ??= readJson<Store>(FILE(), {}));
22
38
  }
23
39
 
40
+ function loadLedger(): Ledger {
41
+ return (ledger ??= readJson<Ledger>(DAILY_FILE(), {}));
42
+ }
43
+
24
44
  function write(): void {
25
45
  const s = load();
26
46
  // 只留最近用过的会话
27
47
  const keys = Object.keys(s).sort((a, b) => s[b]!.at - s[a]!.at);
28
48
  for (const k of keys.slice(MAX_SESSIONS)) delete s[k];
29
49
  writeJsonAtomic(FILE(), s);
50
+ const l = loadLedger();
51
+ const days = Object.keys(l).sort();
52
+ for (const d of days.slice(0, Math.max(0, days.length - MAX_DAYS))) delete l[d];
53
+ writeJsonAtomic(DAILY_FILE(), l);
54
+ }
55
+
56
+ /** 本地时间的 YYYY-MM-DD */
57
+ export function dayOf(ms: number): string {
58
+ const d = new Date(ms);
59
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
30
60
  }
31
61
 
32
62
  function persistSoon(): void {
@@ -52,19 +82,63 @@ const FIELDS = ["input", "cacheRead", "cacheWrite", "output", "reasoning"] as co
52
82
 
53
83
  /**
54
84
  * cumulative:报的是累计值,每项取较大的(累计值只增不减;恢复的会话万一没从原来的总数接着算,
55
- * 宁可先少算,也不把之前的再加一遍)。否则是增量,加上去
85
+ * 宁可先少算,也不把之前的再加一遍)。否则是增量,加上去。
86
+ * by:这次用量算谁的、哪个 agent 的(记进按天的账;不给就只记会话的)
56
87
  */
57
- export function recordUsage(sessionId: string | undefined, usage: TokenUsage, cumulative = false): void {
88
+ export function recordUsage(
89
+ sessionId: string | undefined,
90
+ usage: TokenUsage,
91
+ cumulative = false,
92
+ by?: { owner?: string; agent: string; model?: string },
93
+ ): void {
58
94
  if (!sessionId) return;
59
95
  const s = load();
60
96
  const t = (s[sessionId] ??= { input: 0, cacheRead: 0, cacheWrite: 0, output: 0, reasoning: 0, at: 0 });
61
- for (const f of FIELDS) t[f] = cumulative ? Math.max(t[f], num(usage[f])) : t[f] + num(usage[f]);
97
+ const delta: DailyTotals = { input: 0, cacheRead: 0, cacheWrite: 0, output: 0, reasoning: 0 };
98
+ for (const f of FIELDS) {
99
+ const next = cumulative ? Math.max(t[f], num(usage[f])) : t[f] + num(usage[f]);
100
+ delta[f] = next - t[f];
101
+ t[f] = next;
102
+ }
62
103
  const cost = num(usage.costUsd);
63
- if (cost) t.costUsd = cumulative ? Math.max(t.costUsd ?? 0, cost) : (t.costUsd ?? 0) + cost;
104
+ if (cost) {
105
+ const next = cumulative ? Math.max(t.costUsd ?? 0, cost) : (t.costUsd ?? 0) + cost;
106
+ if (next > (t.costUsd ?? 0)) delta.costUsd = next - (t.costUsd ?? 0);
107
+ t.costUsd = next;
108
+ }
64
109
  t.at = Date.now();
110
+ if (by) addDaily(t.at, by, delta);
65
111
  persistSoon();
66
112
  }
67
113
 
114
+ function addDaily(at: number, by: { owner?: string; agent: string; model?: string }, delta: DailyTotals): void {
115
+ if (!FIELDS.some((f) => delta[f] > 0) && !delta.costUsd) return;
116
+ const day = (loadLedger()[dayOf(at)] ??= {});
117
+ const d = (day[`${by.owner ?? ""}\t${by.agent}\t${by.model ?? ""}`] ??= { input: 0, cacheRead: 0, cacheWrite: 0, output: 0, reasoning: 0 });
118
+ for (const f of FIELDS) d[f] += delta[f];
119
+ if (delta.costUsd) d.costUsd = (d.costUsd ?? 0) + delta.costUsd;
120
+ }
121
+
122
+ /** 按天的账里 from ~ to(含两头,YYYY-MM-DD)的行;visible 按主人过滤 */
123
+ export function dailyUsage(from: string, to: string, visible?: (owner: string | undefined) => boolean): DailyRow[] {
124
+ const rows: DailyRow[] = [];
125
+ for (const [day, buckets] of Object.entries(loadLedger())) {
126
+ if (day < from || day > to) continue;
127
+ for (const [k, v] of Object.entries(buckets)) {
128
+ const [o = "", agent = "", model = ""] = k.split("\t");
129
+ const owner = o || undefined;
130
+ if (visible && !visible(owner)) continue;
131
+ const row: DailyRow = { day, ...(owner ? { owner } : {}), agent, ...(model ? { model } : {}), ...v };
132
+ if (!row.costUsd) {
133
+ const est = estimateCost(model, row);
134
+ if (est) Object.assign(row, { costUsd: est, estimated: true });
135
+ }
136
+ rows.push(row);
137
+ }
138
+ }
139
+ return rows.sort((a, b) => a.day.localeCompare(b.day));
140
+ }
141
+
68
142
  export function usageOf(sessionId: string | undefined): UsageTotals | undefined {
69
143
  return sessionId ? load()[sessionId] : undefined;
70
144
  }
@@ -333,3 +333,197 @@
333
333
  background: var(--code-bg);
334
334
  font-size: 12px;
335
335
  }
336
+
337
+ /* ---------- 用量(按日 / 月 / 年统计 Token 和费用) ---------- */
338
+
339
+ .usage-stats h5 {
340
+ margin: 4px 0 -4px;
341
+ font-size: 13px;
342
+ font-weight: 600;
343
+ color: var(--fg-soft);
344
+ }
345
+
346
+ .us-head {
347
+ display: flex;
348
+ align-items: center;
349
+ gap: 12px;
350
+ flex-wrap: wrap;
351
+ }
352
+
353
+ .us-head h4 {
354
+ flex: 1;
355
+ }
356
+
357
+ .us-head .seg {
358
+ display: inline-flex;
359
+ padding: 3px;
360
+ border-radius: 10px;
361
+ background: var(--bg-soft);
362
+ }
363
+
364
+ .us-head .seg button {
365
+ padding: 5px 12px;
366
+ border-radius: 8px;
367
+ font-size: 13px;
368
+ color: var(--fg-soft);
369
+ }
370
+
371
+ .us-head .seg button.on {
372
+ background: var(--bg);
373
+ color: var(--fg);
374
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
375
+ }
376
+
377
+ .us-nav {
378
+ display: inline-flex;
379
+ align-items: center;
380
+ gap: 6px;
381
+ }
382
+
383
+ .us-title {
384
+ min-width: 7.5em;
385
+ text-align: center;
386
+ font-size: 14px;
387
+ font-weight: 600;
388
+ font-variant-numeric: tabular-nums;
389
+ }
390
+
391
+ .us-empty {
392
+ padding: 24px 0;
393
+ text-align: center;
394
+ }
395
+
396
+ .us-tiles {
397
+ display: grid;
398
+ grid-template-columns: repeat(3, minmax(0, 1fr));
399
+ gap: 8px;
400
+ }
401
+
402
+ .us-tile {
403
+ display: flex;
404
+ flex-direction: column;
405
+ gap: 4px;
406
+ padding: 10px 12px;
407
+ border-radius: 10px;
408
+ background: var(--bg-soft);
409
+ }
410
+
411
+ .us-tile span {
412
+ font-size: 12px;
413
+ color: var(--fg-soft);
414
+ }
415
+
416
+ .us-tile b {
417
+ font-size: 20px;
418
+ font-weight: 600;
419
+ font-variant-numeric: tabular-nums;
420
+ }
421
+
422
+ /* 趋势:单一系列,用主题前景色;没用量的格子留空,悬停看数字 */
423
+ .us-trend {
424
+ position: relative;
425
+ padding-top: 16px;
426
+ }
427
+
428
+ .us-bars {
429
+ display: grid;
430
+ gap: 2px;
431
+ height: 120px;
432
+ align-items: end;
433
+ border-bottom: 1px solid var(--border);
434
+ }
435
+
436
+ .us-col {
437
+ height: 100%;
438
+ display: flex;
439
+ align-items: flex-end;
440
+ border-radius: 4px 4px 0 0;
441
+ }
442
+
443
+ .us-col:hover {
444
+ background: var(--bg-soft);
445
+ }
446
+
447
+ .us-col i {
448
+ width: 100%;
449
+ background: var(--fg);
450
+ opacity: 0.78;
451
+ border-radius: 4px 4px 0 0;
452
+ }
453
+
454
+ .us-col:hover i {
455
+ opacity: 1;
456
+ }
457
+
458
+ .us-axis {
459
+ display: grid;
460
+ gap: 2px;
461
+ margin-top: 4px;
462
+ font-size: 11px;
463
+ color: var(--fg-faint);
464
+ text-align: center;
465
+ font-variant-numeric: tabular-nums;
466
+ }
467
+
468
+ .us-max {
469
+ position: absolute;
470
+ top: 0;
471
+ right: 0;
472
+ font-size: 11px;
473
+ color: var(--fg-faint);
474
+ }
475
+
476
+ .usage-stats .usage {
477
+ padding: 0;
478
+ font-size: 13px;
479
+ }
480
+
481
+ .us-list {
482
+ display: flex;
483
+ flex-direction: column;
484
+ gap: 6px;
485
+ }
486
+
487
+ .us-item {
488
+ display: grid;
489
+ grid-template-columns: minmax(5em, 9em) 1fr 4.5em 4.5em;
490
+ align-items: center;
491
+ gap: 10px;
492
+ font-size: 13px;
493
+ font-variant-numeric: tabular-nums;
494
+ }
495
+
496
+ .us-name {
497
+ overflow: hidden;
498
+ text-overflow: ellipsis;
499
+ white-space: nowrap;
500
+ }
501
+
502
+ .us-meter {
503
+ height: 6px;
504
+ border-radius: 3px;
505
+ background: var(--bg-soft);
506
+ overflow: hidden;
507
+ }
508
+
509
+ .us-meter i {
510
+ display: block;
511
+ height: 100%;
512
+ background: var(--fg-soft);
513
+ border-radius: 3px;
514
+ }
515
+
516
+ .us-num,
517
+ .us-cost {
518
+ text-align: right;
519
+ }
520
+
521
+ .us-cost {
522
+ color: var(--fg-soft);
523
+ }
524
+
525
+ @media (max-width: 640px) {
526
+ .us-tiles {
527
+ grid-template-columns: 1fr 1fr;
528
+ }
529
+ }
@@ -10,6 +10,7 @@ import { feishuTab } from "./feishu.js";
10
10
  import { authTab, securityTab } from "./security.js";
11
11
  import { memoryTab } from "./memory.js";
12
12
  import { skillsTab } from "./skills.js";
13
+ import { usageTab } from "./usage.js";
13
14
  import { systemTab, usersTab } from "./users.js";
14
15
 
15
16
  // ---------- 设置 ----------
@@ -20,11 +21,12 @@ export async function openSettings(tab) {
20
21
  const panel = h("div", { class: "settings-body" });
21
22
  // 设置弹窗高度固定,切标签页时不跟着内容一伸一缩
22
23
  modal({ title: "设置", wide: true, tall: true, body: h("div", { class: "settings" }, nav, panel) });
23
- // 普通成员只能管自己的通知、密码和登录设备;其余设置都是管理员的
24
+ // 普通成员只能管自己的记忆、技能、用量、通知、密码和登录设备;其余设置都是管理员的
24
25
  if (!isAdmin()) {
25
26
  const tabs = [
26
27
  ["记忆", () => memoryTab(panel)],
27
28
  ["技能", () => skillsTab(panel)],
29
+ ["用量", () => usageTab(panel)],
28
30
  ["通知", () => notifyTab(panel)],
29
31
  ["账号", () => securityTab(panel)],
30
32
  ];
@@ -52,6 +54,7 @@ export async function openSettings(tab) {
52
54
  ["飞书机器人", () => feishuTab(cfg, panel)],
53
55
  ["授权", () => authTab(panel)],
54
56
  ["记忆", () => memoryTab(panel)],
57
+ ["用量", () => usageTab(panel)],
55
58
  ["通知", () => notifyTab(panel)],
56
59
  ["账号", () => securityTab(panel)],
57
60
  ["用户", () => usersTab(panel)],
@@ -0,0 +1,272 @@
1
+ import { fill, h } from "../lib/dom.js";
2
+ import { api } from "../lib/api.js";
3
+ import { S } from "../state.js";
4
+
5
+ // ---------- 用量:按日 / 月 / 年统计 token 和费用(数据见 src/core/usage.ts 按天的账) ----------
6
+
7
+ /** 各项的顺序和颜色跟「更多」菜单里本会话的用量一致 */
8
+ const PARTS = [
9
+ { key: "input", label: "输入", cls: "u-input" },
10
+ { key: "cacheWrite", label: "缓存写入", cls: "u-cwrite" },
11
+ { key: "cacheRead", label: "缓存读取", cls: "u-cread" },
12
+ { key: "output", label: "输出", cls: "u-output" },
13
+ ];
14
+
15
+ const UNITS = [
16
+ ["day", "按日"],
17
+ ["month", "按月"],
18
+ ["year", "按年"],
19
+ ];
20
+
21
+ const pad = (n) => String(n).padStart(2, "0");
22
+ const ymd = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
23
+
24
+ /** 1234 → 1.2k,1234567 → 1.23M */
25
+ function fmtCount(n) {
26
+ if (n >= 1e9) return `${(n / 1e9).toFixed(2)}B`;
27
+ if (n >= 1e6) return `${(n / 1e6).toFixed(n >= 1e8 ? 0 : n >= 1e7 ? 1 : 2)}M`;
28
+ if (n >= 1e3) return `${(n / 1e3).toFixed(n >= 1e5 ? 0 : 1)}k`;
29
+ return String(n);
30
+ }
31
+ const exact = (n) => n.toLocaleString("en-US");
32
+ const fmtCost = (n) => `$${n >= 100 ? n.toFixed(0) : n.toFixed(2)}`;
33
+ const tokensOf = (r) => r.input + r.cacheRead + r.cacheWrite + r.output;
34
+
35
+ /** 某个周期的起止(含两头)、标题、趋势图的格子 */
36
+ function periodOf(unit, anchor) {
37
+ const y = anchor.getFullYear();
38
+ const m = anchor.getMonth();
39
+ if (unit === "day") {
40
+ const d = ymd(anchor);
41
+ return { from: d, to: d, title: `${y}年${m + 1}月${anchor.getDate()}日`, buckets: null };
42
+ }
43
+ if (unit === "year") {
44
+ const buckets = Array.from({ length: 12 }, (_, i) => ({ key: `${y}-${pad(i + 1)}`, label: `${i + 1}月` }));
45
+ return { from: `${y}-01-01`, to: `${y}-12-31`, title: `${y}年`, buckets, slice: 7 };
46
+ }
47
+ const days = new Date(y, m + 1, 0).getDate();
48
+ const buckets = Array.from({ length: days }, (_, i) => ({ key: `${y}-${pad(m + 1)}-${pad(i + 1)}`, label: `${m + 1}月${i + 1}日` }));
49
+ return { from: buckets[0].key, to: buckets[days - 1].key, title: `${y}年${m + 1}月`, buckets, slice: 10 };
50
+ }
51
+
52
+ function shift(unit, anchor, step) {
53
+ const d = new Date(anchor);
54
+ if (unit === "day") d.setDate(d.getDate() + step);
55
+ else if (unit === "month") d.setMonth(d.getMonth() + step, 1);
56
+ else d.setFullYear(d.getFullYear() + step, 0, 1);
57
+ return d;
58
+ }
59
+
60
+ function sum(rows) {
61
+ const t = { input: 0, cacheRead: 0, cacheWrite: 0, output: 0, reasoning: 0, costUsd: 0 };
62
+ for (const r of rows) {
63
+ for (const k of Object.keys(t)) t[k] += r[k] ?? 0;
64
+ }
65
+ return t;
66
+ }
67
+
68
+ /** 按 keyOf 分组合计,Token 多的在前 */
69
+ function groupBy(rows, keyOf) {
70
+ const m = new Map();
71
+ for (const r of rows) {
72
+ const k = keyOf(r);
73
+ if (!m.has(k)) m.set(k, []);
74
+ m.get(k).push(r);
75
+ }
76
+ return [...m].map(([k, list]) => ({ key: k, ...sum(list) })).sort((a, b) => tokensOf(b) - tokensOf(a));
77
+ }
78
+
79
+ export async function usageTab(panel) {
80
+ let unit = "month";
81
+ let anchor = new Date();
82
+ const box = h("div", { class: "section usage-stats" });
83
+ fill(panel, box);
84
+
85
+ const render = async () => {
86
+ const period = periodOf(unit, anchor);
87
+ const isCurrent = period.to >= ymd(new Date());
88
+ const seg = h(
89
+ "div",
90
+ { class: "seg" },
91
+ UNITS.map(([u, label]) =>
92
+ h(
93
+ "button",
94
+ {
95
+ class: u === unit ? "on" : "",
96
+ onclick: () => {
97
+ if (u === unit) return;
98
+ unit = u;
99
+ anchor = new Date();
100
+ void render();
101
+ },
102
+ },
103
+ label,
104
+ ),
105
+ ),
106
+ );
107
+ const nav = h(
108
+ "div",
109
+ { class: "us-nav" },
110
+ h("button", { class: "btn small", "aria-label": "上一个", onclick: () => ((anchor = shift(unit, anchor, -1)), void render()) }, "‹"),
111
+ h("span", { class: "us-title" }, period.title),
112
+ h(
113
+ "button",
114
+ { class: "btn small", "aria-label": "下一个", disabled: isCurrent, onclick: () => ((anchor = shift(unit, anchor, 1)), void render()) },
115
+ "›",
116
+ ),
117
+ isCurrent ? null : h("button", { class: "btn small", onclick: () => ((anchor = new Date()), void render()) }, unit === "day" ? "今天" : unit === "month" ? "本月" : "今年"),
118
+ );
119
+ const head = h("div", { class: "us-head" }, h("h4", {}, "Token 与费用"), seg, nav);
120
+ fill(box, head, h("span", { class: "spinner" }));
121
+
122
+ let data;
123
+ try {
124
+ data = await api(`/api/usage?from=${period.from}&to=${period.to}`);
125
+ } catch (err) {
126
+ fill(box, head, h("div", { class: "error-text" }, err.message));
127
+ return;
128
+ }
129
+ const rows = data.rows;
130
+ const total = sum(rows);
131
+ const tokens = tokensOf(total);
132
+ // 估算的金额、没能算金额的 Token(认不出模型,或者是升级前没记模型的 Codex 用量)
133
+ const estimated = rows.reduce((n, r) => n + (r.estimated ? r.costUsd : 0), 0);
134
+ const unpriced = rows.reduce((n, r) => n + (r.costUsd ? 0 : tokensOf(r)), 0);
135
+ const note = h(
136
+ "div",
137
+ { class: "muted" },
138
+ "金额都是按 API 价格算的参考值,订阅用户实际不按这个扣费:Claude 用它自己报的费用;Codex 不报费用,按模型单价(LiteLLM 公开价格表,每天更新)× Token 估算,不含长上下文加价。从这个版本开始记录,之前的用量没有日期,不计入。",
139
+ unpriced ? ` 有 ${fmtCount(unpriced)} Token 认不出模型单价,没算进金额。` : "",
140
+ );
141
+ if (!tokens) {
142
+ fill(box, head, h("div", { class: "muted us-empty" }, `${period.title}没有用量记录`), note);
143
+ return;
144
+ }
145
+
146
+ const days = unit === "day" ? 1 : new Set(rows.map((r) => r.day)).size;
147
+ const tiles = h(
148
+ "div",
149
+ { class: "us-tiles" },
150
+ tile("Token 合计", fmtCount(tokens), `${exact(tokens)} tokens`),
151
+ tile(
152
+ "费用(约)",
153
+ total.costUsd ? fmtCost(total.costUsd) : "—",
154
+ total.costUsd
155
+ ? `$${total.costUsd.toFixed(4)}${estimated ? `,其中按单价估算 $${estimated.toFixed(4)}` : ""}`
156
+ : "这段时间没有能算金额的用量",
157
+ ),
158
+ unit === "day"
159
+ ? tile("输出", fmtCount(total.output), `${exact(total.output)} tokens`)
160
+ : tile("日均 Token", fmtCount(Math.round(tokens / days)), `有用量的 ${days} 天平均`),
161
+ );
162
+
163
+ const parts = PARTS.map((p) => ({ ...p, n: total[p.key] })).filter((p) => p.n > 0);
164
+ const composition = h(
165
+ "div",
166
+ { class: "usage" },
167
+ h("div", { class: "usage-bar", "aria-hidden": "true" }, parts.map((p) => h("i", { class: p.cls, style: `flex-grow:${p.n}` }))),
168
+ parts.map((p) =>
169
+ h(
170
+ "div",
171
+ { class: "usage-row", title: `${exact(p.n)} tokens` },
172
+ h("span", { class: `usage-dot ${p.cls}` }),
173
+ h("span", { class: "usage-name" }, p.label, p.key === "output" && total.reasoning ? h("small", {}, `含推理 ${fmtCount(total.reasoning)}`) : null),
174
+ h("span", { class: "usage-num" }, fmtCount(p.n)),
175
+ h("span", { class: "usage-pct" }, pct(p.n, tokens)),
176
+ ),
177
+ ),
178
+ );
179
+
180
+ const agentName = (id) => S.agents.find((a) => a.id === id)?.label ?? id;
181
+ const byAgent = groupBy(rows, (r) => r.agent).map((g) => ({ ...g, label: agentName(g.key) }));
182
+ const byModel = groupBy(rows, (r) => r.model ?? "").map((g) => ({ ...g, label: g.key || "未记录模型" }));
183
+ const byUser = data.admin
184
+ ? groupBy(rows, (r) => r.owner ?? "").map((g) => ({ ...g, label: g.key ? data.users[g.key] : "未归属(飞书等)" }))
185
+ : [];
186
+
187
+ fill(
188
+ box,
189
+ head,
190
+ tiles,
191
+ period.buckets ? trend(period, rows) : null,
192
+ h("h5", {}, "构成"),
193
+ composition,
194
+ h("h5", {}, "按 Agent"),
195
+ breakdown(byAgent, tokens),
196
+ h("h5", {}, "按模型"),
197
+ breakdown(byModel, tokens),
198
+ byUser.length > 1 || (byUser.length === 1 && byUser[0].key) ? [h("h5", {}, "按用户"), breakdown(byUser, tokens)] : null,
199
+ note,
200
+ );
201
+ };
202
+ await render();
203
+ }
204
+
205
+ function tile(label, value, title) {
206
+ return h("div", { class: "us-tile", title }, h("span", {}, label), h("b", {}, value));
207
+ }
208
+
209
+ function pct(n, total) {
210
+ const p = (n / total) * 100;
211
+ return p > 0 && p < 1 ? "<1%" : `${Math.round(p)}%`;
212
+ }
213
+
214
+ /** 趋势:月视图每天一根,年视图每月一根。悬停看具体数字 */
215
+ function trend(period, rows) {
216
+ const byKey = new Map();
217
+ for (const r of rows) {
218
+ const k = r.day.slice(0, period.slice);
219
+ const t = byKey.get(k) ?? { tokens: 0, cost: 0 };
220
+ t.tokens += tokensOf(r);
221
+ t.cost += r.costUsd ?? 0;
222
+ byKey.set(k, t);
223
+ }
224
+ const max = Math.max(...[...byKey.values()].map((t) => t.tokens), 1);
225
+ const today = ymd(new Date()).slice(0, period.slice);
226
+ const n = period.buckets.length;
227
+ return h(
228
+ "div",
229
+ { class: "us-trend", role: "img", "aria-label": `${period.title}每${period.slice === 7 ? "月" : "天"}的 Token 用量` },
230
+ h(
231
+ "div",
232
+ { class: "us-bars", style: `grid-template-columns:repeat(${n},1fr)` },
233
+ period.buckets.map((b) => {
234
+ const t = byKey.get(b.key);
235
+ const tip = t ? `${b.label} ${exact(t.tokens)} tokens${t.cost ? ` 约 ${fmtCost(t.cost)}` : ""}` : `${b.label} 没有用量`;
236
+ return h(
237
+ "div",
238
+ { class: `us-col${b.key > today ? " future" : ""}`, title: tip },
239
+ t ? h("i", { style: `height:${Math.max(2, (t.tokens / max) * 100)}%` }) : null,
240
+ );
241
+ }),
242
+ ),
243
+ h(
244
+ "div",
245
+ { class: "us-axis", style: `grid-template-columns:repeat(${n},1fr)` },
246
+ period.buckets.map((b, i) => {
247
+ // 月视图只标 1、5、10…号,免得挤
248
+ const show = period.slice === 7 || i === 0 || (i + 1) % 5 === 0;
249
+ return h("span", {}, show ? String(i + 1) : "");
250
+ }),
251
+ ),
252
+ h("div", { class: "us-max" }, `最高 ${fmtCount(max)}`),
253
+ );
254
+ }
255
+
256
+ function breakdown(groups, tokens) {
257
+ return h(
258
+ "div",
259
+ { class: "us-list" },
260
+ groups.map((g) => {
261
+ const n = tokensOf(g);
262
+ return h(
263
+ "div",
264
+ { class: "us-item", title: `${exact(n)} tokens` },
265
+ h("span", { class: "us-name" }, g.label),
266
+ h("span", { class: "us-meter", "aria-hidden": "true" }, h("i", { style: `width:${(n / tokens) * 100}%` })),
267
+ h("span", { class: "us-num" }, fmtCount(n)),
268
+ h("span", { class: "us-cost" }, g.costUsd ? fmtCost(g.costUsd) : "—"),
269
+ );
270
+ }),
271
+ );
272
+ }
@@ -46,7 +46,7 @@ function topMenuItems() {
46
46
  const c = S.current ? S.convs.get(S.current) : null;
47
47
  const chat = (c ? (c.mode ?? "work") : S.draftMode) === "chat";
48
48
  items.push({ id: "mode", label: "聊天模式", icon: "chat", checked: chat, onclick: () => void toggleMode() });
49
- items.push({ id: "schedule", label: "定时发送…", icon: "clock", onclick: () => openSchedule() });
49
+ items.push({ id: "schedule", label: "定时发送", icon: "clock", onclick: () => openSchedule() });
50
50
  }
51
51
  if (!desktopSettings) {
52
52
  if (items.length) items.push("sep");