agent-loft 0.2.33 → 0.2.35

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.
@@ -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
+ }
@@ -0,0 +1,308 @@
1
+ // 网页终端:服务端起 shell(node-pty),页面用 WebSocket 连上来收发。
2
+ //
3
+ // shell 属于人,不属于页面:关掉页面、手机切到后台、断线都不结束它,重连后按 since 补上错过的输出;
4
+ // 没有页面连着超过 terminal.keepAliveMin 分钟才结束。同一个终端可以同时开在几个页面上。
5
+ //
6
+ // 谁能用:管理员;成员要管理员在 设置 → 用户 里单独开「远程控制」(users.json 的 remote)。
7
+ // 和 agent 不同,这里没有授权卡 —— 拿到终端就是拿到这台机器上 agent-loft 这个系统用户的 shell。
8
+ // 权限收回、用户被停用或删除、设置里关掉终端时,他开着的 shell 立刻结束。
9
+ import crypto from "node:crypto";
10
+ import fs from "node:fs";
11
+ import os from "node:os";
12
+ import path from "node:path";
13
+ import { createRequire } from "node:module";
14
+ import { EventEmitter } from "node:events";
15
+ import type { IPty } from "node-pty";
16
+ import { agentEnvironment } from "./agent-env.js";
17
+ import { config, onConfigChange } from "./config.js";
18
+ import { canUseRemote, getUser, userEvents, type User } from "./users.js";
19
+
20
+ // ---------- node-pty ----------
21
+
22
+ type PtyModule = typeof import("node-pty");
23
+
24
+ /** node-pty 是原生模块、可选依赖:装不上(没有编译工具)时终端整个不可用,其他功能照常 */
25
+ let ptyModule: PtyModule | null | undefined;
26
+ let ptyError = "";
27
+
28
+ function loadPty(): PtyModule | null {
29
+ if (ptyModule !== undefined) return ptyModule;
30
+ try {
31
+ ptyModule = createRequire(import.meta.url)("node-pty") as PtyModule;
32
+ } catch (err) {
33
+ ptyModule = null;
34
+ ptyError = err instanceof Error ? err.message.split("\n")[0]! : String(err);
35
+ }
36
+ return ptyModule;
37
+ }
38
+
39
+ /** 终端能不能用;不能用时 reason 写给管理员看 */
40
+ export function terminalAvailability(): { available: boolean; reason?: string } {
41
+ if (loadPty()) return { available: true };
42
+ return { available: false, reason: `没装好 node-pty(${ptyError})。在 agent-loft 的安装目录里运行 npm install node-pty 后重启` };
43
+ }
44
+
45
+ /** 这个人现在能不能用终端 */
46
+ export function canUseTerminal(user: Pick<User, "role" | "disabled" | "remote"> | undefined): boolean {
47
+ return config().remote.terminal && canUseRemote(user);
48
+ }
49
+
50
+ // ---------- 输出记录 ----------
51
+
52
+ /** 每个终端留多少输出(字符),断线重连、换个页面打开时补发 */
53
+ const LOG_LIMIT = 512 * 1024;
54
+
55
+ /**
56
+ * 最近的输出,加上从开始到现在一共输出了多少(total)。
57
+ * 页面记着自己收到第几个字符了(since),重连时只补后面的;早于记录开头的补不了,整屏重画。
58
+ */
59
+ export class OutputLog {
60
+ private buf = "";
61
+ total = 0;
62
+
63
+ constructor(private readonly limit = LOG_LIMIT) {}
64
+
65
+ append(data: string): void {
66
+ this.buf += data;
67
+ this.total += data.length;
68
+ if (this.buf.length > this.limit * 1.25) {
69
+ // 从换行处截:别从一个转义序列中间开始重放
70
+ const cut = this.buf.length - this.limit;
71
+ const nl = this.buf.indexOf("\n", cut);
72
+ this.buf = this.buf.slice(nl >= 0 && nl - cut < 4096 ? nl + 1 : cut);
73
+ }
74
+ }
75
+
76
+ /** since 之后的输出;reset:页面得先清屏,data 是能补的全部 */
77
+ since(since: number): { reset: boolean; data: string } {
78
+ const start = this.total - this.buf.length;
79
+ if (Number.isInteger(since) && since >= start && since <= this.total) return { reset: false, data: this.buf.slice(since - start) };
80
+ return { reset: true, data: this.buf };
81
+ }
82
+ }
83
+
84
+ // ---------- 终端 ----------
85
+
86
+ /** 连着这个终端的一个页面。由 Web 渠道实现(见 channels/web/terminal.ts) */
87
+ export type TermPeer = {
88
+ send(msg: unknown): void;
89
+ close(code: number, reason: string): void;
90
+ };
91
+
92
+ type Term = {
93
+ id: string;
94
+ owner: string;
95
+ cwd: string;
96
+ shell: string;
97
+ createdAt: number;
98
+ pty: IPty;
99
+ log: OutputLog;
100
+ peers: Set<TermPeer>;
101
+ cols: number;
102
+ rows: number;
103
+ /** 没有页面连着时开始计时,到点结束 shell */
104
+ idleTimer: NodeJS.Timeout | null;
105
+ /** 还没发出去的输出:pty 一次只吐几十个字节,攒一下再发 */
106
+ pending: string;
107
+ flushTimer: NodeJS.Timeout | null;
108
+ };
109
+
110
+ export type TermInfo = { id: string; cwd: string; shell: string; createdAt: number; attached: number };
111
+
112
+ const PER_USER = 10;
113
+ const TOTAL = 50;
114
+
115
+ const terms = new Map<string, Term>();
116
+ /** 某人的终端开了 / 结束了:他开着的页面重新拉列表 */
117
+ export const terminalEvents = new EventEmitter<{ changed: [owner: string] }>();
118
+
119
+ function info(t: Term): TermInfo {
120
+ return { id: t.id, cwd: t.cwd, shell: path.basename(t.shell), createdAt: t.createdAt, attached: t.peers.size };
121
+ }
122
+
123
+ export function listTerminals(owner: string): TermInfo[] {
124
+ return [...terms.values()].filter((t) => t.owner === owner).sort((a, b) => a.createdAt - b.createdAt).map(info);
125
+ }
126
+
127
+ /** 只给主人:别人的终端 id 拿到了也当不存在 */
128
+ export function getTerminal(id: string, owner: string): TermInfo | undefined {
129
+ const t = terms.get(id);
130
+ return t && t.owner === owner ? info(t) : undefined;
131
+ }
132
+
133
+ function defaultShell(): { file: string; args: string[] } {
134
+ if (process.platform === "win32") return { file: process.env.COMSPEC || "powershell.exe", args: [] };
135
+ const candidates = [process.env.SHELL, "/bin/bash", "/bin/zsh", "/bin/sh"].filter((s): s is string => !!s);
136
+ const file = candidates.find((s) => path.isAbsolute(s) && fs.existsSync(s)) ?? "/bin/sh";
137
+ // 登录 shell:systemd / launchd 下 PATH 很短,读一遍 profile 才像平时开的终端
138
+ return { file, args: ["-l"] };
139
+ }
140
+
141
+ function usableDir(dir: unknown): string | undefined {
142
+ if (typeof dir !== "string" || !path.isAbsolute(dir)) return undefined;
143
+ try {
144
+ return fs.statSync(dir).isDirectory() ? path.resolve(dir) : undefined;
145
+ } catch {
146
+ return undefined;
147
+ }
148
+ }
149
+
150
+ function clampSize(n: unknown, fallback: number, max: number): number {
151
+ const v = Math.round(Number(n));
152
+ return Number.isFinite(v) && v >= 2 ? Math.min(v, max) : fallback;
153
+ }
154
+
155
+ export function createTerminal(user: User, opts: { cwd?: unknown; cols?: unknown; rows?: unknown }): TermInfo {
156
+ if (!canUseTerminal(user)) throw new Error("你没有使用终端的权限");
157
+ const pty = loadPty();
158
+ if (!pty) throw new Error(terminalAvailability().reason);
159
+ if (listTerminals(user.id).length >= PER_USER) throw new Error(`最多同时开 ${PER_USER} 个终端,先关掉几个`);
160
+ if (terms.size >= TOTAL) throw new Error("服务器上开着的终端太多了");
161
+ const cwd = usableDir(opts.cwd) ?? usableDir(config().workspaceRoot) ?? os.homedir();
162
+ const cols = clampSize(opts.cols, 80, 500);
163
+ const rows = clampSize(opts.rows, 24, 200);
164
+ const shell = defaultShell();
165
+ const env: Record<string, string> = {};
166
+ for (const [k, v] of Object.entries(agentEnvironment())) if (typeof v === "string") env[k] = v;
167
+ env.TERM = "xterm-256color";
168
+ env.COLORTERM = "truecolor";
169
+ if (!env.LANG && !env.LC_ALL) env.LANG = "C.UTF-8";
170
+ const proc = pty.spawn(shell.file, shell.args, { name: "xterm-256color", cols, rows, cwd, env });
171
+ const t: Term = {
172
+ id: `t_${crypto.randomBytes(6).toString("hex")}`,
173
+ owner: user.id,
174
+ cwd,
175
+ shell: shell.file,
176
+ createdAt: Date.now(),
177
+ pty: proc,
178
+ log: new OutputLog(),
179
+ peers: new Set(),
180
+ cols,
181
+ rows,
182
+ idleTimer: null,
183
+ pending: "",
184
+ flushTimer: null,
185
+ };
186
+ terms.set(t.id, t);
187
+ proc.onData((data) => {
188
+ t.log.append(data);
189
+ t.pending += data;
190
+ t.flushTimer ??= setTimeout(() => flush(t), 8);
191
+ });
192
+ proc.onExit(({ exitCode }) => {
193
+ flush(t);
194
+ finish(t, `shell 已退出(${exitCode})`, exitCode);
195
+ });
196
+ armIdle(t);
197
+ console.log(`[终端] ${user.name} 开了终端 ${t.id}(${cwd})`);
198
+ terminalEvents.emit("changed", t.owner);
199
+ return info(t);
200
+ }
201
+
202
+ function flush(t: Term): void {
203
+ if (t.flushTimer) clearTimeout(t.flushTimer);
204
+ t.flushTimer = null;
205
+ if (!t.pending) return;
206
+ const msg = { t: "o", d: t.pending };
207
+ t.pending = "";
208
+ for (const peer of t.peers) peer.send(msg);
209
+ }
210
+
211
+ /** 没有页面连着了:keepAliveMin 分钟后结束 */
212
+ function armIdle(t: Term): void {
213
+ if (t.idleTimer) clearTimeout(t.idleTimer);
214
+ t.idleTimer = null;
215
+ if (t.peers.size > 0) return;
216
+ const ms = Math.max(1, config().remote.keepAliveMin) * 60_000;
217
+ t.idleTimer = setTimeout(() => killTerminal(t.id, "没有页面连着,已超时结束"), ms);
218
+ t.idleTimer.unref();
219
+ }
220
+
221
+ /** 从表里拿掉、告诉连着的页面。shell 自己退出和被结束都走这里 */
222
+ function finish(t: Term, reason: string, code: number | null): void {
223
+ if (terms.get(t.id) !== t) return;
224
+ terms.delete(t.id);
225
+ if (t.idleTimer) clearTimeout(t.idleTimer);
226
+ if (t.flushTimer) clearTimeout(t.flushTimer);
227
+ for (const peer of t.peers) {
228
+ peer.send({ t: "x", code, reason });
229
+ peer.close(4000, "终端已结束");
230
+ }
231
+ t.peers.clear();
232
+ terminalEvents.emit("changed", t.owner);
233
+ }
234
+
235
+ export function killTerminal(id: string, reason = "已关闭"): boolean {
236
+ const t = terms.get(id);
237
+ if (!t) return false;
238
+ finish(t, reason, null);
239
+ try {
240
+ t.pty.kill();
241
+ } catch {
242
+ // 已经退出了
243
+ }
244
+ return true;
245
+ }
246
+
247
+ /** 页面连上来:先补上 since 之后的输出 */
248
+ export function attachTerminal(id: string, owner: string, peer: TermPeer, since: number): boolean {
249
+ const t = terms.get(id);
250
+ if (!t || t.owner !== owner) return false;
251
+ flush(t);
252
+ const { reset, data } = t.log.since(since);
253
+ peer.send({ t: "a", reset, d: data, total: t.log.total, cwd: t.cwd });
254
+ t.peers.add(peer);
255
+ armIdle(t);
256
+ return true;
257
+ }
258
+
259
+ export function detachTerminal(id: string, peer: TermPeer): void {
260
+ const t = terms.get(id);
261
+ if (!t || !t.peers.delete(peer)) return;
262
+ armIdle(t);
263
+ }
264
+
265
+ export function writeTerminal(id: string, data: string): void {
266
+ const t = terms.get(id);
267
+ if (t && data) t.pty.write(data);
268
+ }
269
+
270
+ /** 几个页面开着同一个终端、窗口大小不一样:以最后调整的那个为准 */
271
+ export function resizeTerminal(id: string, cols: unknown, rows: unknown): void {
272
+ const t = terms.get(id);
273
+ if (!t) return;
274
+ const c = clampSize(cols, t.cols, 500);
275
+ const r = clampSize(rows, t.rows, 200);
276
+ if (c === t.cols && r === t.rows) return;
277
+ t.cols = c;
278
+ t.rows = r;
279
+ try {
280
+ t.pty.resize(c, r);
281
+ } catch {
282
+ // 正在退出
283
+ }
284
+ }
285
+
286
+ /**
287
+ * 权限变了(用户改了、设置里关了):现在不能用终端的人,shell 立刻结束。
288
+ * 用户改动由 userEvents 通知;终端里 agent-loft user … 直接改文件的没有事件,Web 的心跳会再调一次
289
+ */
290
+ export function enforceTerminalAccess(): void {
291
+ for (const t of [...terms.values()]) {
292
+ if (!canUseTerminal(getUser(t.owner))) killTerminal(t.id, "终端权限已被收回");
293
+ }
294
+ }
295
+
296
+ let watching = false;
297
+ /** 启动 Web 渠道时调用一次 */
298
+ export function watchTerminalAccess(): void {
299
+ if (watching) return;
300
+ watching = true;
301
+ userEvents.on("changed", enforceTerminalAccess);
302
+ onConfigChange(() => enforceTerminalAccess());
303
+ }
304
+
305
+ /** 服务停止:结束所有 shell */
306
+ export function killAllTerminals(): void {
307
+ for (const id of [...terms.keys()]) killTerminal(id, "服务已停止");
308
+ }
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
  }
