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.
Files changed (48) hide show
  1. package/README.md +42 -23
  2. package/README.zh-CN.md +43 -24
  3. package/bin/hob.mjs +8 -2
  4. package/dist/web/assets/{DesktopView-lixZfbYr.js → DesktopView-3QDYo0UU.js} +1 -1
  5. package/dist/web/assets/index-Dzxrt9T3.js +283 -0
  6. package/dist/web/assets/index-FUtuyxyO.css +1 -0
  7. package/dist/web/index.html +2 -2
  8. package/dist/web/mock/readme-cover.svg +13 -0
  9. package/dist/web/mock/readme-page.html +7 -0
  10. package/package.json +2 -1
  11. package/server/src/actions.ts +2 -1
  12. package/server/src/assistant-skills.ts +2 -2
  13. package/server/src/boot.ts +8 -0
  14. package/server/src/config.ts +52 -11
  15. package/server/src/cron-text.ts +14 -1
  16. package/server/src/delete-skill.ts +19 -0
  17. package/server/src/desktop-tools.ts +6 -6
  18. package/server/src/files.ts +27 -7
  19. package/server/src/http.ts +41 -9
  20. package/server/src/job-view.ts +2 -2
  21. package/server/src/jobs.ts +108 -62
  22. package/server/src/knowledge-tools.ts +1 -1
  23. package/server/src/knowledge.ts +9 -4
  24. package/server/src/layout-cli.ts +25 -0
  25. package/server/src/layout.ts +749 -0
  26. package/server/src/main.ts +5 -0
  27. package/server/src/mcp.ts +3 -2
  28. package/server/src/memory.ts +2 -2
  29. package/server/src/now.ts +1 -1
  30. package/server/src/overview.ts +1 -2
  31. package/server/src/pages.ts +9 -7
  32. package/server/src/prompt.ts +47 -110
  33. package/server/src/push.ts +3 -2
  34. package/server/src/router-tools.ts +96 -40
  35. package/server/src/router.ts +5 -4
  36. package/server/src/routines.ts +177 -22
  37. package/server/src/runtime.ts +12 -1
  38. package/server/src/search.ts +1 -1
  39. package/server/src/secrets.ts +3 -2
  40. package/server/src/settings.ts +44 -6
  41. package/server/src/store.ts +123 -40
  42. package/server/src/transcript.ts +25 -7
  43. package/server/src/usage-prices.ts +166 -0
  44. package/server/src/usage.ts +291 -0
  45. package/server/src/worker-tools.ts +6 -9
  46. package/shared/types.ts +110 -3
  47. package/dist/web/assets/index-BZhge0qe.js +0 -267
  48. package/dist/web/assets/index-DIo1ArRa.css +0 -1
