agent-loft 0.2.32 → 0.2.33

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/src/core/hub.ts CHANGED
@@ -11,6 +11,7 @@ import crypto from "node:crypto";
11
11
  import { EventEmitter } from "node:events";
12
12
  import { config, onConfigChange, resolveWorkspacePath } from "./config.js";
13
13
  import { attachFooters, copyFooters, formatDuration, recordFooter } from "./footers.js";
14
+ import { flushUsage, recordUsage, usageOf, type UsageTotals } from "./usage.js";
14
15
  import { interactions, SETTLEMENT_LABEL } from "./interactions.js";
15
16
  import {
16
17
  agentBinding,
@@ -65,7 +66,15 @@ export type Origin = {
65
66
  };
66
67
 
67
68
  export type ConvEvent =
68
- | { type: "user"; text: string; attachments: string[]; files?: AttachmentInfo[]; origin: Origin }
69
+ /** id:这条消息的编号,后面的 queue 事件按它对上;queued:收到时前面还有在跑 / 在等的,要排队 */
70
+ | { type: "user"; text: string; attachments: string[]; files?: AttachmentInfo[]; origin: Origin; id?: string; queued?: boolean }
71
+ /** 排队的消息:轮到它了(started,或者被当成提问的回答用掉了),或者没发出去(dropped:停止、会话切换…) */
72
+ | { type: "queue"; id: string; state: "started" | "dropped" }
73
+ /**
74
+ * 这里弹出过一张授权 / 提问卡(内容和状态在 interactions 里按 id 查)。只是给卡片在对话里占个位置:
75
+ * 刷新页面时进行中的这一轮靠重放事件画出来,没有它卡片就丢了
76
+ */
77
+ | { type: "ix"; id: string }
69
78
  | { type: "turn_start" }
70
79
  | { type: "step"; kind: StepKind; text: string }
71
80
  | { type: "delta"; text: string }
@@ -141,6 +150,8 @@ export type ConversationInfo = {
141
150
  runtimeEffort?: string;
142
151
  /** 当前 agent 会话的上下文占用(这次启动以来跑过至少一轮才有) */
143
152
  context?: ContextUsage;
153
+ /** 当前 agent 会话累计消耗的 token(升级后跑过至少一轮才有) */
154
+ usage?: UsageTotals;
144
155
  busy: boolean;
145
156
  activeAt: number;
146
157
  createdAt: number;
@@ -190,6 +201,14 @@ class Hub extends EventEmitter<{ event: [EventEnvelope]; task: [TaskTurnEnd] }>
190
201
  private seq = 0;
191
202
  /** 每条会话还没收到结果的轮数,> 0 即忙 */
192
203
  private inflight = new Map<string, number>();
204
+ /**
205
+ * 已经交给 agent 会话、还没出结果的消息,和 inflight、turnTags 一一对应:第一条在跑,后面的在排队。
206
+ * 用来告诉页面哪条消息轮到了 / 被丢了,以及「打断并发送」时把哪条挪到前面
207
+ */
208
+ private turnFifo = new Map<string, { id: string; input: UserInput; queued: boolean }[]>();
209
+ /** 收到了、还在串行队列里没交给 agent 的消息数:这时再来的消息也算排队 */
210
+ private waitingSends = new Map<string, number>();
211
+ private msgSeq = 0;
193
212
  private announced = new Set<string>();
194
213
  /** 每条会话因缓冲区溢出被丢掉的最后一个 seq */
195
214
  private truncated = new Map<string, number>();
@@ -229,6 +248,7 @@ class Hub extends EventEmitter<{ event: [EventEnvelope]; task: [TaskTurnEnd] }>
229
248
  constructor() {
230
249
  super();
231
250
  this.setMaxListeners(100);
251
+ interactions.on("created", (i) => this.emitEvent(i.convKey, { type: "ix", id: i.id }));
232
252
  // 技能装了、改了、停用了、变量改了:受影响的人空闲的会话换上新的
233
253
  skillEvents.on("changed", (users) => this.personalChanged(new Set(users)));
234
254
  // workspaceRoot 改了:store 会把没显式 /cd 过的会话目录重置,正在跑的旧目录会话要作废
@@ -461,6 +481,10 @@ class Hub extends EventEmitter<{ event: [EventEnvelope]; task: [TaskTurnEnd] }>
461
481
  const ctx = binding.sessionId ? this.contextBySession.get(binding.sessionId) : undefined;
462
482
  return ctx ? { context: ctx } : {};
463
483
  })(),
484
+ ...(() => {
485
+ const usage = usageOf(binding.sessionId);
486
+ return usage ? { usage } : {};
487
+ })(),
464
488
  busy: this.isBusy(key),
465
489
  activeAt: conv.activeAt,
466
490
  createdAt: conv.createdAt,
@@ -581,6 +605,7 @@ class Hub extends EventEmitter<{ event: [EventEnvelope]; task: [TaskTurnEnd] }>
581
605
  const conv = getConversation(key);
582
606
  recordFooter(holder.session?.sessionId ?? (conv ? agentBinding(conv, agent).sessionId : undefined), result.text, footer);
583
607
  this.setInflight(key, (this.inflight.get(key) ?? 1) - 1);
608
+ this.finishTurn(key);
584
609
  this.refreshRuntimeOnce(key, liveId);
585
610
  this.syncTitle(key, agent);
586
611
  this.dropIfStale(key);
@@ -607,6 +632,7 @@ class Hub extends EventEmitter<{ event: [EventEnvelope]; task: [TaskTurnEnd] }>
607
632
  this.emitEvent(key, { type: "error", message: diagnosis.chatMessage });
608
633
  }
