agent-loft 0.2.32 → 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.
@@ -0,0 +1,144 @@
1
+ // 每个 agent 会话累计消耗的 token(页面右上角「更多」里显示分布)。
2
+ //
3
+ // Claude 每轮结束报一次这个会话的累计值(恢复 / 分叉来的会话也接着原来的总数算);Codex 每次模型请求报一次增量。
4
+ // 都按会话 id 记在这里:页面重新加载、服务重启后还在。
5
+ // 按 agent 会话算,不按对话:/new、恢复旧会话、切 agent 之后显示的都是现在这个会话的。
6
+ // Codex 升级前跑过的轮次没有记录,从升级后开始算。
7
+ //
8
+ // 另外按天记一本账(usage-daily.json,设置 →「用量」按日 / 月 / 年统计):每次报上来的增量记到当天、
9
+ // 按「谁的对话 + 哪个 agent + 什么模型」分开。只从有这本账开始算,之前的用量没有日期,不补。
10
+ // 没报费用的(Codex)查的时候按模型单价估一个(见 pricing.ts)。
11
+ import path from "node:path";
12
+ import { dataDir, readJson, writeJsonAtomic } from "../home.js";
13
+ import { estimateCost } from "./pricing.js";
14
+ import type { TokenUsage } from "./types.js";
15
+
16
+ export type UsageTotals = Required<Omit<TokenUsage, "costUsd">> & { costUsd?: number; at: number };
17
+ type Store = Record<string, UsageTotals>;
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
+
26
+ const FILE = () => path.join(dataDir, "usage.json");
27
+ const DAILY_FILE = () => path.join(dataDir, "usage-daily.json");
28
+ const MAX_SESSIONS = 1000;
29
+ /** 按天的账留多久(天) */
30
+ const MAX_DAYS = 3 * 366;
31
+
32
+ let store: Store | null = null;
33
+ let ledger: Ledger | null = null;
34
+ let timer: NodeJS.Timeout | null = null;
35
+
36
+ function load(): Store {
37
+ return (store ??= readJson<Store>(FILE(), {}));
38
+ }
39
+
40
+ function loadLedger(): Ledger {
41
+ return (ledger ??= readJson<Ledger>(DAILY_FILE(), {}));
42
+ }
43
+
44
+ function write(): void {
45
+ const s = load();
46
+ // 只留最近用过的会话
47
+ const keys = Object.keys(s).sort((a, b) => s[b]!.at - s[a]!.at);
48
+ for (const k of keys.slice(MAX_SESSIONS)) delete s[k];
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")}`;
60
+ }
61
+
62
+ function persistSoon(): void {
63
+ if (timer) return;
64
+ timer = setTimeout(() => {
65
+ timer = null;
66
+ write();
67
+ }, 1000);
68
+ timer.unref();
69
+ }
70
+
71
+ /** 退出前把还没写的落盘 */
72
+ export function flushUsage(): void {
73
+ if (!timer) return;
74
+ clearTimeout(timer);
75
+ timer = null;
76
+ write();
77
+ }
78
+
79
+ const num = (v: unknown): number => (typeof v === "number" && Number.isFinite(v) && v > 0 ? v : 0);
80
+
81
+ const FIELDS = ["input", "cacheRead", "cacheWrite", "output", "reasoning"] as const;
82
+
83
+ /**
84
+ * cumulative:报的是累计值,每项取较大的(累计值只增不减;恢复的会话万一没从原来的总数接着算,
85
+ * 宁可先少算,也不把之前的再加一遍)。否则是增量,加上去。
86
+ * by:这次用量算谁的、哪个 agent 的(记进按天的账;不给就只记会话的)
87
+ */
88
+ export function recordUsage(
89
+ sessionId: string | undefined,
90
+ usage: TokenUsage,
91
+ cumulative = false,
92
+ by?: { owner?: string; agent: string; model?: string },
93
+ ): void {
94
+ if (!sessionId) return;
95
+ const s = load();
96
+ const t = (s[sessionId] ??= { input: 0, cacheRead: 0, cacheWrite: 0, output: 0, reasoning: 0, at: 0 });
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
+ }
103
+ const cost = num(usage.costUsd);
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
+ }
109
+ t.at = Date.now();
110
+ if (by) addDaily(t.at, by, delta);
111
+ persistSoon();
112
+ }
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
+
142
+ export function usageOf(sessionId: string | undefined): UsageTotals | undefined {
143
+ return sessionId ? load()[sessionId] : undefined;
144
+ }
@@ -167,31 +167,6 @@ button.ctx-item:hover {
167
167
  flex-shrink: 0;
168
168
  }
