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
|
@@ -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
|
|
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: "
|
|
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
|
-
"
|
|
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
|
|
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 }
|