609
634
  this.setInflight(key, (this.inflight.get(key) ?? 1) - 1);
635
+ this.finishTurn(key);
610
636
  this.dropIfStale(key);
611
637
  },
612
638
  onContext: (ctx) => {
@@ -622,6 +648,8 @@ class Hub extends EventEmitter<{ event: [EventEnvelope]; task: [TaskTurnEnd] }>
622
648
  // Codex 一轮里会报好几次,数字没变就不打扰前端
623
649
  if (prev?.used !== ctx.used || prev?.window !== ctx.window) this.emitEvent(key, { type: "meta" });
624
650
  },
651
+ // 不用另发 meta:一轮结束的 result / error 会把会话信息(含用量)推给页面
652
+ onUsage: (usage, cumulative) => alive() && recordUsage(holder.session?.sessionId, usage, cumulative),
625
653
  onDropped: (n) => {
626
654
  if (!alive() || n <= 0) return;
627
655
  // 丢的是排在最后、还没开始的那 n 条(Codex 先报正在跑的那轮出错、再报丢弃,那时队头已经取走了)
@@ -630,6 +658,10 @@ class Hub extends EventEmitter<{ event: [EventEnvelope]; task: [TaskTurnEnd] }>
630
658
  if (tag) this.emit("task", { ...tag, key, status: "interrupted", text: "排队时被停止了" });
631
659
  }
632
660
  this.setInflight(key, (this.inflight.get(key) ?? n) - n);
661
+ // 第一条是在跑的那一轮(它自己会报结果),丢的只会是后面排队的
662
+ const fifo = this.turnFifo.get(key) ?? [];
663
+ const k = Math.min(n, Math.max(0, fifo.length - 1));
664
+ for (const e of fifo.splice(fifo.length - k, k)) this.emitEvent(key, { type: "queue", id: e.id, state: "dropped" });
633
665
  },