169
169
 
170
- .mode-pill {
171
- display: inline-flex;
172
- align-items: center;
173
- height: 32px;
174
- padding: 0 10px;
175
- border-radius: 999px;
176
- font-size: 13px;
177
- color: var(--fg-faint);
178
- white-space: nowrap;
179
- }
180
-
181
- .mode-pill:hover {
182
- background: var(--bg-hover);
183
- color: var(--fg);
184
- }
185
-
186
- .mode-pill.chat {
187
- color: var(--accent);
188
- }
189
-
190
- .topbar .mode-slot {
191
- display: inline-flex;
192
- flex-shrink: 0;
193
- }
194
-
195
170
  .topbar .top-more svg {
196
171
  width: 20px;
197
172
  height: 20px;
@@ -227,6 +227,26 @@ a.att-file:hover {
227
227
  word-break: break-word;
228
228
  }
229
229
 
230
+ /* 太长的用户消息:收起到约 10 行,底部淡出,下面一个「展开显示」 */
231
+ .msg-user.clamped {
232
+ max-height: 260px;
233
+ overflow: hidden;
234
+ -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 56px), transparent);
235
+ mask-image: linear-gradient(to bottom, #000 calc(100% - 56px), transparent);
236
+ }
237
+
238
+ .msg-more {
239
+ margin-top: -2px;
240
+ padding: 2px 8px;
241
+ border-radius: 6px;
242
+ font-size: 13px;
243
+ color: var(--fg-faint);
244
+ }
245
+
246
+ .msg-more:hover {
247
+ color: var(--fg);
248
+ }
249
+
230
250
  .msg-user .origin {
231
251
  display: block;
232
252
  font-size: 12px;
@@ -732,3 +752,152 @@ a.att-file:hover {
732
752
  .lightbox-dl:hover {
733
753
  background: rgba(255, 255, 255, 0.26);
734
754
  }
755
+
756
+ /* ---------- 右边的快速定位:每条用户消息一道横线,鼠标移上去展开成列表 ---------- */
757
+ .msg-nav {
758
+ position: absolute;
759
+ right: 14px;
760
+ top: 45%;
761
+ transform: translateY(-50%);
762
+ z-index: 4;
763
+ display: flex;
764
+ align-items: center;
765
+ }
766
+
767
+ .msg-nav[hidden],
768
+ .main.tasks-view .msg-nav {
769
+ display: none;
770
+ }
771
+
772
+ .msg-nav-ticks {
773
+ display: flex;
774
+ flex-direction: column;
775
+ max-height: 50vh;
776
+ overflow-y: auto;
777
+ scrollbar-width: none;
778
+ opacity: 0.55;
779
+ transition: opacity 0.2s;
780
+ }
781
+
782
+ .msg-nav-ticks::-webkit-scrollbar {
783
+ display: none;
784
+ }
785
+
786
+ .msg-nav.scrolling .msg-nav-ticks,
787
+ .msg-nav:hover .msg-nav-ticks {
788
+ opacity: 1;
789
+ }
790
+
791
+ /* 点击范围比横线大:整个 20×10 都能点 */
792
+ .msg-nav-tick {
793
+ flex-shrink: 0;
794
+ width: 20px;
795
+ height: 10px;
796
+ padding: 0;
797
+ display: flex;
798
+ align-items: center;
799
+ justify-content: flex-end;
800
+ }
801
+
802
+ .msg-nav-tick i {
803
+ display: block;
804
+ width: 10px;
805
+ height: 2px;
806
+ border-radius: 1px;
807
+ background: var(--fg-faint);
808
+ transition: width 0.15s, background 0.15s;
809
+ }
810
+
811
+ .msg-nav-tick:hover i {
812
+ background: var(--fg-soft);
813
+ }
814
+
815
+ .msg-nav-tick.on i {
816
+ width: 16px;
817
+ background: var(--fg);
818
+ }
819
+
820
+ /* 展开的列表:在横线左边,鼠标移开就收起 */
821
+ .msg-nav-panel {
822
+ position: absolute;
823
+ right: calc(100% + 4px);
824
+ top: 50%;
825
+ transform: translateY(-50%);
826
+ width: 260px;
827
+ max-height: 60vh;
828
+ overflow-y: auto;
829
+ padding: 6px;
830
+ display: flex;
831
+ flex-direction: column;
832
+ gap: 1px;
833
+ background: var(--bg);
834
+ border: 1px solid var(--border);
835
+ border-radius: 12px;
836
+ box-shadow: var(--shadow);
837
+ opacity: 0;
838
+ visibility: hidden;
839
+ transition: opacity 0.12s, visibility 0.12s;
840
+ }
841
+
842
+ /* 键盘 Tab 进来时也展开;鼠标点过之后不因为焦点还在就一直开着 */
843
+ .msg-nav:hover .msg-nav-panel,
844
+ .msg-nav:has(:focus-visible) .msg-nav-panel {
845
+ opacity: 1;
846
+ visibility: visible;
847
+ }
848
+
849
+ .msg-nav-row {
850
+ flex-shrink: 0;
851
+ text-align: left;
852
+ padding: 6px 10px;
853
+ border-radius: 8px;
854
+ font-size: 13px;
855
+ color: var(--fg-soft);
856
+ white-space: nowrap;
857
+ overflow: hidden;
858
+ text-overflow: ellipsis;
859
+ }
860
+
861
+ .msg-nav-row:hover {
862
+ background: var(--bg-hover);
863
+ color: var(--fg);
864
+ }
865
+
866
+ .msg-nav-row.on {
867
+ color: var(--fg);
868
+ font-weight: 500;
869
+ }
870
+
871
+ /* 手机上没地方放,也没有悬停 */
872
+ @media (max-width: 768px) {
873
+ .msg-nav {
874
+ display: none;
875
+ }
876
+ }
877
+
878
+ /* 排队中的消息:「排队中 · 打断并发送」 */
879
+ .queue-note {
880
+ display: flex;
881
+ align-items: center;
882
+ gap: 10px;
883
+ margin-top: -2px;
884
+ font-size: 12px;
885
+ color: var(--fg-faint);
886
+ }
887
+
888
+ .queue-note.dropped {
889
+ color: var(--danger);
890
+ }
891
+
892
+ .queue-jump {
893
+ padding: 2px 8px;
894
+ border-radius: 6px;
895
+ font-size: 12px;
896
+ color: var(--fg-soft);
897
+ border: 1px solid var(--border);
898
+ }
899
+
900
+ .queue-jump:hover {
901
+ color: var(--fg);
902
+ background: var(--bg-hover);
903
+ }
@@ -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
+ }
@@ -324,13 +324,13 @@ button.conv-group:hover {
324
324
  min-height: 48px;
325
325
  }
326
326
 
327
- .side-gear {
328
- margin-left: auto;
327
+ /* 没有额度可显示:连上面的分隔线一起藏起来 */
328
+ .side-bottom[hidden] {
329
+ display: none;
329
330
  }
330
331
 
331
- /* 桌面端里:设置在顶栏「更多」里,侧栏底部只留额度(额度由桌面端统一查好推过来) */
332
- .desktop-settings .side-gear {
333
- display: none;
332
+ .conv-item .chat-mark {
333
+ color: var(--fg-soft);
334
334
  }
335
335
 
336
336
  /* ---- 侧栏底部的额度 ---- */
@@ -1,4 +1,4 @@
1
- /* ---------- 技能页(版式沿用定时任务页的 .tasks-page / .task-card) ---------- */
1
+ /* ---------- 技能(设置里的一页,卡片沿用定时任务页的 .task-card) ---------- */
2
2
 
3
3
  .skill-head-btns {
4
4
  display: flex;
@@ -366,3 +366,20 @@
366
366
  flex: 1 1 100%;
367
367
  }
368
368
  }
369
+
370
+ /* 设置里的技能页 */
371
+ .skills-page {
372
+ display: flex;
373
+ flex-direction: column;
374
+ gap: 14px;
375
+ }
376
+
377
+ .skills-head {
378
+ display: flex;
379
+ align-items: flex-start;
380
+ gap: 16px;
381
+ }
382
+
383
+ .skills-head .skill-note {
384
+ flex: 1;
385
+ }