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.
- package/README.md +9 -3
- package/package.json +1 -1
- package/src/agents/claude/driver.ts +80 -2
- package/src/agents/claude/session.ts +51 -7
- package/src/agents/codex/session.ts +43 -7
- package/src/channels/feishu/bot.ts +2 -2
- package/src/channels/web/api.ts +84 -2
- package/src/channels/web/conv-pages.ts +68 -0
- package/src/channels/web/server.ts +13 -4
- package/src/core/hub.ts +126 -19
- package/src/core/model-prices.json +103 -0
- package/src/core/pricing.ts +93 -0
- package/src/core/skill-runtime.ts +1 -1
- package/src/core/skills.ts +2 -1
- package/src/core/types.ts +24 -2
- package/src/core/usage.ts +144 -0
- package/web/css/composer.css +0 -25
- package/web/css/messages.css +169 -0
- package/web/css/settings.css +194 -0
- package/web/css/sidebar.css +5 -5
- package/web/css/skills.css +18 -1
- package/web/css/topbar.css +131 -0
- package/web/lib/dom.js +2 -1
- package/web/lib/markdown.js +84 -14
- package/web/lib/ws.js +46 -6
- package/web/settings/index.js +11 -4
- package/web/{views → settings}/skills.js +86 -172
- package/web/settings/usage.js +272 -0
- package/web/state.js +5 -4
- package/web/views/composer.js +2 -1
- package/web/views/interaction.js +12 -4
- package/web/views/layout.js +22 -25
- package/web/views/mention.js +2 -2
- package/web/views/messages.js +239 -81
- package/web/views/msg-nav.js +120 -0
- package/web/views/quota.js +1 -0
- package/web/views/sidebar.js +114 -17
- package/web/views/topbar.js +169 -41
|
@@ -143,6 +143,8 @@ type Client = {
|
|
|
143
143
|
/** 页面在前台,且正看着哪条会话(前端在切会话、切前后台时上报) */
|
|
144
144
|
visible: boolean;
|
|
145
145
|
viewing: string | null;
|
|
146
|
+
/** 最后一次收到这个页面的消息(含 ping) */
|
|
147
|
+
seen: number;
|
|
146
148
|
};
|
|
147
149
|
|
|
148
150
|
/** 每次进程启动换一个:客户端发现变了,就知道 seq 已经从头计数,得整页重拉 */
|
|
@@ -187,16 +189,22 @@ function broadcastTasks(owner: string): void {
|
|
|
187
189
|
for (const client of clients) if (client.user.id === owner) send(client, { t: "tasks" });
|
|
188
190
|
}
|
|
189
191
|
|
|
190
|
-
/**
|
|
192
|
+
/** 技能变了:以前装过别人技能的人也受影响,通知所有人重新拉(列表很小) */
|
|
191
193
|
function broadcastSkills(): void {
|
|
192
194
|
for (const client of clients) send(client, { t: "skills" });
|
|
193
195
|
}
|
|
194
196
|
|
|
195
197
|
// ---------- 系统通知(Web Push) ----------
|
|
196
198
|
|
|
197
|
-
/**
|
|
199
|
+
/**
|
|
200
|
+
* 有页面正在前台看着这条会话:人就在眼前,不用推。
|
|
201
|
+
* 页面每 25 秒发一次 ping;40 秒没收到它的任何消息就不算(手机切走后网络断了,连接还没被心跳清掉,
|
|
202
|
+
* 最后报的还是「在看」)
|
|
203
|
+
*/
|
|
204
|
+
const SEEN_MS = 40_000;
|
|
198
205
|
function watching(key: string): boolean {
|
|
199
|
-
|
|
206
|
+
const now = Date.now();
|
|
207
|
+
for (const client of clients) if (client.visible && client.viewing === key && now - client.seen < SEEN_MS) return true;
|
|
200
208
|
return false;
|
|
201
209
|
}
|
|
202
210
|
|
|
@@ -275,6 +283,7 @@ function handleClientMessage(client: Client, raw: string): void {
|
|
|
275
283
|
// 每条消息都复查登录态:握手时有效不代表现在还有效(改了密码、被踢了)
|
|
276
284
|
const user = checkClient(client);
|
|
277
285
|
if (!user) return;
|
|
286
|
+
client.seen = Date.now();
|
|
278
287
|
let msg: Record<string, unknown>;
|
|
279
288
|
try {
|
|
280
289
|
const parsed: unknown = JSON.parse(raw);
|
|
@@ -448,7 +457,7 @@ export async function startWeb(): Promise<void> {
|
|
|
448
457
|
}
|
|
449
458
|
const token = cookies(req)[COOKIE] ?? "";
|
|
450
459
|
wss!.handleUpgrade(req, socket, head, (ws) => {
|
|
451
|
-
const client: Client = { ws, subs: new Set(), alive: true, token, user, visible: false, viewing: null };
|
|
460
|
+
const client: Client = { ws, subs: new Set(), alive: true, token, user, visible: false, viewing: null, seen: Date.now() };
|
|
452
461
|
clients.add(client);
|
|
453
462
|
ws.on("pong", () => (client.alive = true));
|
|
454
463
|
ws.on("message", (data) => {
|
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
|
-
|
|
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,14 @@ 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) => {
|
|
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
|
+
},
|
|
625
659
|
onDropped: (n) => {
|
|
626
660
|
if (!alive() || n <= 0) return;
|
|
627
661
|
// 丢的是排在最后、还没开始的那 n 条(Codex 先报正在跑的那轮出错、再报丢弃,那时队头已经取走了)
|
|
@@ -630,6 +664,10 @@ class Hub extends EventEmitter<{ event: [EventEnvelope]; task: [TaskTurnEnd] }>
|
|
|
630
664
|
if (tag) this.emit("task", { ...tag, key, status: "interrupted", text: "排队时被停止了" });
|
|
631
665
|
}
|
|
632
666
|
this.setInflight(key, (this.inflight.get(key) ?? n) - n);
|
|
667
|
+
// 第一条是在跑的那一轮(它自己会报结果),丢的只会是后面排队的
|
|
668
|
+
const fifo = this.turnFifo.get(key) ?? [];
|
|
669
|
+
const k = Math.min(n, Math.max(0, fifo.length - 1));
|
|
670
|
+
for (const e of fifo.splice(fifo.length - k, k)) this.emitEvent(key, { type: "queue", id: e.id, state: "dropped" });
|
|
633
671
|
},
|
|
634
672
|
onSessionId: (sessionId) => {
|
|
635
673
|
// 切走之后旧会话晚到的回调不该覆盖新绑定
|
|
@@ -703,6 +741,7 @@ class Hub extends EventEmitter<{ event: [EventEnvelope]; task: [TaskTurnEnd] }>
|
|
|
703
741
|
this.live.delete(key);
|
|
704
742
|
for (const tag of this.turnTags.get(key) ?? []) if (tag) this.emit("task", { ...tag, key, status: "interrupted", text: note });
|
|
705
743
|
this.turnTags.delete(key);
|
|
744
|
+
this.clearFifo(key);
|
|
706
745
|
this.setInflight(key, 0);
|
|
707
746
|
if (!existing) return;
|
|
708
747
|
// dispose 卡住(CLI 不响应)不能把这条会话的操作队列永远堵死
|
|
@@ -831,6 +870,8 @@ class Hub extends EventEmitter<{ event: [EventEnvelope]; task: [TaskTurnEnd] }>
|
|
|
831
870
|
attachments: input.attachments.map((a) => `${KIND_LABEL[a.kind]} ${a.name}`),
|
|
832
871
|
files: input.attachments.map(attachmentInfo),
|
|
833
872
|
origin,
|
|
873
|
+
id: `m${++this.msgSeq}`,
|
|
874
|
+
queued: false,
|
|
834
875
|
};
|
|
835
876
|
const by = `${origin.name ?? "用户"}以消息作答`;
|
|
836
877
|
|
|
@@ -865,29 +906,42 @@ class Hub extends EventEmitter<{ event: [EventEnvelope]; task: [TaskTurnEnd] }>
|
|
|
865
906
|
updateConversation(key, { title: shown.text.trim().replace(/\s+/g, " ").slice(0, 40), titleAuto: true });
|
|
866
907
|
this.emitEvent(key, { type: "meta" });
|
|
867
908
|
}
|
|
909
|
+
// 前面有在跑的、或者还在串行队列里等着的:这条要排队
|
|
910
|
+
shown.queued = this.isBusy(key) || (this.waitingSends.get(key) ?? 0) > 0;
|
|
868
911
|
this.emitEvent(key, shown);
|
|
912
|
+
const dropped = () => this.emitEvent(key, { type: "queue", id: shown.id, state: "dropped" });
|
|
869
913
|
const gone = (text: string) => {
|
|
914
|
+
dropped();
|
|
870
915
|
if (task) this.emit("task", { ...task, key, status: "interrupted", text });
|
|
871
916
|
};
|
|
917
|
+
this.waitingSends.set(key, (this.waitingSends.get(key) ?? 0) + 1);
|
|
872
918
|
void this.serial(key, async () => {
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
919
|
+
try {
|
|
920
|
+
if (cancelled()) return gone("没有发出(停止了或者会话被切换了)");
|
|
921
|
+
if (questionAtEntry && tryAnswer(questionAtEntry)) {
|
|
922
|
+
// 被当成提问的回答用掉了:不再排队
|
|
923
|
+
if (shown.queued) this.emitEvent(key, { type: "queue", id: shown.id, state: "started" });
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
// 收到之后发生过显式切换(恢复 / 分叉 / 新会话…):用户已经明确指定了会话,不再按旧的闲置时间作废它。
|
|
927
|
+
// 定时任务也不按闲置过期:每天跑一次的任务要接着上次的上下文
|
|
928
|
+
const ttl = config().sessionTtlMs;
|
|
929
|
+
const switchedAfterEntry = (this.lastSwitch.get(key)?.at ?? 0) >= g.at;
|
|
930
|
+
const expired = !task && !switchedAfterEntry && ttl > 0 && Date.now() - lastActive > ttl;
|
|
931
|
+
if (expired) {
|
|
932
|
+
await this.expireNow(key);
|
|
933
|
+
// 清理要等旧会话 dispose,期间可能又点了停止
|
|
934
|
+
if (cancelled()) return dropped();
|
|
935
|
+
}
|
|
936
|
+
const blocked = await this.blockedSlash(key, input.text);
|
|
937
|
+
if (blocked) {
|
|
938
|
+
this.emitEvent(key, { type: "notice", text: blocked });
|
|
939
|
+
return gone(blocked);
|
|
940
|
+
}
|
|
941
|
+
this.sendNow(key, this.withMentions(key, input), task ?? null, { id: shown.id, queued: shown.queued });
|
|
942
|
+
} finally {
|
|
943
|
+
this.waitingSends.set(key, Math.max(0, (this.waitingSends.get(key) ?? 1) - 1));
|
|
889
944
|
}
|
|
890
|
-
this.sendNow(key, this.withMentions(key, input), task ?? null);
|
|
891
945
|
});
|
|
892
946
|
}
|
|
893
947
|
|
|
@@ -946,7 +1000,8 @@ class Hub extends EventEmitter<{ event: [EventEnvelope]; task: [TaskTurnEnd] }>
|
|
|
946
1000
|
this.emitEvent(key, { type: "meta" });
|
|
947
1001
|
}
|
|
948
1002
|
|
|
949
|
-
|
|
1003
|
+
/** msg:页面上那条消息的编号和收到时是否排队(见 send 里的 shown) */
|
|
1004
|
+
private sendNow(key: string, input: UserInput, tag: TaskTurn | null, msg: { id: string; queued: boolean }): void {
|
|
950
1005
|
const fail = (err: unknown) => {
|
|
951
1006
|
const message = err instanceof Error ? err.message : String(err);
|
|
952
1007
|
this.emitEvent(key, { type: "error", message });
|
|
@@ -963,12 +1018,22 @@ class Hub extends EventEmitter<{ event: [EventEnvelope]; task: [TaskTurnEnd] }>
|
|
|
963
1018
|
const tags = this.turnTags.get(key) ?? [];
|
|
964
1019
|
tags.push(tag);
|
|
965
1020
|
this.turnTags.set(key, tags);
|
|
1021
|
+
const fifo = this.turnFifo.get(key) ?? [];
|
|
1022
|
+
fifo.push({ id: msg.id, input, queued: msg.queued });
|
|
1023
|
+
this.turnFifo.set(key, fifo);
|
|
966
1024
|
try {
|
|
967
1025
|
session.send(input);
|
|
968
1026
|
} catch (err) {
|
|
969
1027
|
tags.pop();
|
|
1028
|
+
fifo.pop();
|
|
970
1029
|
this.setInflight(key, (this.inflight.get(key) ?? 1) - 1);
|
|
971
1030
|
fail(err);
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
// 收到时在排队(前面的在串行队列里等着),交出去时前面已经空了:这就开始了
|
|
1034
|
+
if (fifo.length === 1 && msg.queued) {
|
|
1035
|
+
fifo[0]!.queued = false;
|
|
1036
|
+
this.emitEvent(key, { type: "queue", id: msg.id, state: "started" });
|
|
972
1037
|
}
|
|
973
1038
|
}
|
|
974
1039
|
|
|
@@ -1247,10 +1312,51 @@ class Hub extends EventEmitter<{ event: [EventEnvelope]; task: [TaskTurnEnd] }>
|
|
|
1247
1312
|
// 和 inflight 一起清零:被打断的那一轮不一定还会报结果,留着的话下一轮的结果会被当成定时任务的
|
|
1248
1313
|
for (const tag of this.turnTags.get(key) ?? []) if (tag) this.emit("task", { ...tag, key, status: "interrupted", text: "被停止了" });
|
|
1249
1314
|
this.turnTags.delete(key);
|
|
1315
|
+
this.clearFifo(key);
|
|
1250
1316
|
this.setInflight(key, 0);
|
|
1251
1317
|
return "已请求中断。";
|
|
1252
1318
|
}
|
|
1253
1319
|
|
|
1320
|
+
/**
|
|
1321
|
+
* 打断并发送:停掉正在跑的这一轮,排着队的 id 这条挪到最前面,马上处理它;其余排着的照常接在后面。
|
|
1322
|
+
* 和停止不同,不丢排队的消息、也不清计数 —— 被打断的这一轮会正常报结果(已中断),之后就轮到它
|
|
1323
|
+
*/
|
|
1324
|
+
async jump(key: string, id: string): Promise<string> {
|
|
1325
|
+
const fifo = this.turnFifo.get(key) ?? [];
|
|
1326
|
+
const idx = fifo.findIndex((e) => e.id === id);
|
|
1327
|
+
if (idx === 0) return "这条已经在处理了。";
|
|
1328
|
+
const live = this.live.get(key);
|
|
1329
|
+
if (idx < 0 || !live) return "这条消息已经不在排队了。";
|
|
1330
|
+
const [entry] = fifo.splice(idx, 1);
|
|
1331
|
+
fifo.splice(1, 0, entry!);
|
|
1332
|
+
// turnTags 和 fifo 一一对应,一起挪
|
|
1333
|
+
const tags = this.turnTags.get(key);
|
|
1334
|
+
if (tags && tags.length === fifo.length) tags.splice(1, 0, ...tags.splice(idx, 1));
|
|
1335
|
+
this.stoppedAt.set(key, Date.now());
|
|
1336
|
+
interactions.cancelAll(key, "本轮已中断");
|
|
1337
|
+
await live.session.interrupt({ keepQueue: true, first: entry!.input });
|
|
1338
|
+
return "已打断,马上处理这条。";
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
/** 一轮出结果了:队首出队,下一条要是排过队的,告诉页面轮到它了 */
|
|
1342
|
+
private finishTurn(key: string): void {
|
|
1343
|
+
const fifo = this.turnFifo.get(key);
|
|
1344
|
+
if (!fifo) return;
|
|
1345
|
+
fifo.shift();
|
|
1346
|
+
const next = fifo[0];
|
|
1347
|
+
if (next?.queued) {
|
|
1348
|
+
next.queued = false;
|
|
1349
|
+
this.emitEvent(key, { type: "queue", id: next.id, state: "started" });
|
|
1350
|
+
}
|
|
1351
|
+
if (fifo.length === 0) this.turnFifo.delete(key);
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
/** 停止 / 会话丢了:在跑的那条不管(它的结果或中断自己会报),排着队的都没发出 */
|
|
1355
|
+
private clearFifo(key: string): void {
|
|
1356
|
+
for (const e of (this.turnFifo.get(key) ?? []).slice(1)) this.emitEvent(key, { type: "queue", id: e.id, state: "dropped" });
|
|
1357
|
+
this.turnFifo.delete(key);
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1254
1360
|
private async cdNow(key: string, dir: string, op: SwitchOp): Promise<string> {
|
|
1255
1361
|
const resolved = resolveWorkspacePath(dir);
|
|
1256
1362
|
if (!resolved) return `不允许:目录必须存在,且在 ${config().workspaceRoot} 之内(不能经符号链接跳出去)。`;
|
|
@@ -1515,6 +1621,7 @@ class Hub extends EventEmitter<{ event: [EventEnvelope]; task: [TaskTurnEnd] }>
|
|
|
1515
1621
|
async shutdown(): Promise<void> {
|
|
1516
1622
|
for (const key of [...this.live.keys()]) await this.drop(key, "服务正在重启");
|
|
1517
1623
|
for (const driver of this.drivers.values()) await driver.stop().catch(() => {});
|
|
1624
|
+
flushUsage();
|
|
1518
1625
|
}
|
|
1519
1626
|
}
|
|
1520
1627
|
|
|
@@ -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
|
+
}
|
|
@@ -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("
|
|
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 也会自己挑合适的用。");
|
package/src/core/skills.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
/** 手上已知的模型 / 思考程度,同步返回:不拉起进程、不发请求 */
|