634
666
  onSessionId: (sessionId) => {
635
667
  // 切走之后旧会话晚到的回调不该覆盖新绑定
@@ -703,6 +735,7 @@ class Hub extends EventEmitter<{ event: [EventEnvelope]; task: [TaskTurnEnd] }>
703
735
  this.live.delete(key);
704
736
  for (const tag of this.turnTags.get(key) ?? []) if (tag) this.emit("task", { ...tag, key, status: "interrupted", text: note });
705
737
  this.turnTags.delete(key);
738
+ this.clearFifo(key);
706
739
  this.setInflight(key, 0);
707
740
  if (!existing) return;
708
741
  // dispose 卡住(CLI 不响应)不能把这条会话的操作队列永远堵死
@@ -831,6 +864,8 @@ class Hub extends EventEmitter<{ event: [EventEnvelope]; task: [TaskTurnEnd] }>
831
864
  attachments: input.attachments.map((a) => `${KIND_LABEL[a.kind]} ${a.name}`),
832
865
  files: input.attachments.map(attachmentInfo),
833
866
  origin,
867
+ id: `m${++this.msgSeq}`,
868
+ queued: false,
834
869
  };
835
870
  const by = `${origin.name ?? "用户"}以消息作答`;
836
871
 
@@ -865,29 +900,42 @@ class Hub extends EventEmitter<{ event: [EventEnvelope]; task: [TaskTurnEnd] }>
865
900
  updateConversation(key, { title: shown.text.trim().replace(/\s+/g, " ").slice(0, 40), titleAuto: true });
866
901
  this.emitEvent(key, { type: "meta" });
867
902
  }
903
+ // 前面有在跑的、或者还在串行队列里等着的:这条要排队
904
+ shown.queued = this.isBusy(key) || (this.waitingSends.get(key) ?? 0) > 0;
868
905
  this.emitEvent(key, shown);
906
+ const dropped = () => this.emitEvent(key, { type: "queue", id: shown.id, state: "dropped" });
869
907
  const gone = (text: string) => {
908
+ dropped();
870
909
  if (task) this.emit("task", { ...task, key, status: "interrupted", text });
871
910
  };
911
+ this.waitingSends.set(key, (this.waitingSends.get(key) ?? 0) + 1);
872
912
  void this.serial(key, async () => {
873
- if (cancelled()) return gone("没有发出(停止了或者会话被切换了)");
874
- if (questionAtEntry && tryAnswer(questionAtEntry)) return;
875
- // 收到之后发生过显式切换(恢复 / 分叉 / 新会话…):用户已经明确指定了会话,不再按旧的闲置时间作废它。
876
- // 定时任务也不按闲置过期:每天跑一次的任务要接着上次的上下文
877
- const ttl = config().sessionTtlMs;
878
- const switchedAfterEntry = (this.lastSwitch.get(key)?.at ?? 0) >= g.at;
879
- const expired = !task && !switchedAfterEntry && ttl > 0 && Date.now() - lastActive > ttl;
880
- if (expired) {
881
- await this.expireNow(key);
882
- // 清理要等旧会话 dispose,期间可能又点了停止
883
- if (cancelled()) return;
884
- }
885
- const blocked = await this.blockedSlash(key, input.text);
886
- if (blocked) {
887
- this.emitEvent(key, { type: "notice", text: blocked });
888
- return gone(blocked);
913
+ try {
914
+ if (cancelled()) return gone("没有发出(停止了或者会话被切换了)");
915
+ if (questionAtEntry && tryAnswer(questionAtEntry)) {
916
+ // 被当成提问的回答用掉了:不再排队
917
+ if (shown.queued) this.emitEvent(key, { type: "queue", id: shown.id, state: "started" });
918
+ return;
919
+ }
920
+ // 收到之后发生过显式切换(恢复 / 分叉 / 新会话…):用户已经明确指定了会话,不再按旧的闲置时间作废它。
921
+ // 定时任务也不按闲置过期:每天跑一次的任务要接着上次的上下文
922
+ const ttl = config().sessionTtlMs;
923
+ const switchedAfterEntry = (this.lastSwitch.get(key)?.at ?? 0) >= g.at;
924
+ const expired = !task && !switchedAfterEntry && ttl > 0 && Date.now() - lastActive > ttl;
925
+ if (expired) {
926
+ await this.expireNow(key);
927
+ // 清理要等旧会话 dispose,期间可能又点了停止
928
+ if (cancelled()) return dropped();
929
+ }
930
+ const blocked = await this.blockedSlash(key, input.text);
931
+ if (blocked) {
932
+ this.emitEvent(key, { type: "notice", text: blocked });
933
+ return gone(blocked);
934
+ }
935
+ this.sendNow(key, this.withMentions(key, input), task ?? null, { id: shown.id, queued: shown.queued });
936
+ } finally {
937
+ this.waitingSends.set(key, Math.max(0, (this.waitingSends.get(key) ?? 1) - 1));
889
938
  }
890
- this.sendNow(key, this.withMentions(key, input), task ?? null);
891
939
  });
892
940
  }