package/src/core/users.ts CHANGED
@@ -25,6 +25,8 @@ export type User = {
25
25
  passwordHash: string;
26
26
  createdAt: number;
27
27
  disabled?: boolean;
28
+ /** 成员能不能用远程控制(终端、桌面)。管理员一直能用,不看这个 */
29
+ remote?: boolean;
28
30
  };
29
31
 
30
32
  export type PublicUser = Omit<User, "passwordHash">;
@@ -165,6 +167,12 @@ export function isAdmin(user: Pick<User, "role"> | undefined): boolean {
165
167
  return user?.role === "admin";
166
168
  }
167
169
 
170
+ /** 能不能用远程控制(终端、桌面):管理员能用,成员要单独开。设置里各自还有总开关 */
171
+ export function canUseRemote(user: Pick<User, "role" | "disabled" | "remote"> | undefined): boolean {
172
+ if (!user || user.disabled) return false;
173
+ return user.role === "admin" || user.remote === true;
174
+ }
175
+
168
176
  /**
169
177
  * 升级前的登录态、推送订阅没有记是谁的:都算最早的那个管理员的(就是迁移出来的 admin)。
170
178
  */
@@ -220,7 +228,10 @@ export function createUser(params: { name: string; password: string; role?: Role
220
228
  return publicUser(user);
221
229
  }
222
230
 
223
- export function updateUser(id: string, patch: { name?: string; role?: Role; disabled?: boolean; password?: string }): PublicUser {
231
+ export function updateUser(
232
+ id: string,
233
+ patch: { name?: string; role?: Role; disabled?: boolean; password?: string; remote?: boolean },
234
+ ): PublicUser {
224
235
  const list = load().map((u) => ({ ...u }));
225
236
  const user = list.find((u) => u.id === id);
226
237
  if (!user) throw new Error("用户不存在");
@@ -234,6 +245,10 @@ export function updateUser(id: string, patch: { name?: string; role?: Role; disa
234
245
  if (patch.disabled) user.disabled = true;
235
246
  else delete user.disabled;
236
247
  }
248
+ if (patch.remote !== undefined) {
249
+ if (patch.remote) user.remote = true;
250
+ else delete user.remote;
251
+ }
237
252
  if (patch.password !== undefined) {
238
253
  checkPassword(patch.password);
239
254
  user.passwordHash = hashPassword(patch.password);