@@ -0,0 +1,291 @@
1
+ // 消耗:不另外记账,直接从会话文件里 assistant 消息的 usage 统计(见 docs/architecture.md“消耗”)。
2
+ import { createReadStream, existsSync, readFileSync, readdirSync, statSync } from "node:fs";
3
+ import { basename, join } from "node:path";
4
+ import type { TokenPrice, UsageAssistant, UsageModelPrice, UsagePrices, UsageRow, UsageSource, UsageView } from "../../shared/types.ts";
5
+ import type { SettingsStore } from "./settings.ts";
6
+ import type { Store } from "./store.ts";
7
+ import type { UsagePrices as PriceTable } from "./usage-prices.ts";
8
+
9
+ /** 一条 assistant 消息的用量;日志按毫秒记,归到哪一天按设置的时区算。 */
10
+ interface UsageRecord {
11
+ at: number;
12
+ provider: string;
13
+ model: string;
14
+ input: number;
15
+ output: number;
16
+ cacheRead: number;
17
+ cacheWrite: number;
18
+ }
19
+
20
+ /** 一个会话文件:大小和修改时间没变就不重读,变长时只读追加的那一段。 */
21
+ interface CachedFile {
22
+ size: number;
23
+ mtimeMs: number;
24
+ offset: number; // 已读到的字节位置(只到最后一个完整换行,半行留着下次读)
25
+ records: UsageRecord[];
26
+ assistantId: string;
27
+ assistantName: string;
28
+ source: UsageSource;
29
+ }
30
+
31
+ interface Target {
32
+ file: string;
33
+ assistantId: string;
34
+ assistantName: string;
35
+ source: UsageSource;
36
+ }
37
+
38
+ function num(value: unknown): number {
39
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
40
+ }
41
+
42
+ /** 一行会话记录 → 用量;不是带用量字段的 assistant 消息就返回 undefined。 */
43
+ function recordOf(line: string): UsageRecord | undefined {
44
+ let entry: { type?: unknown; timestamp?: unknown; message?: any };
45
+ try {
46
+ entry = JSON.parse(line) as typeof entry;
47
+ } catch {
48
+ return undefined; // 半行(进程被杀)跳过
49
+ }
50
+ if (entry?.type !== "message") return undefined;
51
+ const message = entry.message;
52
+ if (message?.role !== "assistant") return undefined;
53
+ const provider = typeof message.provider === "string" ? message.provider : "";
54
+ const model = typeof message.model === "string" ? message.model : "";
55
+ if (!provider || !model) return undefined;
56
+ const at = typeof message.timestamp === "number" ? message.timestamp : Date.parse(String(message.timestamp ?? entry.timestamp ?? ""));
57
+ if (!Number.isFinite(at)) return undefined;
58
+ const usage = message.usage ?? {};
59
+ return {
60
+ at,
61
+ provider,
62
+ model,
63
+ input: num(usage.input),
64
+ output: num(usage.output),
65
+ cacheRead: num(usage.cacheRead),
66
+ cacheWrite: num(usage.cacheWrite),
67
+ };
68
+ }
69
+
70
+ /** 从 start 字节起读完整的行,返回下一次从这里接着读的字节位置和用量。 */
71
+ async function readUsage(file: string, start: number): Promise<{ offset: number; records: UsageRecord[] }> {
72
+ const records: UsageRecord[] = [];
73
+ let pending: Buffer = Buffer.alloc(0);
74
+ let read = start;
75
+ for await (const chunk of createReadStream(file, { start })) {
76
+ const buffer = chunk as Buffer;
77
+ read += buffer.length;
78
+ pending = pending.length ? Buffer.concat([pending, buffer]) : buffer;
79
+ let from = 0;
80
+ for (let at = pending.indexOf(0x0a); at !== -1; at = pending.indexOf(0x0a, from)) {
81
+ const line = pending.toString("utf8", from, at);
82
+ from = at + 1;
83
+ const record = recordOf(line);
84
+ if (record) records.push(record);
85
+ }
86
+ pending = pending.subarray(from);
87
+ }
88
+ return { offset: read - pending.length, records };
89
+ }
90
+
91
+ /** 时间戳 → 设置时区下的日期(同一小时里的本地日期一样,按时段记一下省掉重复的时区换算)。 */
92
+ function dayKeyer(timezone: string): (at: number) => string {
93
+ const format = new Intl.DateTimeFormat("sv-SE", { timeZone: timezone, year: "numeric", month: "2-digit", day: "2-digit" });
94
+ const memo = new Map<number, string>();
95
+ return (at) => {
96
+ const hour = Math.floor(at / 3_600_000);
97
+ const hit = memo.get(hour);
98
+ if (hit) return hit;
99
+ const day = format.format(new Date(at));
100
+ memo.set(hour, day);
101
+ return day;
102
+ };
103
+ }
104
+
105
+ function readDir(path: string): string[] {
106
+ return existsSync(path) ? readdirSync(path) : [];
107
+ }
108
+
109
+ /** 所有任务的会话目录:在的助手和归档的助手都算。 */
110
+ function jobSessionDirs(home: string): string[] {
111
+ const out: string[] = [];
112
+ const roots = [
113
+ ...readDir(join(home, "assistants")).map((id) => join(home, "assistants", id, "jobs")),
114
+ ...readDir(join(home, "archive", "assistants")).map((folder) => join(home, "archive", "assistants", folder, "jobs")),
115
+ ];
116
+ for (const root of roots) for (const id of readDir(root)) out.push(join(root, id));
117
+ return out;
118
+ }
119
+
120
+ function readJson(path: string): Record<string, unknown> | undefined {
121
+ try {
122
+ return JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
123
+ } catch {
124
+ return undefined;
125
+ }
126
+ }
127
+
128
+ function modelPrice(id: string, table: Record<string, TokenPrice>, overrides: Record<string, TokenPrice>): UsageModelPrice | null {
129
+ const custom = Object.hasOwn(overrides, id) ? overrides[id] : undefined;
130
+ const base = Object.hasOwn(table, id) ? table[id] : undefined;
131
+ if (custom && base) return { price: custom, custom: true, default: base };
132
+ if (custom) return { price: custom, custom: true };
133
+ return base ? { price: base, custom: false } : null;
134
+ }
135
+
136
+ /**
137
+ * 消耗统计:扫出全部会话文件(助手对话、归档助手的对话、任务的会话),按文件缓存解析结果。
138
+ * 第一次扫描是异步流式读,不阻塞其他请求。
139
+ */
140
+ export class UsageService {
141
+ private files = new Map<string, CachedFile>();
142
+ private assistants = new Map<string, UsageAssistant>();
143
+ private scanning?: Promise<void>;
144
+ /** 读过几次会话文件(文件没变时不该再读,测试用它看这条) */
145
+ reads = 0;
146
+
147
+ constructor(
148
+ private readonly options: {
149
+ home: string;
150
+ store: Store;
151
+ settings: SettingsStore;
152
+ prices: PriceTable;
153
+ },
154
+ ) {}
155
+
156
+ /** 启动后先扫一遍;服务不等它。 */
157
+ warmup(): void {
158
+ void this.scan().catch(() => {});
159
+ }
160
+
161
+ async view(): Promise<UsageView> {
162
+ await this.scan();
163
+ const timezone = this.options.settings.settings.general.timezone;
164
+ const dayOf = dayKeyer(timezone);
165
+ const rows = new Map<string, UsageRow>();
166
+ for (const file of this.files.values()) {
167
+ for (const record of file.records) {
168
+ const day = dayOf(record.at);
169
+ const key = [day, file.assistantId, file.source, record.provider, record.model].join("\u0000");
170
+ let row = rows.get(key);
171
+ if (!row) {
172
+ row = { day, assistantId: file.assistantId, source: file.source, provider: record.provider, model: record.model, requests: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
173
+ rows.set(key, row);
174
+ }
175
+ row.requests += 1;
176
+ row.input += record.input;
177
+ row.output += record.output;
178
+ row.cacheRead += record.cacheRead;
179
+ row.cacheWrite += record.cacheWrite;
180
+ }
181
+ }
182
+ return {
183
+ timezone,
184
+ rows: [...rows.values()].sort((a, b) => a.day.localeCompare(b.day) || a.assistantId.localeCompare(b.assistantId) || a.source.localeCompare(b.source) || a.model.localeCompare(b.model)),
185
+ assistants: [...this.assistants.values()],
186
+ prices: await this.prices(),
187
+ };
188
+ }
189
+
190
+ /** 一个模型现在的单价(`PUT /api/usage/price` 的返回用)。 */
191
+ async priceOf(model: string): Promise<UsageModelPrice | null> {
192
+ await this.options.prices.ensure();
193
+ return modelPrice(model, this.options.prices.table().prices, this.options.settings.settings.usage?.prices ?? {});
194
+ }
195
+
196
+ private async priceTable(): Promise<Record<string, UsageModelPrice>> {
197
+ const table = this.options.prices.table().prices;
198
+ const overrides = this.options.settings.settings.usage?.prices ?? {};
199
+ const models: Record<string, UsageModelPrice> = {};
200
+ for (const id of new Set([...Object.keys(table), ...Object.keys(overrides)])) {
201
+ const price = modelPrice(id, table, overrides);
202
+ if (price) models[id] = price;
203
+ }
204
+ return models;
205
+ }
206
+
207
+ private async prices(): Promise<UsagePrices> {
208
+ await this.options.prices.ensure();
209
+ const { fetchedAt, error } = this.options.prices.table();
210
+ return { updatedAt: fetchedAt, error, models: await this.priceTable() };
211
+ }
212
+
213
+ private async scan(): Promise<void> {
214
+ this.scanning ??= this.doScan().finally(() => {
215
+ this.scanning = undefined;
216
+ });
217
+ await this.scanning;
218
+ }
219
+
220
+ private async doScan(): Promise<void> {
221
+ const home = this.options.home;
222
+ const seen = new Set<string>();
223
+ const assistants = new Map<string, UsageAssistant>();
224
+ for (const id of readDir(join(home, "assistants"))) {
225
+ const file = join(home, "assistants", id, "router.jsonl");
226
+ if (!existsSync(file)) continue;
227
+ const name = this.options.store.assistant(id)?.name ?? id;
228
+ assistants.set(id, { id, name, archived: false });
229
+ await this.sync({ file, assistantId: id, assistantName: name, source: "chat" }, seen);
230
+ }
231
+ const archiveRoot = join(home, "archive", "assistants");
232
+ for (const folder of readDir(archiveRoot)) {
233
+ const file = join(archiveRoot, folder, "router.jsonl");
234
+ if (!existsSync(file)) continue;
235
+ // 归档目录名是 <助手 id>-<归档时间>,id 从归档里的档案读
236
+ const profile = readJson(join(archiveRoot, folder, "profile.json"));
237
+ const id = typeof profile?.id === "string" && profile.id ? profile.id : folder;
238
+ const name = typeof profile?.name === "string" && profile.name ? profile.name : id;
239
+ if (!assistants.has(id)) assistants.set(id, { id, name, archived: true });
240
+ await this.sync({ file, assistantId: id, assistantName: name, source: "chat" }, seen);
241
+ }
242
+ for (const jobDir of jobSessionDirs(home)) {
243
+ const file = join(jobDir, "worker.jsonl");
244
+ if (!existsSync(file)) continue;
245
+ const cached = this.files.get(file);
246
+ const job = cached ? undefined : this.options.store.job(basename(jobDir));
247
+ const assistantId = cached?.assistantId ?? job?.assistantId;
248
+ if (!assistantId) continue;
249
+ const live = this.options.store.assistant(assistantId);
250
+ const name = cached?.assistantName ?? job?.assistantName ?? live?.name ?? assistantId;
251
+ if (!assistants.has(assistantId)) assistants.set(assistantId, { id: assistantId, name, archived: !live });
252
+ await this.sync({ file, assistantId, assistantName: name, source: "job" }, seen);
253
+ }
254
+ for (const file of [...this.files.keys()]) if (!seen.has(file)) this.files.delete(file);
255
+ this.assistants = assistants;
256
+ }
257
+
258
+ private async sync(target: Target, seen: Set<string>): Promise<void> {
259
+ seen.add(target.file);
260
+ let stat;
261
+ try {
262
+ stat = statSync(target.file);
263
+ } catch {
264
+ this.files.delete(target.file);
265
+ return;
266
+ }
267
+ const cached = this.files.get(target.file);
268
+ const usable = cached && cached.assistantId === target.assistantId && cached.source === target.source;
269
+ if (usable && cached.size === stat.size && cached.mtimeMs === stat.mtimeMs) return;
270
+ // 会话文件只往后写:变长时接着上次的位置读,否则整份重读
271
+ const appendFrom = usable && stat.size > cached.size ? cached.offset : 0;
272
+ let parsed;
273
+ try {
274
+ parsed = await readUsage(target.file, appendFrom);
275
+ } catch {
276
+ return; // 读不了就留着上次的记录,不当成 0
277
+ }
278
+ this.reads += 1;
279
+ let records = parsed.records;
280
+ if (appendFrom && cached) records = [...cached.records, ...parsed.records];
281
+ this.files.set(target.file, {
282
+ size: stat.size,
283
+ mtimeMs: stat.mtimeMs,
284
+ offset: parsed.offset,
285
+ records,
286
+ assistantId: target.assistantId,
287
+ assistantName: target.assistantName,
288
+ source: target.source,
289
+ });
290
+ }
291
+ }
@@ -13,14 +13,13 @@ export function registerReportTool(runtime: HobRuntime, jobId: string): ToolDefi
13
13
  return {
14
14
  name: "report",
15
15
  label: "提交结果",
16
- description: "任务结束时调用一次,提交结果。",
17
- promptSnippet: "任务结束必须调用 report 提交结果。",
16
+ description: "交结果。做完了、做不下去了,或者要用户拍板时调用。",
18
17
  parameters: Type.Object({
19
18
  outcome: Type.Unsafe<"done" | "stuck" | "need_approval">(
20
- Type.String({ description: "done 做完 / stuck 做不下去(写明缺什么)/ need_approval 需要用户拍板" }),
19
+ Type.String({ description: "done 要的结果拿到了;stuck 要的结果没拿到(做不下去或没做完),写明做到哪、缺什么;need_approval 要用户拍板才能往下做,写明要定什么。" }),
21
20
  ),
22
21
  result: Type.String({ description: "完整结果,按这个顺序写:结论;做了什么;验证了什么、怎么验证的;没验证或不确定的;需要用户决定的。带上数据、链接和文件路径,写给派活的助手。" }),
23
- files: Type.Optional(Type.Array(Type.String(), { description: "交给助手的文件(绝对路径),比如要给用户看的图" })),
22
+ files: Type.Optional(Type.Array(Type.String(), { description: "交给助手的文件(绝对路径),比如要给用户看的图。" })),
24
23
  }),
25
24
  execute: async (_id, params: any) => {
26
25
  // 不认识的结果不能当成已完成:退回让模型重新调用
@@ -46,11 +45,10 @@ export function registerAskTool(runtime: HobRuntime, jobId: string): ToolDefinit
46
45
  return {
47
46
  name: "ask_user",
48
47
  label: "询问用户",
49
- description: "问你的助手,等它回复:它会自己答,或者去问用户。用于用户定的规则要求先确认的事,或发送、删除、付费这类撤不回的事。其他拿不准的不问,按合理的假设做下去,把假设写进 result。",
50
- promptSnippet: "规则要求执行前确认的事,执行前必须调用 ask_user。",
48
+ description: "问你的助手,等它回复。它能答的会直接答,要用户定的会去问用户。",
51
49
  parameters: Type.Object({
52
50
  title: Type.String({ description: "问题,一句话" }),
53
- detail: Type.Optional(Type.String({ description: "补充说明,写清后果" })),
51
+ detail: Type.Optional(Type.String({ description: "补充说明,写清后果。" })),
54
52
  options: Type.Array(
55
53
  Type.Object({
56
54
  label: Type.String({ description: "选项文字" }),
@@ -84,8 +82,7 @@ export function registerImageTool(runtime: HobRuntime, jobId: string): ToolDefin
84
82
  name: "generate_image",
85
83
  label: "生成图片",
86
84
  description:
87
- "按文字描述生成一张图片,返回图片的绝对路径(存在任务工作目录)。要把图给用户看,就把这个路径放进 report 的 files。",
88
- promptSnippet: "生成图片用 generate_image;要给用户看就把它放进 report 的 files。",
85
+ "按文字描述生成一张图片,返回图片的绝对路径(存在任务工作目录)。要给用户看,就把路径放进 report 的 files。",
89
86
  parameters: Type.Object({
90
87
  prompt: Type.String({ description: "要画什么,越具体越好" }),
91
88
  size: Type.Optional(
package/shared/types.ts CHANGED
@@ -225,9 +225,11 @@ export interface Assistant {
225
225
  prop?: CustomProp;
226
226
  primary: boolean; // 只有 hob 为 true
227
227
  pinned: boolean; // 左栏顶部大头像
228
+ /** 置顶区里的位置:小的在前,拖动头像换位置时改它;没排过的排在排过的后面,再按创建时间 */
229
+ pinOrder?: number;
228
230
  hidden: boolean; // 收起的助手
229
231
  notify: boolean; // 需要确认时通知我
230
- instructions: string; // 补充说明:用户可直接编辑的自定义指令,这个助手特有的做法写这里
232
+ instructions: string; // 补充说明:用户可直接编辑该助手需要知道的专属事实
231
233
  /** 这个助手用哪些模型;缺省全部跟随设置 → 模型 */
232
234
  models?: {
233
235
  conversation?: ModelPick; // 不填 = 跟随全局的对话模型
@@ -275,16 +277,40 @@ export interface RoutineRun {
275
277
  tokens?: number; // 技能二期:输入加输出 token
276
278
  }
277
279
 
280
+ export interface RoutineCheck {
281
+ script: string; // 到点先跑的 bash 脚本:没有输出就跳过这一次
282
+ then: "notify" | "job"; // 有输出时:notify 把输出作为提醒发到对话;job 带着输出开始任务
283
+ }
284
+
285
+ export interface RoutineChecks {
286
+ total: number; // 检查了几次
287
+ hits: number; // 触发了几次
288
+ failing?: string; // 最近一次失败的原因,下次成功就清掉
289
+ lastAt?: Iso; // 上次检查的时间
290
+ }
291
+
292
+ /** 定时到点(或点「立即运行」)这一次的结果。 */
293
+ export interface RoutineTick {
294
+ outcome: "job" | "notify" | "quiet" | "failed"; // job 开了任务;notify 已发到对话;quiet 没变化;failed 检查失败
295
+ job?: Job;
296
+ reason?: string; // failed 时的原因
297
+ }
298
+
278
299
  export interface Routine {
279
300
  id: string;
280
301
  assistantId: string;
281
302
  name: string; // 如 "每日简报"
282
303
  instruction: string; // 要做什么
283
- schedules: string[]; // cron 表达式,如 "0 9 * * *";可多个("再加一个")
304
+ schedules: string[]; // cron 表达式(如 "0 9 * * *")或具体时间(如 "2026-11-08T09:00");可多个("再加一个")
284
305
  active: boolean;
285
306
  createdAt: Iso;
286
307
  runs: RoutineRun[]; // 最近 20 次,新的在前
287
308
  skill?: string; // 绑定这个助手自己的技能(AssistantSkill.name):每次执行按它做
309
+ check?: RoutineCheck; // 到点先跑脚本,多数时候没变化就不开任务
310
+ checks?: RoutineChecks; // 检查计数和最近一次失败的原因
311
+ once?: boolean; // 做成一次就停:没有检查脚本的跑完就停,有检查的第一次有输出就停
312
+ stoppedAt?: Iso; // 上次停下的时间
313
+ stopReason?: "hit" | "finished"; // hit=按 once 停了;finished=所有执行时间都跑完了
288
314
  }
289
315
 
290
316
  export interface Delegation {
@@ -368,6 +394,12 @@ export interface JobsPage {
368
394
  hasMore: boolean;
369
395
  }
370
396
 
397
+ export type SedimentStatus =
398
+ | { status: "running" }
399
+ | { status: "saved"; name: string; title: string; version: number; updated: boolean }
400
+ | { status: "empty"; reason: string }
401
+ | { status: "failed"; reason: string };
402
+
371
403
  export interface Job {
372
404
  id: string; // "t-" + 4 位以上
373
405
  assistantId: string;
@@ -392,6 +424,8 @@ export interface Job {
392
424
  modelReason?: string; // 换了模型的原因,如「需要看图,换成了 X」
393
425
  inputs?: string[]; // 交给任务的附件(绝对路径)
394
426
  routineId?: string; // 由定时触发
427
+ sediment?: SedimentStatus; // 在原任务中沉淀技能,不改变任务状态
428
+ skills?: string[]; // 派活时交给任务的本助手技能
395
429
  skillsUsed?: string[]; // 这次读过的本助手技能(AssistantSkill.name)
396
430
  delegationId?: string; // 转交回合创建的任务
397
431
  /** 续做前每一轮结束时的样子(第 0 轮起)。对话里前面几张任务卡照它显示,最后一张跟着任务实时变 */
@@ -549,6 +583,78 @@ export interface PushSettings {
549
583
  email: string;
550
584
  }
551
585
 
586
+ /** 每百万 token 的单价(美元),四项。 */
587
+ export interface TokenPrice {
588
+ input: number;
589
+ output: number;
590
+ cacheRead: number;
591
+ cacheWrite: number;
592
+ }
593
+
594
+ /** 设置 → 消耗:按模型改过的单价,键是模型 id(服务商 id 是用户自己起的,价格按模型 id 对)。 */
595
+ export interface UsageSettings {
596
+ prices: Record<string, TokenPrice>;
597
+ }
598
+
599
+ /** 用量的来源:助手自己的对话 / 任务的会话。 */
600
+ export type UsageSource = "chat" | "job";
601
+
602
+ /** GET /api/usage 的一行:按天 × 助手 × 来源 × 服务商 × 模型汇总;day 是设置时区下的日期。 */
603
+ export interface UsageRow {
604
+ day: string; // 2026-11-06
605
+ assistantId: string;
606
+ source: UsageSource;
607
+ provider: string;
608
+ model: string;
609
+ requests: number; // assistant 消息条数
610
+ input: number; // 没命中缓存的输入 token
611
+ output: number;
612
+ cacheRead: number;
613
+ cacheWrite: number;
614
+ }
615
+
616
+ /** 有会话文件的助手(归档的也算),「按助手」一节用它的名字。 */
617
+ export interface UsageAssistant {
618
+ id: string;
619
+ name: string;
620
+ archived: boolean;
621
+ }
622
+
623
+ /** 一个模型现在的单价:改过的是自定,default 是价格表里的默认值(恢复默认和对比用)。 */
624
+ export interface UsageModelPrice {
625
+ price: TokenPrice;
626
+ custom: boolean;
627
+ default?: TokenPrice;
628
+ }
629
+
630
+ /** 价格表:默认单价从 models.dev 拉,一天最多更新一次。 */
631
+ export interface UsagePrices {
632
+ updatedAt: Iso | null; // 价格表拉到的时间;从没拉到过为 null
633
+ error: string | null; // 这次更新价格表失败的原因;成功为 null
634
+ models: Record<string, UsageModelPrice>; // 键是模型 id
635
+ }
636
+
637
+ /** GET /api/usage:按天、助手、来源、服务商、模型分组的明细 + 价格;前端自己汇总。 */
638
+ export interface UsageView {
639
+ timezone: string;
640
+ rows: UsageRow[];
641
+ assistants: UsageAssistant[];
642
+ prices: UsagePrices;
643
+ }
644
+
645
+ /** PUT /api/usage/price:改一个模型的单价,price 为 null 就恢复默认。 */
646
+ export interface UsagePricePatch {
647
+ revision: number;
648
+ model: string;
649
+ price: TokenPrice | null;
650
+ }
651
+
652
+ /** PUT /api/usage/price:新 revision 的整份设置 + 这个模型现在的单价(既没价格表也没自定时为 null)。 */
653
+ export interface UsagePriceSaved {
654
+ settings: SettingsFile;
655
+ price: UsageModelPrice | null;
656
+ }
657
+
552
658
  export interface Settings {
553
659
  general: GeneralSettings;
554
660
  /** 服务商(Key 在 secrets.json,这里只放不敏感的) */
@@ -565,6 +671,8 @@ export interface Settings {
565
671
  push: PushSettings;
566
672
  /** 知识库二期:向量模型(Key 在 secrets.json 的 embedding);缺省 = 关闭,只按关键词查 */
567
673
  knowledge?: { embedding: EmbeddingSettings };
674
+ /** 消耗:按模型改过的单价;没改过的模型用 models.dev 的价格 */
675
+ usage?: UsageSettings;
568
676
  }
569
677
 
570
678
  /** 设置 → 知识库:向量模型,照图像生成的做法(兼容接口的地址 + 模型,Key 单独存)。 */
@@ -698,7 +806,6 @@ export type HobEvent =
698
806
  | { type: "item"; assistantId: string; item: Item } // 新条目,或按 id 替换
699
807
  | { type: "delta"; assistantId: string; itemId: string; text: string } // 流式追加到 assistant 条目
700
808
  | { type: "job"; job: Job }
701
- | { type: "sediment-finished"; jobId: string }
702
809
  | { type: "delegation"; delegation: Delegation }
703
810
  | { type: "job_step"; jobId: string; step: JobStep } // 新的一步,或按 id 替换(输出流式更新、跑完补结果)
704
811
  | { type: "router-activity"; assistantId: string; activity: RouterActivity | null }