hob-bot 1.3.0 → 1.5.0
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 +42 -23
- package/README.zh-CN.md +43 -24
- package/bin/hob.mjs +8 -2
- package/dist/web/assets/{DesktopView-lixZfbYr.js → DesktopView-3QDYo0UU.js} +1 -1
- package/dist/web/assets/index-Dzxrt9T3.js +283 -0
- package/dist/web/assets/index-FUtuyxyO.css +1 -0
- package/dist/web/index.html +2 -2
- package/dist/web/mock/readme-cover.svg +13 -0
- package/dist/web/mock/readme-page.html +7 -0
- package/package.json +2 -1
- package/server/src/actions.ts +2 -1
- package/server/src/assistant-skills.ts +2 -2
- package/server/src/boot.ts +8 -0
- package/server/src/config.ts +52 -11
- package/server/src/cron-text.ts +14 -1
- package/server/src/delete-skill.ts +19 -0
- package/server/src/desktop-tools.ts +6 -6
- package/server/src/files.ts +27 -7
- package/server/src/http.ts +41 -9
- package/server/src/job-view.ts +2 -2
- package/server/src/jobs.ts +108 -62
- package/server/src/knowledge-tools.ts +1 -1
- package/server/src/knowledge.ts +9 -4
- package/server/src/layout-cli.ts +25 -0
- package/server/src/layout.ts +749 -0
- package/server/src/main.ts +5 -0
- package/server/src/mcp.ts +3 -2
- package/server/src/memory.ts +2 -2
- package/server/src/now.ts +1 -1
- package/server/src/overview.ts +1 -2
- package/server/src/pages.ts +9 -7
- package/server/src/prompt.ts +47 -110
- package/server/src/push.ts +3 -2
- package/server/src/router-tools.ts +96 -40
- package/server/src/router.ts +5 -4
- package/server/src/routines.ts +177 -22
- package/server/src/runtime.ts +12 -1
- package/server/src/search.ts +1 -1
- package/server/src/secrets.ts +3 -2
- package/server/src/settings.ts +44 -6
- package/server/src/store.ts +123 -40
- package/server/src/transcript.ts +25 -7
- package/server/src/usage-prices.ts +166 -0
- package/server/src/usage.ts +291 -0
- package/server/src/worker-tools.ts +6 -9
- package/shared/types.ts +110 -3
- package/dist/web/assets/index-BZhge0qe.js +0 -267
- package/dist/web/assets/index-DIo1ArRa.css +0 -1
package/server/src/store.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync, appendFileSync } from "node:fs";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
|
+
import { archiveAssistantsDir, assistantDir, assistantJobsDir } from "./config.ts";
|
|
3
4
|
import { newId } from "./ids.ts";
|
|
4
5
|
import type {
|
|
5
6
|
Approval,
|
|
@@ -49,14 +50,19 @@ export interface AssistantState {
|
|
|
49
50
|
|
|
50
51
|
/**
|
|
51
52
|
* HOB_HOME 下的全部 JSON / JSONL 数据。文件都小,直接同步读写,写入走临时文件 + rename。
|
|
53
|
+
*
|
|
54
|
+
* v2 布局里任务和审批、定时都跟着所属助手放(assistants/<id>/jobs|approvals.json|routines.json),
|
|
55
|
+
* 进不到目录的地方要知道 jobId 落在哪个助手下面,所以这里维护一份内存索引,启动时扫一遍、
|
|
56
|
+
* 新建任务时登记;不存索引文件,免得和实际目录对不上。
|
|
52
57
|
*/
|
|
53
58
|
export class Store {
|
|
59
|
+
/** jobId → 任务目录(含归档助手目录里的) */
|
|
60
|
+
private jobIndex: Map<string, string> | null = null;
|
|
61
|
+
|
|
54
62
|
constructor(readonly home: string) {}
|
|
55
63
|
|
|
56
64
|
init(): void {
|
|
57
|
-
|
|
58
|
-
mkdirSync(join(this.home, dir), { recursive: true });
|
|
59
|
-
}
|
|
65
|
+
mkdirSync(join(this.home, "assistants"), { recursive: true });
|
|
60
66
|
if (!existsSync(join(this.home, "rules.json"))) {
|
|
61
67
|
const changedAt = new Date().toISOString();
|
|
62
68
|
this.saveGlobalRules([
|
|
@@ -80,7 +86,7 @@ export class Store {
|
|
|
80
86
|
|
|
81
87
|
// ---------- 助手 ----------
|
|
82
88
|
private assistantDir(id: string): string {
|
|
83
|
-
return
|
|
89
|
+
return assistantDir(this.home, id);
|
|
84
90
|
}
|
|
85
91
|
|
|
86
92
|
assistants(): Assistant[] {
|
|
@@ -110,14 +116,16 @@ export class Store {
|
|
|
110
116
|
}
|
|
111
117
|
|
|
112
118
|
/**
|
|
113
|
-
*
|
|
114
|
-
*
|
|
119
|
+
* 归档助手:整个助手目录(含自己的任务、定时、审批、文件)移进 archive/assistants/<id>-<时间>/。
|
|
120
|
+
* 同盘 rename,不复制也不删数据。
|
|
115
121
|
*/
|
|
116
122
|
archiveAssistant(id: string, name: string, routines: Routine[] = []): string {
|
|
117
123
|
if (routines.length) writeJsonAtomic(join(this.assistantDir(id), "routines.json"), routines);
|
|
118
|
-
const target = join(this.home,
|
|
124
|
+
const target = join(archiveAssistantsDir(this.home), name);
|
|
119
125
|
mkdirSync(dirname(target), { recursive: true });
|
|
120
126
|
renameSync(this.assistantDir(id), target);
|
|
127
|
+
// 任务跟着搬走了,索引要重建
|
|
128
|
+
this.jobIndex = null;
|
|
121
129
|
return target;
|
|
122
130
|
}
|
|
123
131
|
|
|
@@ -151,13 +159,45 @@ export class Store {
|
|
|
151
159
|
appendFileSync(join(dir, "worklog.jsonl"), `${JSON.stringify(entry)}\n`);
|
|
152
160
|
}
|
|
153
161
|
|
|
154
|
-
// ----------
|
|
155
|
-
|
|
156
|
-
|
|
162
|
+
// ---------- 定时和审批:跟着所属助手 ----------
|
|
163
|
+
/** 每个助手目录下的一份记录文件(含归档目录里的),有就给路径。 */
|
|
164
|
+
private recordFiles(name: string, archived: boolean): string[] {
|
|
165
|
+
const out: string[] = [];
|
|
166
|
+
const live = join(this.home, "assistants");
|
|
167
|
+
if (existsSync(live)) {
|
|
168
|
+
for (const id of readdirSync(live)) {
|
|
169
|
+
const file = join(live, id, name);
|
|
170
|
+
if (existsSync(file)) out.push(file);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (!archived) return out;
|
|
174
|
+
const root = archiveAssistantsDir(this.home);
|
|
175
|
+
if (existsSync(root)) {
|
|
176
|
+
for (const folder of readdirSync(root)) {
|
|
177
|
+
const file = join(root, folder, name);
|
|
178
|
+
if (existsSync(file)) out.push(file);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return out;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** 写这个助手的记录文件:助手归档了就跟到归档目录,不然就地建。 */
|
|
185
|
+
private ownerFile(assistantId: string, name: string): string {
|
|
186
|
+
const live = this.assistantDir(assistantId);
|
|
187
|
+
if (existsSync(join(live, "profile.json"))) return join(live, name);
|
|
188
|
+
const root = archiveAssistantsDir(this.home);
|
|
189
|
+
if (existsSync(root)) {
|
|
190
|
+
for (const folder of readdirSync(root)) {
|
|
191
|
+
const dir = join(root, folder);
|
|
192
|
+
const profile = readJson<{ id?: unknown }>(join(dir, "profile.json"), {});
|
|
193
|
+
if (profile.id === assistantId) return join(dir, name);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return join(live, name);
|
|
157
197
|
}
|
|
158
198
|
|
|
159
199
|
routines(): Routine[] {
|
|
160
|
-
return readJson<Routine[]>(
|
|
200
|
+
return this.recordFiles("routines.json", false).flatMap((file) => readJson<Routine[]>(file, []));
|
|
161
201
|
}
|
|
162
202
|
|
|
163
203
|
routine(id: string): Routine | undefined {
|
|
@@ -165,22 +205,29 @@ export class Store {
|
|
|
165
205
|
}
|
|
166
206
|
|
|
167
207
|
saveRoutine(routine: Routine): void {
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
208
|
+
const target = this.ownerFile(routine.assistantId, "routines.json");
|
|
209
|
+
for (const file of this.recordFiles("routines.json", true)) {
|
|
210
|
+
const list = readJson<Routine[]>(file, []);
|
|
211
|
+
const next = list.filter((r) => r.id !== routine.id);
|
|
212
|
+
if (next.length !== list.length) writeJsonAtomic(file, next);
|
|
213
|
+
}
|
|
214
|
+
const list = readJson<Routine[]>(target, []).filter((r) => r.id !== routine.id);
|
|
215
|
+
list.push(routine);
|
|
216
|
+
writeJsonAtomic(target, list);
|
|
171
217
|
}
|
|
172
218
|
|
|
219
|
+
/** 只从还在的助手里拿掉;归档目录里那份是历史,留着。 */
|
|
173
220
|
deleteRoutine(id: string): void {
|
|
174
|
-
|
|
221
|
+
for (const file of this.recordFiles("routines.json", false)) {
|
|
222
|
+
const list = readJson<Routine[]>(file, []);
|
|
223
|
+
const next = list.filter((r) => r.id !== id);
|
|
224
|
+
if (next.length !== list.length) writeJsonAtomic(file, next);
|
|
225
|
+
}
|
|
175
226
|
}
|
|
176
227
|
|
|
177
228
|
// ---------- 审批 ----------
|
|
178
|
-
private approvalsFile(): string {
|
|
179
|
-
return join(this.home, "approvals.json");
|
|
180
|
-
}
|
|
181
|
-
|
|
182
229
|
approvals(): Approval[] {
|
|
183
|
-
return readJson<Approval[]>(
|
|
230
|
+
return this.recordFiles("approvals.json", true).flatMap((file) => readJson<Approval[]>(file, []));
|
|
184
231
|
}
|
|
185
232
|
|
|
186
233
|
approval(id: string): Approval | undefined {
|
|
@@ -188,14 +235,24 @@ export class Store {
|
|
|
188
235
|
}
|
|
189
236
|
|
|
190
237
|
saveApproval(approval: Approval): void {
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
238
|
+
const target = this.ownerFile(approval.assistantId, "approvals.json");
|
|
239
|
+
for (const file of this.recordFiles("approvals.json", true)) {
|
|
240
|
+
const list = readJson<Approval[]>(file, []);
|
|
241
|
+
const next = list.filter((a) => a.id !== approval.id);
|
|
242
|
+
if (next.length !== list.length) writeJsonAtomic(file, next);
|
|
243
|
+
}
|
|
244
|
+
const list = readJson<Approval[]>(target, []).filter((a) => a.id !== approval.id);
|
|
245
|
+
list.push(approval);
|
|
246
|
+
writeJsonAtomic(target, list);
|
|
194
247
|
}
|
|
195
248
|
|
|
196
249
|
// ---------- 委派 ----------
|
|
250
|
+
private hobFile(name: string): string {
|
|
251
|
+
return join(this.assistantDir("hob"), name);
|
|
252
|
+
}
|
|
253
|
+
|
|
197
254
|
delegations(): Delegation[] {
|
|
198
|
-
return readJson<Delegation[]>(
|
|
255
|
+
return readJson<Delegation[]>(this.hobFile("delegations.json"), []);
|
|
199
256
|
}
|
|
200
257
|
|
|
201
258
|
delegation(id: string): Delegation | undefined {
|
|
@@ -205,16 +262,12 @@ export class Store {
|
|
|
205
262
|
saveDelegation(delegation: Delegation): void {
|
|
206
263
|
const all = this.delegations().filter((item) => item.id !== delegation.id);
|
|
207
264
|
all.push(delegation);
|
|
208
|
-
writeJsonAtomic(
|
|
265
|
+
writeJsonAtomic(this.hobFile("delegations.json"), all);
|
|
209
266
|
}
|
|
210
267
|
|
|
211
268
|
// ---------- 草稿 ----------
|
|
212
|
-
private draftsFile(): string {
|
|
213
|
-
return join(this.home, "drafts.json");
|
|
214
|
-
}
|
|
215
|
-
|
|
216
269
|
drafts(): Draft[] {
|
|
217
|
-
return readJson<Draft[]>(this.
|
|
270
|
+
return readJson<Draft[]>(this.hobFile("drafts.json"), []);
|
|
218
271
|
}
|
|
219
272
|
|
|
220
273
|
draft(id: string): Draft | undefined {
|
|
@@ -224,31 +277,61 @@ export class Store {
|
|
|
224
277
|
saveDraft(draft: Draft): void {
|
|
225
278
|
const all = this.drafts().filter((d) => d.id !== draft.id);
|
|
226
279
|
all.push(draft);
|
|
227
|
-
writeJsonAtomic(this.
|
|
280
|
+
writeJsonAtomic(this.hobFile("drafts.json"), all);
|
|
228
281
|
}
|
|
229
282
|
|
|
230
283
|
// ---------- 任务 ----------
|
|
284
|
+
/** 所有任务目录的根:在的助手和归档的助手都算。 */
|
|
285
|
+
private jobRoots(): string[] {
|
|
286
|
+
const out: string[] = [];
|
|
287
|
+
const live = join(this.home, "assistants");
|
|
288
|
+
if (existsSync(live)) for (const id of readdirSync(live)) out.push(assistantJobsDir(this.home, id));
|
|
289
|
+
const archived = archiveAssistantsDir(this.home);
|
|
290
|
+
if (existsSync(archived)) for (const folder of readdirSync(archived)) out.push(join(archived, folder, "jobs"));
|
|
291
|
+
return out.filter((dir) => existsSync(dir));
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
private rebuildJobIndex(): Map<string, string> {
|
|
295
|
+
const index = new Map<string, string>();
|
|
296
|
+
for (const root of this.jobRoots()) {
|
|
297
|
+
for (const id of readdirSync(root).sort()) {
|
|
298
|
+
const dir = join(root, id);
|
|
299
|
+
if (existsSync(join(dir, "job.json"))) index.set(id, dir);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
this.jobIndex = index;
|
|
303
|
+
return index;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
private findJobDir(id: string): string | undefined {
|
|
307
|
+
const hit = (this.jobIndex ?? this.rebuildJobIndex()).get(id);
|
|
308
|
+
if (hit) return hit;
|
|
309
|
+
return this.rebuildJobIndex().get(id);
|
|
310
|
+
}
|
|
311
|
+
|
|
231
312
|
jobDir(id: string): string {
|
|
232
|
-
return join(this.home, "jobs", id);
|
|
313
|
+
return this.findJobDir(id) ?? join(this.home, "assistants", "hob", "jobs", id);
|
|
233
314
|
}
|
|
234
315
|
|
|
235
316
|
jobs(): Job[] {
|
|
236
|
-
const root = join(this.home, "jobs");
|
|
237
|
-
if (!existsSync(root)) return [];
|
|
238
317
|
const out: Job[] = [];
|
|
239
|
-
for (const
|
|
240
|
-
const job = readJson<Job | null>(join(
|
|
318
|
+
for (const dir of this.rebuildJobIndex().values()) {
|
|
319
|
+
const job = readJson<Job | null>(join(dir, "job.json"), null);
|
|
241
320
|
if (job) out.push(job);
|
|
242
321
|
}
|
|
243
322
|
return out.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
244
323
|
}
|
|
245
324
|
|
|
246
325
|
job(id: string): Job | undefined {
|
|
247
|
-
|
|
326
|
+
const dir = this.findJobDir(id);
|
|
327
|
+
return dir ? readJson<Job | null>(join(dir, "job.json"), null) ?? undefined : undefined;
|
|
248
328
|
}
|
|
249
329
|
|
|
250
330
|
saveJob(job: Job): void {
|
|
251
|
-
|
|
331
|
+
const dir = this.findJobDir(job.id) ?? join(assistantJobsDir(this.home, job.assistantId), job.id);
|
|
332
|
+
writeJsonAtomic(join(dir, "job.json"), job);
|
|
333
|
+
this.jobIndex ??= new Map();
|
|
334
|
+
this.jobIndex.set(job.id, dir);
|
|
252
335
|
}
|
|
253
336
|
|
|
254
337
|
writeReport(jobId: string, text: string): string {
|
|
@@ -285,10 +368,10 @@ export class Store {
|
|
|
285
368
|
*/
|
|
286
369
|
migrateMemories(): void {
|
|
287
370
|
type OldDraft = Draft & { memories?: string[]; carry: Draft["carry"] & { memories?: number } };
|
|
288
|
-
const drafts = readJson<OldDraft[]>(this.
|
|
371
|
+
const drafts = readJson<OldDraft[]>(this.hobFile("drafts.json"), []);
|
|
289
372
|
if (drafts.some((draft) => !Array.isArray(draft.instructions))) {
|
|
290
373
|
writeJsonAtomic(
|
|
291
|
-
this.
|
|
374
|
+
this.hobFile("drafts.json"),
|
|
292
375
|
drafts.map(({ memories, carry: { memories: _carried, ...carry }, ...draft }) => ({
|
|
293
376
|
...draft,
|
|
294
377
|
instructions: draft.instructions ?? memories ?? [],
|
package/server/src/transcript.ts
CHANGED
|
@@ -1,11 +1,22 @@
|
|
|
1
1
|
// 任务过程(transcript):从任务的 worker.jsonl 推导步骤列表,实时事件和落盘推导共用一套拼装。
|
|
2
2
|
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { isAbsolute } from "node:path";
|
|
3
4
|
import { commandSummary } from "../../shared/command.ts";
|
|
4
5
|
import type { JobStep } from "../../shared/types.ts";
|
|
5
6
|
import { showPath } from "./text.ts";
|
|
6
7
|
|
|
7
8
|
/** 沉淀技能那一轮发给任务的话的开头:任务详情里这句显示成「沉淀技能」。 */
|
|
8
|
-
export const SEDIMENT_PROMPT_HEAD = "
|
|
9
|
+
export const SEDIMENT_PROMPT_HEAD = "回顾刚才的过程,看看有没有能复用的做法。这类事以后还会再做的,把做法写成技能";
|
|
10
|
+
|
|
11
|
+
/** 旧版用过的开头:已经存下来的任务记录里还是这些,任务详情照样显示成「沉淀技能」。 */
|
|
12
|
+
const LEGACY_SEDIMENT_HEADS = [
|
|
13
|
+
"这件事以后还会再做。回顾刚才的过程,把能复用的做法写成技能",
|
|
14
|
+
"这件事以后还会再做。回顾刚才的过程,把能复用的做法沉淀成技能",
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
function isSedimentPrompt(text: string): boolean {
|
|
18
|
+
return text.startsWith(SEDIMENT_PROMPT_HEAD) || LEGACY_SEDIMENT_HEADS.some((head) => text.startsWith(head));
|
|
19
|
+
}
|
|
9
20
|
|
|
10
21
|
/** transcript 里单步输出最多这么多字,超出的整段落盘,页面按路径去拉。 */
|
|
11
22
|
export const STEP_OUTPUT_LIMIT = 20 * 1024;
|
|
@@ -14,7 +25,7 @@ export const STEP_EVENT_LIMIT = 4 * 1024;
|
|
|
14
25
|
/** diff 只给看得清的一段,超长的改动不整段塞进接口。 */
|
|
15
26
|
const EDIT_TEXT_LIMIT = 4_000;
|
|
16
27
|
|
|
17
|
-
const AUTO_FOLLOW_UP = "
|
|
28
|
+
const AUTO_FOLLOW_UP = "这一轮结束了,还没有交结果。";
|
|
18
29
|
|
|
19
30
|
function oneLine(value: string, max = 120): string {
|
|
20
31
|
const line = value.replace(/\s+/g, " ").trim();
|
|
@@ -38,8 +49,10 @@ function clipEdit(edits: unknown): { oldText: string; newText: string }[] {
|
|
|
38
49
|
}
|
|
39
50
|
|
|
40
51
|
/** 只留详情页要看的那几个参数(write 的正文不整段传,只给长度)。 */
|
|
41
|
-
export function toolArgs(name: string, raw: Record<string, unknown>, cwd = ""): Record<string, unknown> {
|
|
42
|
-
const
|
|
52
|
+
export function toolArgs(name: string, raw: Record<string, unknown>, cwd = "", resolvePath?: (path: string) => string): Record<string, unknown> {
|
|
53
|
+
const given = String(raw.path ?? "");
|
|
54
|
+
// 迁移前写进会话的绝对路径先按 moves.json 换成新位置再和 cwd 比:老步骤照样显示成短文件名
|
|
55
|
+
const path = showPath(isAbsolute(given) && resolvePath ? resolvePath(given) : given, cwd);
|
|
43
56
|
switch (name) {
|
|
44
57
|
case "bash":
|
|
45
58
|
return { command: String(raw.command ?? "") };
|
|
@@ -197,11 +210,13 @@ export interface ToolStepInput {
|
|
|
197
210
|
running?: boolean;
|
|
198
211
|
/** MCP 工具的显示名映射(转义后的服务名 → 配置里的原名) */
|
|
199
212
|
mcpServers?: Record<string, string>;
|
|
213
|
+
/** 会话里的老绝对路径换到新位置(迁移后显示老步骤用) */
|
|
214
|
+
resolvePath?: (path: string) => string;
|
|
200
215
|
}
|
|
201
216
|
|
|
202
217
|
/** 一次工具调用 → 一个步骤(还没有结果)。实时事件和落盘推导都用它,页面按 id 合并。 */
|
|
203
218
|
export function toolStep(input: ToolStepInput): JobStep {
|
|
204
|
-
const args = toolArgs(input.name, input.args, input.cwd ?? "");
|
|
219
|
+
const args = toolArgs(input.name, input.args, input.cwd ?? "", input.resolvePath);
|
|
205
220
|
const mcp = mcpToolDisplay(input.name, input.mcpServers);
|
|
206
221
|
return {
|
|
207
222
|
id: input.id,
|
|
@@ -245,6 +260,8 @@ export interface DeriveOptions {
|
|
|
245
260
|
save?: (stepId: string, text: string) => string;
|
|
246
261
|
/** MCP 工具的显示名映射(转义后的服务名 → 配置里的原名) */
|
|
247
262
|
mcpServers?: Record<string, string>;
|
|
263
|
+
/** 会话里的老绝对路径换到新位置(迁移后显示老步骤用) */
|
|
264
|
+
resolvePath?: (path: string) => string;
|
|
248
265
|
}
|
|
249
266
|
|
|
250
267
|
/** 从任务会话文件推导整个过程:说明文字、思考摘要、工具调用(含结果)。 */
|
|
@@ -290,6 +307,7 @@ export function deriveSteps(file: string, options: DeriveOptions = {}): JobStep[
|
|
|
290
307
|
at,
|
|
291
308
|
cwd: options.cwd ?? "",
|
|
292
309
|
mcpServers: options.mcpServers,
|
|
310
|
+
resolvePath: options.resolvePath,
|
|
293
311
|
running: true,
|
|
294
312
|
});
|
|
295
313
|
toolIndex.set(step.id, steps.length);
|
|
@@ -313,12 +331,12 @@ export function deriveSteps(file: string, options: DeriveOptions = {}): JobStep[
|
|
|
313
331
|
|
|
314
332
|
if (message.role === "user") {
|
|
315
333
|
const text = textOfContent(message.content).trim();
|
|
316
|
-
if (!text || text.startsWith(AUTO_FOLLOW_UP)) continue;
|
|
334
|
+
if (!text || text.startsWith(AUTO_FOLLOW_UP) || text.startsWith("你还没有调用 report")) continue;
|
|
317
335
|
if (!seenUser) {
|
|
318
336
|
seenUser = true; // 第一句是任务说明,详情头部已经有
|
|
319
337
|
continue;
|
|
320
338
|
}
|
|
321
|
-
steps.push({ id: `u${message.timestamp ?? at}`, kind: "text", at, from: "user", text: text
|
|
339
|
+
steps.push({ id: `u${message.timestamp ?? at}`, kind: "text", at, from: "user", text: isSedimentPrompt(text) ? "沉淀技能" : text });
|
|
322
340
|
}
|
|
323
341
|
}
|
|
324
342
|
return steps;
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// 模型单价:默认从 models.dev 拉,一天最多更新一次;存在 HOB_HOME/cache/usage-prices.json,拉不到就用上次的。
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import type { TokenPrice } from "../../shared/types.ts";
|
|
5
|
+
import { cacheDir } from "./config.ts";
|
|
6
|
+
|
|
7
|
+
const SOURCE = "https://models.dev/api.json";
|
|
8
|
+
const FILE = "usage-prices.json";
|
|
9
|
+
const DAY = 24 * 3600 * 1000;
|
|
10
|
+
/** 失败后这么久不再试:价格表本来就一天更新一次,失败时别让每次打开页面都等一次超时。 */
|
|
11
|
+
const RETRY = 10 * 60 * 1000;
|
|
12
|
+
const TIMEOUT = 10_000;
|
|
13
|
+
|
|
14
|
+
/** 模型厂商自己那一家:同一个模型 id 出现在多个服务商里时优先它。 */
|
|
15
|
+
const VENDOR_PROVIDERS = new Set(["openai", "anthropic", "deepseek", "google", "xai", "mistral", "zhipuai", "moonshotai"]);
|
|
16
|
+
|
|
17
|
+
/** HOB_HOME/cache/usage-prices.json:拉到的单价 + 上次更新和上次尝试的时间。 */
|
|
18
|
+
interface PriceFile {
|
|
19
|
+
fetchedAt: string | null;
|
|
20
|
+
attemptAt: string | null;
|
|
21
|
+
error: string | null;
|
|
22
|
+
prices: Record<string, TokenPrice>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const EMPTY: PriceFile = { fetchedAt: null, attemptAt: null, error: null, prices: {} };
|
|
26
|
+
|
|
27
|
+
function readFile(home: string): PriceFile {
|
|
28
|
+
const path = join(cacheDir(home), FILE);
|
|
29
|
+
if (!existsSync(path)) return EMPTY;
|
|
30
|
+
try {
|
|
31
|
+
const stored = JSON.parse(readFileSync(path, "utf8")) as Partial<PriceFile>;
|
|
32
|
+
const prices = stored.prices && typeof stored.prices === "object" ? stored.prices : {};
|
|
33
|
+
return {
|
|
34
|
+
fetchedAt: typeof stored.fetchedAt === "string" ? stored.fetchedAt : null,
|
|
35
|
+
attemptAt: typeof stored.attemptAt === "string" ? stored.attemptAt : null,
|
|
36
|
+
error: typeof stored.error === "string" ? stored.error : null,
|
|
37
|
+
prices: Object.fromEntries(Object.entries(prices).filter(([, price]) => isPrice(price))),
|
|
38
|
+
};
|
|
39
|
+
} catch {
|
|
40
|
+
return EMPTY;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isPrice(value: unknown): value is TokenPrice {
|
|
45
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
46
|
+
const price = value as Record<string, unknown>;
|
|
47
|
+
return ["input", "output", "cacheRead", "cacheWrite"].every((key) => typeof price[key] === "number" && Number.isFinite(price[key]) && (price[key] as number) >= 0);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 用户填的单价:四项都是大于等于 0 的数字,不合法说清哪一项。 */
|
|
51
|
+
export function checkPrice(raw: unknown): TokenPrice {
|
|
52
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("单价要填四项:输入、输出、缓存读、缓存写。");
|
|
53
|
+
const input = raw as Record<string, unknown>;
|
|
54
|
+
const names: Record<keyof TokenPrice, string> = { input: "输入", output: "输出", cacheRead: "缓存读", cacheWrite: "缓存写" };
|
|
55
|
+
const out = {} as TokenPrice;
|
|
56
|
+
for (const key of Object.keys(names) as (keyof TokenPrice)[]) {
|
|
57
|
+
const value = input[key];
|
|
58
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
|
59
|
+
throw new Error(`「${names[key]}」的单价要填大于等于 0 的数字(美元 / 百万 token)。`);
|
|
60
|
+
}
|
|
61
|
+
out[key] = Number(value.toFixed(6));
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function failureReason(error: unknown): string {
|
|
67
|
+
if (error instanceof Error) {
|
|
68
|
+
if (error.name === "TimeoutError" || error.name === "AbortError") return "请求超时";
|
|
69
|
+
return error.message;
|
|
70
|
+
}
|
|
71
|
+
return String(error);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* models.dev 的整份数据 → 模型 id → 单价。
|
|
76
|
+
* 同一个模型 id 在多个服务商里都有时,优先模型厂商自己那家(`canonical_model_id` 指回自己,或 id 在厂商名单里)。
|
|
77
|
+
*/
|
|
78
|
+
export function indexPrices(raw: unknown): Record<string, TokenPrice> {
|
|
79
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("返回的不是服务商列表");
|
|
80
|
+
const candidates = new Map<string, { vendor: boolean; price: TokenPrice }[]>();
|
|
81
|
+
for (const [providerId, provider] of Object.entries(raw as Record<string, unknown>)) {
|
|
82
|
+
const models = (provider as { models?: unknown } | null)?.models;
|
|
83
|
+
if (!models || typeof models !== "object" || Array.isArray(models)) continue;
|
|
84
|
+
for (const [modelId, model] of Object.entries(models as Record<string, unknown>)) {
|
|
85
|
+
const cost = (model as { cost?: unknown } | null)?.cost as Record<string, unknown> | null | undefined;
|
|
86
|
+
if (!cost || typeof cost.input !== "number" || typeof cost.output !== "number") continue;
|
|
87
|
+
const canonical = (model as { canonical_model_id?: unknown }).canonical_model_id;
|
|
88
|
+
const vendor = (typeof canonical === "string" && canonical.split("/")[0] === providerId) || VENDOR_PROVIDERS.has(providerId);
|
|
89
|
+
const price: TokenPrice = {
|
|
90
|
+
input: cost.input,
|
|
91
|
+
output: cost.output,
|
|
92
|
+
cacheRead: typeof cost.cache_read === "number" ? cost.cache_read : 0,
|
|
93
|
+
cacheWrite: typeof cost.cache_write === "number" ? cost.cache_write : 0,
|
|
94
|
+
};
|
|
95
|
+
const list = candidates.get(modelId) ?? [];
|
|
96
|
+
list.push({ vendor, price });
|
|
97
|
+
candidates.set(modelId, list);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const prices: Record<string, TokenPrice> = Object.fromEntries([...candidates].map(([modelId, list]) => [modelId, (list.find((item) => item.vendor) ?? list[0]).price]));
|
|
101
|
+
if (!Object.keys(prices).length) throw new Error("返回里没有模型价格");
|
|
102
|
+
return prices;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 价格表:一天最多更新一次;拉不到就留着上次的,并把原因写下来给页面显示。
|
|
107
|
+
* `fetchImpl` 和 `now` 只为测试注入(不连真实网络)。
|
|
108
|
+
*/
|
|
109
|
+
export class UsagePrices {
|
|
110
|
+
private file: PriceFile;
|
|
111
|
+
private loading?: Promise<void>;
|
|
112
|
+
|
|
113
|
+
constructor(private readonly options: { home: string; fetchImpl?: typeof fetch; now?: () => number }) {
|
|
114
|
+
this.file = readFile(options.home);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private get now(): number {
|
|
118
|
+
return this.options.now?.() ?? Date.now();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** 需要更新时拉一次(同一时刻只拉一次);要更新时会把结果写进 HOB_HOME。 */
|
|
122
|
+
async ensure(): Promise<void> {
|
|
123
|
+
this.loading ??= this.refresh().finally(() => {
|
|
124
|
+
this.loading = undefined;
|
|
125
|
+
});
|
|
126
|
+
await this.loading;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** 现在用的价格和上次更新的状态。 */
|
|
130
|
+
table(): { fetchedAt: string | null; error: string | null; prices: Record<string, TokenPrice> } {
|
|
131
|
+
return { fetchedAt: this.file.fetchedAt, error: this.file.error, prices: this.file.prices };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private async refresh(): Promise<void> {
|
|
135
|
+
const now = this.now;
|
|
136
|
+
const fetchedAt = this.file.fetchedAt ? Date.parse(this.file.fetchedAt) : 0;
|
|
137
|
+
if (now - fetchedAt < DAY) return;
|
|
138
|
+
const attemptAt = this.file.attemptAt ? Date.parse(this.file.attemptAt) : 0;
|
|
139
|
+
if (attemptAt > fetchedAt && now - attemptAt < RETRY) return;
|
|
140
|
+
try {
|
|
141
|
+
this.file = { fetchedAt: new Date(now).toISOString(), attemptAt: new Date(now).toISOString(), error: null, prices: indexPrices(await this.fetchSource()) };
|
|
142
|
+
} catch (error) {
|
|
143
|
+
this.file = { ...this.file, attemptAt: new Date(now).toISOString(), error: failureReason(error) };
|
|
144
|
+
}
|
|
145
|
+
this.write();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private async fetchSource(): Promise<unknown> {
|
|
149
|
+
const fetchImpl = this.options.fetchImpl ?? globalThis.fetch;
|
|
150
|
+
const response = await fetchImpl(SOURCE, { signal: AbortSignal.timeout(TIMEOUT), headers: { accept: "application/json" } });
|
|
151
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
152
|
+
try {
|
|
153
|
+
return await response.json();
|
|
154
|
+
} catch {
|
|
155
|
+
throw new Error("返回的不是有效 JSON");
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private write(): void {
|
|
160
|
+
const path = join(cacheDir(this.options.home), FILE);
|
|
161
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
162
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
163
|
+
writeFileSync(tmp, `${JSON.stringify(this.file, null, 2)}\n`);
|
|
164
|
+
renameSync(tmp, path);
|
|
165
|
+
}
|
|
166
|
+
}
|