893
941
 
@@ -946,7 +994,8 @@ class Hub extends EventEmitter<{ event: [EventEnvelope]; task: [TaskTurnEnd] }>
946
994
  this.emitEvent(key, { type: "meta" });
947
995
  }
948
996
 
949
- private sendNow(key: string, input: UserInput, tag: TaskTurn | null): void {
997
+ /** msg:页面上那条消息的编号和收到时是否排队(见 send 里的 shown) */
998
+ private sendNow(key: string, input: UserInput, tag: TaskTurn | null, msg: { id: string; queued: boolean }): void {
950
999
  const fail = (err: unknown) => {
951
1000
  const message = err instanceof Error ? err.message : String(err);
952
1001
  this.emitEvent(key, { type: "error", message });
@@ -963,12 +1012,22 @@ class Hub extends EventEmitter<{ event: [EventEnvelope]; task: [TaskTurnEnd] }>
963
1012
  const tags = this.turnTags.get(key) ?? [];
964
1013
  tags.push(tag);
965
1014
  this.turnTags.set(key, tags);
1015
+ const fifo = this.turnFifo.get(key) ?? [];
1016
+ fifo.push({ id: msg.id, input, queued: msg.queued });
1017
+ this.turnFifo.set(key, fifo);
966
1018
  try {
967
1019
  session.send(input);
968
1020
  } catch (err) {
969
1021
  tags.pop();
1022
+ fifo.pop();
970
1023
  this.setInflight(key, (this.inflight.get(key) ?? 1) - 1);
971
1024
  fail(err);
1025
+ return;
1026
+ }
1027
+ // 收到时在排队(前面的在串行队列里等着),交出去时前面已经空了:这就开始了
1028
+ if (fifo.length === 1 && msg.queued) {
1029
+ fifo[0]!.queued = false;
1030
+ this.emitEvent(key, { type: "queue", id: msg.id, state: "started" });
972
1031
  }
973
1032
  }
974
1033
 
@@ -1247,10 +1306,51 @@ class Hub extends EventEmitter<{ event: [EventEnvelope]; task: [TaskTurnEnd] }>
1247
1306
  // 和 inflight 一起清零:被打断的那一轮不一定还会报结果,留着的话下一轮的结果会被当成定时任务的
1248
1307
  for (const tag of this.turnTags.get(key) ?? []) if (tag) this.emit("task", { ...tag, key, status: "interrupted", text: "被停止了" });
1249
1308
  this.turnTags.delete(key);
1309
+ this.clearFifo(key);
1250
1310
  this.setInflight(key, 0);
1251
1311
  return "已请求中断。";
1252
1312
  }
1253
1313
 
1314
+ /**
1315
+ * 打断并发送:停掉正在跑的这一轮,排着队的 id 这条挪到最前面,马上处理它;其余排着的照常接在后面。
1316
+ * 和停止不同,不丢排队的消息、也不清计数 —— 被打断的这一轮会正常报结果(已中断),之后就轮到它
1317
+ */
1318
+ async jump(key: string, id: string): Promise<string> {
1319
+ const fifo = this.turnFifo.get(key) ?? [];
1320
+ const idx = fifo.findIndex((e) => e.id === id);
1321
+ if (idx === 0) return "这条已经在处理了。";
1322
+ const live = this.live.get(key);
1323
+ if (idx < 0 || !live) return "这条消息已经不在排队了。";
1324
+ const [entry] = fifo.splice(idx, 1);
1325
+ fifo.splice(1, 0, entry!);
1326
+ // turnTags 和 fifo 一一对应,一起挪
1327
+ const tags = this.turnTags.get(key);
1328
+ if (tags && tags.length === fifo.length) tags.splice(1, 0, ...tags.splice(idx, 1));
1329
+ this.stoppedAt.set(key, Date.now());
1330
+ interactions.cancelAll(key, "本轮已中断");
1331
+ await live.session.interrupt({ keepQueue: true, first: entry!.input });
1332
+ return "已打断,马上处理这条。";
1333
+ }
1334
+
1335
+ /** 一轮出结果了:队首出队,下一条要是排过队的,告诉页面轮到它了 */
1336
+ private finishTurn(key: string): void {
1337
+ const fifo = this.turnFifo.get(key);
1338
+ if (!fifo) return;
1339
+ fifo.shift();
1340
+ const next = fifo[0];
1341
+ if (next?.queued) {
1342
+ next.queued = false;
1343
+ this.emitEvent(key, { type: "queue", id: next.id, state: "started" });
1344
+ }
1345
+ if (fifo.length === 0) this.turnFifo.delete(key);
1346
+ }
1347
+
1348
+ /** 停止 / 会话丢了:在跑的那条不管(它的结果或中断自己会报),排着队的都没发出 */
1349
+ private clearFifo(key: string): void {
1350
+ for (const e of (this.turnFifo.get(key) ?? []).slice(1)) this.emitEvent(key, { type: "queue", id: e.id, state: "dropped" });
1351
+ this.turnFifo.delete(key);
1352
+ }
1353
+
1254
1354
  private async cdNow(key: string, dir: string, op: SwitchOp): Promise<string> {
1255
1355
  const resolved = resolveWorkspacePath(dir);
1256
1356
  if (!resolved) return `不允许:目录必须存在,且在 ${config().workspaceRoot} 之内(不能经符号链接跳出去)。`;
@@ -1515,6 +1615,7 @@ class Hub extends EventEmitter<{ event: [EventEnvelope]; task: [TaskTurnEnd] }>
1515
1615
  async shutdown(): Promise<void> {
1516
1616
  for (const key of [...this.live.keys()]) await this.drop(key, "服务正在重启");
1517
1617
  for (const driver of this.drivers.values()) await driver.stop().catch(() => {});
1618
+ flushUsage();
1518
1619
  }
1519
1620
  }
1520
1621
 
@@ -345,7 +345,7 @@ export function skillsText(owner: string | undefined): string {
345
345
  if (loft.length) {
346
346
  lines.push(`**能用的技能(${loft.length} 个)**`, "", ...loft.map((t) => `- \`@${t.name}\` ${t.description}`));
347
347
  } else {
348
- lines.push("还没有启用任何技能。可以在网页侧栏「技能」里新建、上传,或者从技能广场安装;也可以直接让 agent「把刚才的做法存成技能」。");
348
+ lines.push("还没有启用任何技能。可以在网页「设置 → 技能」里新建、上传;也可以直接让 agent「把刚才的做法存成技能」。");
349
349
  }
350
350
  if (machine.length) lines.push("", `**本机技能(只有管理员能用,${machine.length} 个)**:${machine.map((t) => `\`@${t.name}\``).join(" ")}`);
351
351
  lines.push("", "在消息里写 @名字 点名使用;工作模式下 agent 也会自己挑合适的用。");
@@ -1,7 +1,8 @@
1
1
  // 技能(skill):一份 SKILL.md 加上它附带的脚本、资料,agent 需要时照着做。和 Claude Code / Codex 自己的
2
2
  // skill 是同一种格式,只是由 agent-loft 按人管理:
3
3
  // - 谁都能建(网页上写、上传技能包,或者让 agent 在对话里生成),建的人是作者;
4
- // - 作者决定谁看得到:只有自己 / 指定的几个人 / 所有人。看得到的人在「技能广场」里安装;
4
+ // - 作者决定谁看得到:只有自己 / 指定的几个人 / 所有人,看得到的人可以安装。网页上已经不再提供分享和技能广场
5
+ // (新建的都是仅自己),这套数据留着是为了以前分享、安装过的照常能用;
5
6
  // - 安装的是作者那一份本身,不是副本:作者改了,装了的人下一条消息起就用上新的;
6
7
  // - 技能可以声明要用的环境变量(API 地址、密钥之类),值由每个用户自己填,只注入到他自己的会话里。
7
8
  //
package/src/core/types.ts CHANGED
@@ -100,11 +100,22 @@ export type AgentEvents = {
100
100
  onDropped?: (n: number) => void;
101
101
  /** 上下文窗口占用更新了(一般在每轮结束后) */
102
102
  onContext?: (ctx: ContextUsage) => void;
103
+ /**
104
+ * token 用量更新了。cumulative = 报的是这个会话到目前的累计值(Claude,每轮结束报一次);
105
+ * 否则是新增的量(Codex,每次模型请求报一次),由核心累加
106
+ */
107
+ onUsage?: (usage: TokenUsage, cumulative?: boolean) => void;
103
108
  };
104
109
 
105
110
  /** 上下文窗口占用:used / window 都是 token 数,percent 是 0-100(可能略超 100) */
106
111
  export type ContextUsage = { used: number; window: number; percent: number };
107
112
 
113
+ /**
114
+ * token 用量。input 是没命中缓存的输入;cacheRead 是命中缓存读的;cacheWrite 是写进缓存的(Claude 单独计费);
115
+ * output 含推理,reasoning 是其中推理的部分(Codex 才有)。costUsd 是 agent 自己算的费用(Claude 才有)
116
+ */
117
+ export type TokenUsage = { input: number; cacheRead: number; cacheWrite: number; output: number; reasoning?: number; costUsd?: number };
118
+
108
119
  /** 账号额度(订阅的用量窗口)。没有订阅额度(API key 之类)时 driver 返回 null */
109
120
  export type QuotaInfo = {
110
121
  plan?: string;
@@ -148,8 +159,13 @@ export type SessionSummary = {
148
159
  };
149
160
 
150
161
  export type HistoryMessage = {
151
- role: "user" | "assistant";
162
+ /** question:agent 问过的问题和你的回答(答过的提问卡,刷新后照样显示) */
163
+ role: "user" | "assistant" | "question";
152
164
  text: string;
165
+ /** 助手消息:被提问卡打断的前半段(只显示过程,不带复制 / 分叉 / 脚注) */
166
+ segment?: boolean;
167
+ /** 提问:问了什么、选了哪些(按问题下标)、自由作答的内容;skipped = 没作答(跳过 / 超时) */
168
+ question?: { id: string; title: string; questions: QuestionSpec[]; selections: number[][]; freeText: Record<number, string>; skipped?: boolean };
153
169
  /** 助手这一条之前的过程摘要 */
154
170
  steps?: string[];
155
171
  /** 毫秒时间戳;助手消息是这一轮结束的时间 */
@@ -213,10 +229,16 @@ export type SessionSkills = {
213
229
 
214
230
  export type ConversationMode = "chat" | "work";
215
231
 
232
+ /**
233
+ * 中断的方式。默认(停止):这一轮和排着队的都不要了。
234
+ * keepQueue(打断并发送):只停这一轮,排着的留着;给了 first 就把它挪到最前面,这一轮一结束就轮到它
235
+ */
236
+ export type InterruptOptions = { keepQueue?: boolean; first?: UserInput };
237
+
216
238
  export interface AgentSession {
217
239
  readonly sessionId: string | undefined;
218
240
  send(input: UserInput): void;
219
- interrupt(): Promise<void>;
241
+ interrupt(opts?: InterruptOptions): Promise<void>;
220
242
  setModel(model?: string, effort?: string): Promise<void>;
221
243
  describeRuntime(): Promise<RuntimeInfo>;
222
244
  /** 手上已知的模型 / 思考程度,同步返回:不拉起进程、不发请求 */
@@ -0,0 +1,70 @@
1
+ // 每个 agent 会话累计消耗的 token(页面右上角「更多」里显示分布)。
2
+ //
3
+ // Claude 每轮结束报一次这个会话的累计值(恢复 / 分叉来的会话也接着原来的总数算);Codex 每次模型请求报一次增量。
4
+ // 都按会话 id 记在这里:页面重新加载、服务重启后还在。
5
+ // 按 agent 会话算,不按对话:/new、恢复旧会话、切 agent 之后显示的都是现在这个会话的。
6
+ // Codex 升级前跑过的轮次没有记录,从升级后开始算。
7
+ import path from "node:path";
8
+ import { dataDir, readJson, writeJsonAtomic } from "../home.js";
9
+ import type { TokenUsage } from "./types.js";
10
+
11
+ export type UsageTotals = Required<Omit<TokenUsage, "costUsd">> & { costUsd?: number; at: number };
12
+ type Store = Record<string, UsageTotals>;
13
+
14
+ const FILE = () => path.join(dataDir, "usage.json");
15
+ const MAX_SESSIONS = 1000;
16
+
17
+ let store: Store | null = null;
18
+ let timer: NodeJS.Timeout | null = null;
19
+
20
+ function load(): Store {
21
+ return (store ??= readJson<Store>(FILE(), {}));
22
+ }
23
+
24
+ function write(): void {
25
+ const s = load();
26
+ // 只留最近用过的会话
27
+ const keys = Object.keys(s).sort((a, b) => s[b]!.at - s[a]!.at);
28
+ for (const k of keys.slice(MAX_SESSIONS)) delete s[k];
29
+ writeJsonAtomic(FILE(), s);
30
+ }
31
+
32
+ function persistSoon(): void {
33
+ if (timer) return;
34
+ timer = setTimeout(() => {
35
+ timer = null;
36
+ write();
37
+ }, 1000);
38
+ timer.unref();
39
+ }
40
+
41
+ /** 退出前把还没写的落盘 */
42
+ export function flushUsage(): void {
43
+ if (!timer) return;
44
+ clearTimeout(timer);
45
+ timer = null;
46
+ write();
47
+ }
48
+
49
+ const num = (v: unknown): number => (typeof v === "number" && Number.isFinite(v) && v > 0 ? v : 0);
50
+
51
+ const FIELDS = ["input", "cacheRead", "cacheWrite", "output", "reasoning"] as const;
52
+
53
+ /**
54
+ * cumulative:报的是累计值,每项取较大的(累计值只增不减;恢复的会话万一没从原来的总数接着算,
55
+ * 宁可先少算,也不把之前的再加一遍)。否则是增量,加上去
56
+ */
57
+ export function recordUsage(sessionId: string | undefined, usage: TokenUsage, cumulative = false): void {
58
+ if (!sessionId) return;
59
+ const s = load();
60
+ 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]);
62
+ const cost = num(usage.costUsd);
63
+ if (cost) t.costUsd = cumulative ? Math.max(t.costUsd ?? 0, cost) : (t.costUsd ?? 0) + cost;
64
+ t.at = Date.now();
65
+ persistSoon();
66
+ }
67
+
68
+ export function usageOf(sessionId: string | undefined): UsageTotals | undefined {
69
+ return sessionId ? load()[sessionId] : undefined;
70
+ }
@@ -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
+ }
@@ -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
+ }