hob-bot 1.2.0 → 1.4.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 +32 -24
- package/README.zh-CN.md +33 -25
- package/dist/web/assets/DesktopView-A11jmUwu.js +5 -0
- package/dist/web/assets/index-B-YlVbpo.js +270 -0
- package/dist/web/assets/index-DkSQ-ygR.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 +1 -1
- package/server/src/assistant-skills.ts +2 -2
- package/server/src/delete-skill.ts +19 -0
- package/server/src/desktop-tools.ts +48 -14
- package/server/src/http.ts +56 -6
- package/server/src/job-view.ts +2 -2
- package/server/src/jobs.ts +120 -108
- package/server/src/knowledge-tools.ts +1 -1
- package/server/src/main.ts +2 -0
- package/server/src/memory.ts +2 -2
- package/server/src/now.ts +2 -2
- package/server/src/overview.ts +2 -3
- package/server/src/pages.ts +7 -6
- package/server/src/prompt.ts +57 -58
- package/server/src/router-activity.ts +7 -0
- package/server/src/router-tools.ts +65 -36
- package/server/src/router.ts +92 -40
- package/server/src/routines.ts +8 -3
- package/server/src/runtime.ts +12 -1
- package/server/src/settings.ts +37 -0
- package/server/src/store.ts +20 -0
- package/server/src/transcript.ts +14 -4
- package/server/src/usage-prices.ts +165 -0
- package/server/src/usage.ts +281 -0
- package/server/src/worker-tools.ts +7 -17
- package/shared/skills.ts +3 -1
- package/shared/types.ts +85 -2
- package/dist/web/assets/DesktopView-CEBJFAIN.js +0 -5
- package/dist/web/assets/index-Di5lyTHO.css +0 -1
- package/dist/web/assets/index-y09i1RGd.js +0 -267
package/server/src/router.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { getSupportedThinkingLevels } from "@earendil-works/pi-ai";
|
|
2
2
|
import { parseStreamingJson } from "@earendil-works/pi-ai/utils/json-parse";
|
|
3
|
-
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { mkdirSync } from "node:fs";
|
|
5
5
|
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
6
6
|
import type { AgentSession, AgentSessionEvent, SessionEntry, SessionManager } from "@earendil-works/pi-coding-agent";
|
|
@@ -30,6 +30,7 @@ import { activityLabel, thinkingLine } from "./router-activity.ts";
|
|
|
30
30
|
import { imageContents } from "./jobs.ts";
|
|
31
31
|
import { nowBlock } from "./now.ts";
|
|
32
32
|
import { monthDayTimeText } from "./time.ts";
|
|
33
|
+
import { DesktopToolSession } from "./desktop-tools.ts";
|
|
33
34
|
|
|
34
35
|
export const ITEM_TYPE = "hob.item";
|
|
35
36
|
export const ITEM_UPDATE_TYPE = "hob.item.update";
|
|
@@ -65,7 +66,7 @@ interface RouterState {
|
|
|
65
66
|
/** 这一轮最后一次有动静的时间,看门狗用它判断“没响应” */
|
|
66
67
|
lastEventAt: number;
|
|
67
68
|
stallTimer?: NodeJS.Timeout;
|
|
68
|
-
/**
|
|
69
|
+
/** 这一轮已经被叫停(没响应,或用户按了停止):之后的报错不再写「回复失败」 */
|
|
69
70
|
stalled: boolean;
|
|
70
71
|
/** 记忆提供者换了:跑着的这一轮结束就释放会话,下一轮按新提供者重新打开 */
|
|
71
72
|
releaseAfterTurn?: boolean;
|
|
@@ -78,6 +79,7 @@ interface RouterState {
|
|
|
78
79
|
knowledgeNames?: string[];
|
|
79
80
|
delegationReply?: string[];
|
|
80
81
|
terminatingTools?: Set<string>;
|
|
82
|
+
desktop?: DesktopToolSession;
|
|
81
83
|
}
|
|
82
84
|
|
|
83
85
|
function readEntries(file: string): SessionEntry[] {
|
|
@@ -186,6 +188,7 @@ export function deriveItems(entries: SessionEntry[]): Item[] {
|
|
|
186
188
|
|
|
187
189
|
export class RouterManager {
|
|
188
190
|
private routers = new Map<string, RouterState>();
|
|
191
|
+
private itemCache = new Map<string, { manager?: SessionManager; version: string; result: ReturnType<typeof walk>; positions: Map<string, number>; assistantTimes: string[]; last?: { text: string; at: Iso } }>();
|
|
189
192
|
private livePages = new Map<string, Map<string, { page: PageLive; timer?: NodeJS.Timeout; last: number }>>();
|
|
190
193
|
private liveJson = new Map<string, string>();
|
|
191
194
|
|
|
@@ -250,6 +253,37 @@ export class RouterManager {
|
|
|
250
253
|
return readEntries(this.store.routerFile(assistantId));
|
|
251
254
|
}
|
|
252
255
|
|
|
256
|
+
private derived(assistantId: string) {
|
|
257
|
+
const manager = this.routers.get(assistantId)?.manager;
|
|
258
|
+
const file = this.store.routerFile(assistantId);
|
|
259
|
+
let version: string;
|
|
260
|
+
if (manager) version = String(manager.getEntryCount());
|
|
261
|
+
else {
|
|
262
|
+
try {
|
|
263
|
+
const stat = statSync(file);
|
|
264
|
+
version = `${stat.size}:${stat.mtimeMs}`;
|
|
265
|
+
} catch (error) {
|
|
266
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
267
|
+
version = "missing";
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
const cached = this.itemCache.get(assistantId);
|
|
271
|
+
if (cached && cached.manager === manager && cached.version === version) return cached;
|
|
272
|
+
const result = walk(manager ? manager.getEntries() : readEntries(file));
|
|
273
|
+
const positions = new Map<string, number>();
|
|
274
|
+
const assistantTimes: string[] = [];
|
|
275
|
+
let last: { text: string; at: Iso } | undefined;
|
|
276
|
+
result.items.forEach((item, index) => {
|
|
277
|
+
positions.set(item.id, index);
|
|
278
|
+
if (item.kind === "assistant") assistantTimes.push(item.at);
|
|
279
|
+
if (item.kind === "assistant" || item.kind === "user" || item.kind === "note") last = { text: item.text, at: item.at };
|
|
280
|
+
});
|
|
281
|
+
assistantTimes.sort();
|
|
282
|
+
const next = { manager, version, result, positions, assistantTimes, last };
|
|
283
|
+
this.itemCache.set(assistantId, next);
|
|
284
|
+
return next;
|
|
285
|
+
}
|
|
286
|
+
|
|
253
287
|
async ensure(assistantId: string): Promise<RouterState> {
|
|
254
288
|
const assistant = this.assistant(assistantId);
|
|
255
289
|
let state = this.routers.get(assistantId);
|
|
@@ -271,6 +305,10 @@ export class RouterManager {
|
|
|
271
305
|
await this.runtime.models.get();
|
|
272
306
|
const pick = conversationPick(this.runtime.settings.settings.models, assistant);
|
|
273
307
|
state.knowledgeNames = (await boundKnowledge(this.runtime, assistant.id)).map((collection) => collection.name);
|
|
308
|
+
state.desktop = new DesktopToolSession(this.runtime, assistant.id, () => !!state.session?.model?.input.includes("image"), undefined, true);
|
|
309
|
+
const tools = [...buildRouterTools(this.runtime, assistant.id), ...state.desktop.tools().filter((tool) => tool.name.startsWith("browser_"))];
|
|
310
|
+
const desktop = this.runtime.desktop.view();
|
|
311
|
+
const enabled = desktop.enabled && !!desktop.url && desktop.tokenConfigured;
|
|
274
312
|
const opened = await this.runtime.pi.open({
|
|
275
313
|
file: this.store.routerFile(assistant.id),
|
|
276
314
|
spillDir: join(dirname(this.store.routerFile(assistant.id)), "spill"),
|
|
@@ -280,10 +318,16 @@ export class RouterManager {
|
|
|
280
318
|
memory: this.runtime.settings.memory().provider,
|
|
281
319
|
systemPrompt: () => {
|
|
282
320
|
const { timezone, aboutYou } = this.runtime.settings.settings.general;
|
|
321
|
+
const desktop = this.runtime.desktop.view();
|
|
283
322
|
return buildSystemPrompt(this.store, assistant.id, {
|
|
284
323
|
timezone,
|
|
285
324
|
aboutYou,
|
|
286
325
|
memory: this.runtime.settings.memory().provider,
|
|
326
|
+
searchEnabled: this.runtime.settings.settings.search.some((provider) => provider.enabled),
|
|
327
|
+
imageEnabled: tools.some((tool) => tool.name === "generate_image"),
|
|
328
|
+
showPageEnabled: tools.some((tool) => tool.name === "show_page"),
|
|
329
|
+
desktopEnabled: desktop.enabled && !!desktop.url && desktop.tokenConfigured,
|
|
330
|
+
publicDesktop: desktop.publicAssistants.includes(assistant.id),
|
|
287
331
|
models: this.promptModels(assistant.id),
|
|
288
332
|
skills: availableSkills(this.runtime.home, undefined, this.runtime.settings.settings.skills?.disabled, this.runtime.settings.memory().provider === "lore" ? this.runtime.pi.loreSkillPaths : []),
|
|
289
333
|
assistantSkills: usableAssistantSkills(this.runtime.home, assistant.id),
|
|
@@ -292,8 +336,8 @@ export class RouterManager {
|
|
|
292
336
|
knowledgeNames: state.knowledgeNames,
|
|
293
337
|
});
|
|
294
338
|
},
|
|
295
|
-
tools:
|
|
296
|
-
customTools:
|
|
339
|
+
tools: tools.filter((tool) => enabled || !tool.name.startsWith("browser_")).map((tool) => tool.name),
|
|
340
|
+
customTools: tools,
|
|
297
341
|
});
|
|
298
342
|
state.session = opened.session;
|
|
299
343
|
if (!state.knowledgeNames.length) refreshSystemPrompt(opened.session, opened.session.getActiveToolNames().filter((name) => name !== "search_knowledge"));
|
|
@@ -305,8 +349,10 @@ export class RouterManager {
|
|
|
305
349
|
|
|
306
350
|
private async refreshTools(state: RouterState): Promise<void> {
|
|
307
351
|
state.knowledgeNames = (await boundKnowledge(this.runtime, state.assistantId)).map((collection) => collection.name);
|
|
308
|
-
const names = state.session!.getActiveToolNames().filter((name) => name !== "search_knowledge");
|
|
352
|
+
const names = state.session!.getActiveToolNames().filter((name) => name !== "search_knowledge" && !name.startsWith("browser_"));
|
|
309
353
|
if (state.knowledgeNames.length) names.push("search_knowledge");
|
|
354
|
+
const desktop = this.runtime.desktop.view();
|
|
355
|
+
if (desktop.enabled && desktop.url && desktop.tokenConfigured) names.push(...state.desktop!.tools().filter((tool) => tool.name.startsWith("browser_")).map((tool) => tool.name));
|
|
310
356
|
refreshSystemPrompt(state.session!, names);
|
|
311
357
|
}
|
|
312
358
|
|
|
@@ -392,7 +438,7 @@ export class RouterManager {
|
|
|
392
438
|
}
|
|
393
439
|
if (event.type === "message_start" && event.message.role === "custom" && event.message.customType === EVENT_TYPE) {
|
|
394
440
|
const details = event.message.details as EventDetails | undefined;
|
|
395
|
-
const users =
|
|
441
|
+
const users = this.derived(assistantId).result.items.filter((item) => item.kind === "user");
|
|
396
442
|
const source = details?.sourceUserId ? users.find((item) => item.id === details.sourceUserId) : undefined;
|
|
397
443
|
state.currentUserId = (source ?? users.at(-1))?.id;
|
|
398
444
|
if (details?.delegationId && details.delegationId !== state.currentDelegationId) {
|
|
@@ -471,7 +517,7 @@ export class RouterManager {
|
|
|
471
517
|
at: new Date().toISOString(),
|
|
472
518
|
text: "",
|
|
473
519
|
streaming: true,
|
|
474
|
-
...(
|
|
520
|
+
...(this.derived(assistantId).result.lead ? { lead: true } : {}),
|
|
475
521
|
},
|
|
476
522
|
});
|
|
477
523
|
}
|
|
@@ -502,7 +548,7 @@ export class RouterManager {
|
|
|
502
548
|
}
|
|
503
549
|
|
|
504
550
|
private emitSettledAssistant(assistantId: string, text: string, expectedId?: string): void {
|
|
505
|
-
const list =
|
|
551
|
+
const list = this.derived(assistantId).result.items.filter((item) => item.kind === "assistant");
|
|
506
552
|
const item = list.find((entry) => entry.id === expectedId) ?? list.filter((entry) => entry.text === text).pop();
|
|
507
553
|
if (item) this.bus.emit({ type: "item", assistantId, item });
|
|
508
554
|
this.touch(assistantId);
|
|
@@ -580,8 +626,11 @@ export class RouterManager {
|
|
|
580
626
|
if (state.idleTimer) clearTimeout(state.idleTimer);
|
|
581
627
|
state.idleTimer = undefined;
|
|
582
628
|
state.session?.dispose();
|
|
629
|
+
void state.desktop?.dispose();
|
|
630
|
+
state.desktop = undefined;
|
|
583
631
|
state.session = undefined;
|
|
584
632
|
state.manager = undefined;
|
|
633
|
+
this.itemCache.delete(state.assistantId);
|
|
585
634
|
}
|
|
586
635
|
|
|
587
636
|
/**
|
|
@@ -597,6 +646,7 @@ export class RouterManager {
|
|
|
597
646
|
|
|
598
647
|
/** 助手没了(归档):关掉它的会话,内存里也不留 —— 它的目录随后会被移进归档。 */
|
|
599
648
|
forget(assistantId: string): void {
|
|
649
|
+
this.itemCache.delete(assistantId);
|
|
600
650
|
const state = this.routers.get(assistantId);
|
|
601
651
|
if (!state) return;
|
|
602
652
|
this.close(state);
|
|
@@ -618,7 +668,7 @@ export class RouterManager {
|
|
|
618
668
|
// ---------- 事件与条目 ----------
|
|
619
669
|
async addItem(assistantId: string, item: NewItem): Promise<Item> {
|
|
620
670
|
const state = await this.ensure(assistantId);
|
|
621
|
-
const lead =
|
|
671
|
+
const lead = this.derived(assistantId).result.lead;
|
|
622
672
|
const id = state.manager!.appendCustomEntry(ITEM_TYPE, item);
|
|
623
673
|
const full = { ...item, ...(lead ? { lead: true } : {}), id, at: this.entryAt(state, id) } as Item;
|
|
624
674
|
this.bus.emit({ type: "item", assistantId, item: full });
|
|
@@ -632,7 +682,7 @@ export class RouterManager {
|
|
|
632
682
|
|
|
633
683
|
async updateImage(assistantId: string, id: string, update: { status: "done" | "failed"; path?: string; error?: string }): Promise<void> {
|
|
634
684
|
const state = await this.ensure(assistantId);
|
|
635
|
-
const item =
|
|
685
|
+
const item = this.derived(assistantId).result.items.find((entry) => entry.id === id && entry.kind === "image");
|
|
636
686
|
if (!item) throw new Error(`图片条目不存在:${id}`);
|
|
637
687
|
state.manager!.appendCustomEntry(ITEM_UPDATE_TYPE, { id, ...update });
|
|
638
688
|
this.bus.emit({ type: "item", assistantId, item: { ...item, ...update } });
|
|
@@ -647,7 +697,7 @@ export class RouterManager {
|
|
|
647
697
|
|
|
648
698
|
/** 取当前回合来源及其之前的可见对话;事件取最近用户消息。 */
|
|
649
699
|
originalWords(assistantId: string, note?: string, recent = false, extraFiles: string[] = []): { text: string; files: string[] } {
|
|
650
|
-
const items =
|
|
700
|
+
const items = this.derived(assistantId).result.items;
|
|
651
701
|
const state = this.routers.get(assistantId);
|
|
652
702
|
const source = items.find((item) => item.kind === "user" && item.id === state?.currentUserId)
|
|
653
703
|
?? items.findLast((item) => item.kind === "user");
|
|
@@ -743,7 +793,8 @@ export class RouterManager {
|
|
|
743
793
|
*/
|
|
744
794
|
private async reportTurnError(state: RouterState): Promise<void> {
|
|
745
795
|
const last = state.session?.messages.findLast((message) => message.role === "assistant");
|
|
746
|
-
|
|
796
|
+
// 停下的那一轮:有的服务商(Claude 订阅桥)把停止报成 error,不是回复失败
|
|
797
|
+
if (state.stalled || !last || last.role !== "assistant" || last.stopReason !== "error") return;
|
|
747
798
|
await this.addItem(state.assistantId, { kind: "note", icon: "info", text: `回复失败:${briefModelError(last.errorMessage)}。发送任意消息即可重试。` });
|
|
748
799
|
}
|
|
749
800
|
|
|
@@ -876,7 +927,10 @@ export class RouterManager {
|
|
|
876
927
|
|
|
877
928
|
async stop(assistantId: string): Promise<void> {
|
|
878
929
|
const state = this.routers.get(assistantId);
|
|
879
|
-
if (state)
|
|
930
|
+
if (state) {
|
|
931
|
+
state.stalled = true;
|
|
932
|
+
this.setActivity(state, null);
|
|
933
|
+
}
|
|
880
934
|
if (state?.session) await state.session.abort();
|
|
881
935
|
}
|
|
882
936
|
|
|
@@ -908,14 +962,12 @@ export class RouterManager {
|
|
|
908
962
|
}
|
|
909
963
|
|
|
910
964
|
items(assistantId: string, options: { before?: string; limit?: number } = {}): ItemsPage {
|
|
911
|
-
const
|
|
965
|
+
const derived = this.derived(assistantId);
|
|
966
|
+
const all = derived.result.items;
|
|
912
967
|
const limit = Math.max(1, Math.min(options.limit ?? 50, 200));
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
if (index !== -1) list = all.slice(0, index);
|
|
917
|
-
}
|
|
918
|
-
const page = list.slice(-limit);
|
|
968
|
+
const end = options.before ? derived.positions.get(options.before) ?? all.length : all.length;
|
|
969
|
+
const start = Math.max(0, end - limit);
|
|
970
|
+
const page = all.slice(start, end);
|
|
919
971
|
const jobIds = new Set(page.filter((item) => item.kind === "job").map((item) => (item as { jobId: string }).jobId));
|
|
920
972
|
const draftIds = new Set(
|
|
921
973
|
page.filter((item) => item.kind === "draft").map((item) => (item as { draftId: string }).draftId),
|
|
@@ -929,15 +981,14 @@ export class RouterManager {
|
|
|
929
981
|
for (const id of delegation.jobIds) jobIds.add(id);
|
|
930
982
|
for (const id of delegation.approvalIds ?? []) approvalIds.add(id);
|
|
931
983
|
}
|
|
932
|
-
for (const approval of this.store.approvals()) if (approval.jobId && jobIds.has(approval.jobId)) approvalIds.add(approval.id);
|
|
933
984
|
return {
|
|
934
985
|
...(!options.before ? { pageLive: [...(this.livePages.get(assistantId)?.values() ?? [])].map((live) => live.page) } : {}),
|
|
935
986
|
items: page,
|
|
936
987
|
approvals: this.store.approvals().filter((approval) => approvalIds.has(approval.id)),
|
|
937
|
-
jobs:
|
|
988
|
+
jobs: [...jobIds].flatMap((id) => { const job = this.store.job(id); return job ? [job] : []; }),
|
|
938
989
|
drafts: this.store.drafts().filter((draft) => draftIds.has(draft.id)),
|
|
939
990
|
delegations,
|
|
940
|
-
hasMore:
|
|
991
|
+
hasMore: start > 0,
|
|
941
992
|
};
|
|
942
993
|
}
|
|
943
994
|
|
|
@@ -947,21 +998,23 @@ export class RouterManager {
|
|
|
947
998
|
|
|
948
999
|
unread(assistantId: string): number {
|
|
949
1000
|
const readAt = this.store.state(assistantId).readAt;
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
(
|
|
953
|
-
|
|
1001
|
+
// 从没记过已读:从现在开始算,以前的都当读过。不记的话未读永远是 0,前端也就永远不会来标已读
|
|
1002
|
+
if (!readAt) {
|
|
1003
|
+
this.read(assistantId);
|
|
1004
|
+
return 0;
|
|
1005
|
+
}
|
|
1006
|
+
const times = this.derived(assistantId).assistantTimes;
|
|
1007
|
+
let lo = 0, hi = times.length;
|
|
1008
|
+
while (lo < hi) {
|
|
1009
|
+
const mid = (lo + hi) >>> 1;
|
|
1010
|
+
if (times[mid] <= readAt) lo = mid + 1;
|
|
1011
|
+
else hi = mid;
|
|
1012
|
+
}
|
|
1013
|
+
return times.length - lo;
|
|
954
1014
|
}
|
|
955
1015
|
|
|
956
1016
|
last(assistantId: string): { text: string; at: Iso } | undefined {
|
|
957
|
-
|
|
958
|
-
for (let i = items.length - 1; i >= 0; i -= 1) {
|
|
959
|
-
const item = items[i];
|
|
960
|
-
if (item.kind === "user" || item.kind === "assistant" || item.kind === "note") {
|
|
961
|
-
return { text: item.text, at: item.at };
|
|
962
|
-
}
|
|
963
|
-
}
|
|
964
|
-
return undefined;
|
|
1017
|
+
return this.derived(assistantId).last;
|
|
965
1018
|
}
|
|
966
1019
|
|
|
967
1020
|
summary(assistant: Assistant): AssistantSummary {
|
|
@@ -970,6 +1023,7 @@ export class RouterManager {
|
|
|
970
1023
|
...assistant,
|
|
971
1024
|
...(last ? { last } : {}),
|
|
972
1025
|
unread: this.unread(assistant.id),
|
|
1026
|
+
...(this.store.state(assistant.id).readAt ? { readAt: this.store.state(assistant.id).readAt } : {}),
|
|
973
1027
|
busy: this.isBusy(assistant.id),
|
|
974
1028
|
activity: this.routers.get(assistant.id)?.activity ?? null,
|
|
975
1029
|
};
|
|
@@ -993,7 +1047,7 @@ export class RouterManager {
|
|
|
993
1047
|
.jobs()
|
|
994
1048
|
.filter((job) => job.status === "running" || job.status === "queued" || job.status === "waiting" || (job.endedAt ?? job.createdAt) > jobCutoff),
|
|
995
1049
|
routines: this.store.routines(),
|
|
996
|
-
approvals: this.store.approvals().filter((approval) => approval.status === "pending"),
|
|
1050
|
+
approvals: this.store.approvals().filter((approval) => approval.status === "pending" && approval.source !== "job"),
|
|
997
1051
|
delegations: this.store.delegations(),
|
|
998
1052
|
settings: this.runtime.settings.get(),
|
|
999
1053
|
};
|
|
@@ -1001,11 +1055,9 @@ export class RouterManager {
|
|
|
1001
1055
|
|
|
1002
1056
|
/** 进程退出或测试收尾:释放所有 Router 会话。 */
|
|
1003
1057
|
disposeAll(): void {
|
|
1004
|
-
for (const state of this.routers.values())
|
|
1005
|
-
if (state.idleTimer) clearTimeout(state.idleTimer);
|
|
1006
|
-
state.session?.dispose();
|
|
1007
|
-
}
|
|
1058
|
+
for (const state of this.routers.values()) this.close(state);
|
|
1008
1059
|
this.routers.clear();
|
|
1060
|
+
this.itemCache.clear();
|
|
1009
1061
|
}
|
|
1010
1062
|
|
|
1011
1063
|
/** 直接走事件的 followUp 队列,实际收到这条消息时才开始关联任务。 */
|
package/server/src/routines.ts
CHANGED
|
@@ -9,6 +9,7 @@ export interface RoutineInput {
|
|
|
9
9
|
name: string;
|
|
10
10
|
instruction: string;
|
|
11
11
|
schedules: string[];
|
|
12
|
+
skill?: string;
|
|
12
13
|
}
|
|
13
14
|
|
|
14
15
|
function validate(schedules: string[], timezone: string): void {
|
|
@@ -58,7 +59,7 @@ export class RoutineScheduler {
|
|
|
58
59
|
|
|
59
60
|
private instruction(routine: Routine): string {
|
|
60
61
|
const skill = routine.skill && usableAssistantSkills(this.runtime.home, routine.assistantId).find((item) => item.name === routine.skill);
|
|
61
|
-
return skill ? skillInstruction(skill.title, routine.instruction) : routine.instruction;
|
|
62
|
+
return skill ? skillInstruction([skill.title], routine.instruction) : routine.instruction;
|
|
62
63
|
}
|
|
63
64
|
|
|
64
65
|
private async fire(routineId: string): Promise<void> {
|
|
@@ -68,6 +69,7 @@ export class RoutineScheduler {
|
|
|
68
69
|
await this.runtime.jobs.start(routine.assistantId, {
|
|
69
70
|
title: routine.name,
|
|
70
71
|
instruction: this.instruction(routine),
|
|
72
|
+
...(routine.skill && usableAssistantSkills(this.runtime.home, routine.assistantId).some((skill) => skill.name === routine.skill) ? { skills: [routine.skill] } : {}),
|
|
71
73
|
routineId: routine.id,
|
|
72
74
|
});
|
|
73
75
|
} catch (error) {
|
|
@@ -81,6 +83,7 @@ export class RoutineScheduler {
|
|
|
81
83
|
|
|
82
84
|
create(input: RoutineInput): Routine {
|
|
83
85
|
validate(input.schedules, this.runtime.settings.settings.general.timezone);
|
|
86
|
+
if (input.skill && !listAssistantSkills(this.runtime.home, input.assistantId).some((skill) => skill.name === input.skill)) throw new Error("绑定的技能无效");
|
|
84
87
|
const routine: Routine = {
|
|
85
88
|
id: newId("rt-"),
|
|
86
89
|
assistantId: input.assistantId,
|
|
@@ -90,6 +93,7 @@ export class RoutineScheduler {
|
|
|
90
93
|
active: true,
|
|
91
94
|
createdAt: new Date().toISOString(),
|
|
92
95
|
runs: [],
|
|
96
|
+
...(input.skill ? { skill: input.skill } : {}),
|
|
93
97
|
};
|
|
94
98
|
this.runtime.store.saveRoutine(routine);
|
|
95
99
|
this.apply(routine);
|
|
@@ -101,10 +105,10 @@ export class RoutineScheduler {
|
|
|
101
105
|
const routine = this.runtime.store.routine(id);
|
|
102
106
|
if (!routine) throw new Error(`定时任务不存在:${id}`);
|
|
103
107
|
if (patch.schedules) validate(patch.schedules, this.runtime.settings.settings.general.timezone);
|
|
104
|
-
if (patch.skill !== undefined && patch.skill !== null && (typeof patch.skill !== "string" || !listAssistantSkills(this.runtime.home, routine.assistantId).some((skill) => skill.name === patch.skill))) throw new Error("绑定的技能无效");
|
|
108
|
+
if (patch.skill !== undefined && patch.skill !== null && patch.skill !== "" && (typeof patch.skill !== "string" || !listAssistantSkills(this.runtime.home, routine.assistantId).some((skill) => skill.name === patch.skill))) throw new Error("绑定的技能无效");
|
|
105
109
|
const { skill, ...rest } = patch;
|
|
106
110
|
const next: Routine = { ...routine, ...rest };
|
|
107
|
-
if (skill === null) delete next.skill;
|
|
111
|
+
if (skill === null || skill === "") delete next.skill;
|
|
108
112
|
else if (skill !== undefined) next.skill = skill;
|
|
109
113
|
this.runtime.store.saveRoutine(next);
|
|
110
114
|
this.apply(next);
|
|
@@ -133,6 +137,7 @@ export class RoutineScheduler {
|
|
|
133
137
|
return await this.runtime.jobs.start(routine.assistantId, {
|
|
134
138
|
title: routine.name,
|
|
135
139
|
instruction: this.instruction(routine),
|
|
140
|
+
...(routine.skill && usableAssistantSkills(this.runtime.home, routine.assistantId).some((skill) => skill.name === routine.skill) ? { skills: [routine.skill] } : {}),
|
|
136
141
|
routineId: routine.id,
|
|
137
142
|
});
|
|
138
143
|
}
|
package/server/src/runtime.ts
CHANGED
|
@@ -17,6 +17,7 @@ import type { LegacyModels } from "./settings.ts";
|
|
|
17
17
|
import type { SettingsStore } from "./settings.ts";
|
|
18
18
|
import type { Store } from "./store.ts";
|
|
19
19
|
import type { PushSender, PushService } from "./push.ts";
|
|
20
|
+
import type { UsageService } from "./usage.ts";
|
|
20
21
|
import { Bus as BusImpl } from "./bus.ts";
|
|
21
22
|
import { JobManager as JobManagerImpl } from "./jobs.ts";
|
|
22
23
|
import { PushService as PushServiceImpl } from "./push.ts";
|
|
@@ -25,6 +26,8 @@ import { RouterManager as RouterManagerImpl } from "./router.ts";
|
|
|
25
26
|
import { RoutineScheduler as RoutineSchedulerImpl } from "./routines.ts";
|
|
26
27
|
import { SettingsStore as SettingsStoreImpl } from "./settings.ts";
|
|
27
28
|
import { Store as StoreImpl } from "./store.ts";
|
|
29
|
+
import { UsagePrices as UsagePricesImpl } from "./usage-prices.ts";
|
|
30
|
+
import { UsageService as UsageServiceImpl } from "./usage.ts";
|
|
28
31
|
|
|
29
32
|
/**
|
|
30
33
|
* 进程内的全部服务。构造时先建 store/bus/pi,再把 router / jobs / routines 挂上去
|
|
@@ -53,6 +56,8 @@ export interface HobRuntime {
|
|
|
53
56
|
routines: RoutineScheduler;
|
|
54
57
|
/** Web Push:VAPID 密钥和订阅在 HOB_HOME/push.json,可见性只在内存里 */
|
|
55
58
|
push: PushService;
|
|
59
|
+
/** 消耗:从会话文件统计用量,单价从 models.dev 拉 */
|
|
60
|
+
usage: UsageService;
|
|
56
61
|
}
|
|
57
62
|
|
|
58
63
|
export interface CreateRuntimeOptions {
|
|
@@ -63,7 +68,7 @@ export interface CreateRuntimeOptions {
|
|
|
63
68
|
legacyModels?: LegacyModels;
|
|
64
69
|
/** 测试注入的推送发送器(不碰网络) */
|
|
65
70
|
pushSender?: PushSender;
|
|
66
|
-
/**
|
|
71
|
+
/** 测试注入假的服务商接口(拉模型列表用)和价格表接口(价格用) */
|
|
67
72
|
fetchImpl?: typeof fetch;
|
|
68
73
|
}
|
|
69
74
|
|
|
@@ -115,5 +120,11 @@ export function createRuntime(options: CreateRuntimeOptions): HobRuntime {
|
|
|
115
120
|
email: () => runtime.settings.settings.push.email,
|
|
116
121
|
...(options.pushSender ? { sender: options.pushSender } : {}),
|
|
117
122
|
});
|
|
123
|
+
runtime.usage = new UsageServiceImpl({
|
|
124
|
+
home: options.home,
|
|
125
|
+
store,
|
|
126
|
+
settings,
|
|
127
|
+
prices: new UsagePricesImpl({ home: options.home, ...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}) }),
|
|
128
|
+
});
|
|
118
129
|
return runtime;
|
|
119
130
|
}
|
package/server/src/settings.ts
CHANGED
|
@@ -15,6 +15,7 @@ import type {
|
|
|
15
15
|
Theme,
|
|
16
16
|
ThinkingLevel,
|
|
17
17
|
ThinkingLevelMapSetting,
|
|
18
|
+
TokenPrice,
|
|
18
19
|
} from "../../shared/types.ts";
|
|
19
20
|
import { defaultSearch, normalizeSearch } from "../../shared/search.ts";
|
|
20
21
|
import { checkImageSettings, normalizeImageSettings } from "./image.ts";
|
|
@@ -25,6 +26,7 @@ import type { ModelScores } from "../../shared/types.ts";
|
|
|
25
26
|
import { isMemoryProvider, LORE_PLUGIN_VERSION, loreConnection, resolveMemoryProvider } from "./memory.ts";
|
|
26
27
|
import { checkProvider } from "./providers.ts";
|
|
27
28
|
import { checkTimezone } from "./time.ts";
|
|
29
|
+
import { checkPrice } from "./usage-prices.ts";
|
|
28
30
|
|
|
29
31
|
const FILE = "settings.json";
|
|
30
32
|
const THEMES: Theme[] = ["system", "light", "dark"];
|
|
@@ -192,6 +194,22 @@ function normalizeProviders(raw: unknown): ProviderSettings[] {
|
|
|
192
194
|
return providers;
|
|
193
195
|
}
|
|
194
196
|
|
|
197
|
+
/** 每百万 token 的单价的四项,键名固定。 */
|
|
198
|
+
function normalizePrices(raw: unknown): Record<string, TokenPrice> {
|
|
199
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
|
200
|
+
const prices: Record<string, TokenPrice> = {};
|
|
201
|
+
for (const [model, price] of Object.entries(raw as Record<string, unknown>)) {
|
|
202
|
+
const id = model.trim();
|
|
203
|
+
if (!id) continue;
|
|
204
|
+
try {
|
|
205
|
+
prices[id] = checkPrice(price);
|
|
206
|
+
} catch {
|
|
207
|
+
// 这一项坏了:跳过,不影响其他模型
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return prices;
|
|
211
|
+
}
|
|
212
|
+
|
|
195
213
|
/** 把读到的(或补丁里的)内容规整成合法设置:不合法的项退回默认值,时区不合法直接报错。 */
|
|
196
214
|
function normalize(raw: unknown, base: Settings): Settings {
|
|
197
215
|
const input = (raw ?? {}) as Partial<Settings>;
|
|
@@ -200,6 +218,7 @@ function normalize(raw: unknown, base: Settings): Settings {
|
|
|
200
218
|
const work = (input.work ?? {}) as Partial<Settings["work"]>;
|
|
201
219
|
const push = (input.push ?? {}) as Partial<Settings["push"]>;
|
|
202
220
|
const theme = text(general.theme, base.general.theme) as Theme;
|
|
221
|
+
const prices = normalizePrices(input.usage?.prices);
|
|
203
222
|
return {
|
|
204
223
|
general: {
|
|
205
224
|
theme: THEMES.includes(theme) ? theme : base.general.theme,
|
|
@@ -234,6 +253,7 @@ function normalize(raw: unknown, base: Settings): Settings {
|
|
|
234
253
|
routerStallMinutes: int(work.routerStallMinutes, base.work.routerStallMinutes, 1, 24 * 60),
|
|
235
254
|
},
|
|
236
255
|
push: { email: text(push.email, base.push.email) },
|
|
256
|
+
...(Object.keys(prices).length ? { usage: { prices } } : {}),
|
|
237
257
|
};
|
|
238
258
|
}
|
|
239
259
|
|
|
@@ -392,6 +412,7 @@ export class SettingsStore {
|
|
|
392
412
|
providers: current.providers,
|
|
393
413
|
work: { ...current.work, ...(patch.work ?? {}) },
|
|
394
414
|
push: { ...current.push, ...(patch.push ?? {}) },
|
|
415
|
+
usage: current.usage,
|
|
395
416
|
},
|
|
396
417
|
current,
|
|
397
418
|
);
|
|
@@ -466,6 +487,22 @@ export class SettingsStore {
|
|
|
466
487
|
return this.get();
|
|
467
488
|
}
|
|
468
489
|
|
|
490
|
+
/** 存一个模型的自定单价(price 为 null = 恢复默认)。不合法直接抛错,界面显示原因。 */
|
|
491
|
+
setUsagePrice(revision: number, model: string, price: TokenPrice | null): SettingsFile | undefined {
|
|
492
|
+
if (revision !== this.file.revision) return undefined;
|
|
493
|
+
const id = model.trim();
|
|
494
|
+
if (!id) throw new Error("未指定模型。");
|
|
495
|
+
const prices = { ...(this.settings.usage?.prices ?? {}) };
|
|
496
|
+
if (price === null) delete prices[id];
|
|
497
|
+
else prices[id] = checkPrice(price);
|
|
498
|
+
const settings = { ...this.settings };
|
|
499
|
+
if (Object.keys(prices).length) settings.usage = { prices };
|
|
500
|
+
else delete settings.usage;
|
|
501
|
+
this.file = { revision: this.file.revision + 1, settings };
|
|
502
|
+
this.write();
|
|
503
|
+
return this.get();
|
|
504
|
+
}
|
|
505
|
+
|
|
469
506
|
/** 新建或改一个服务商(Key 不在这里,见 secrets.json)。 */
|
|
470
507
|
setProvider(provider: ProviderSettings): SettingsFile {
|
|
471
508
|
// 改已有的原地替换(列表顺序不跳),新的排到最后
|
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 { newId } from "./ids.ts";
|
|
3
4
|
import type {
|
|
4
5
|
Approval,
|
|
5
6
|
Assistant,
|
|
@@ -56,6 +57,25 @@ export class Store {
|
|
|
56
57
|
for (const dir of ["assistants", "jobs", "uploads"]) {
|
|
57
58
|
mkdirSync(join(this.home, dir), { recursive: true });
|
|
58
59
|
}
|
|
60
|
+
if (!existsSync(join(this.home, "rules.json"))) {
|
|
61
|
+
const changedAt = new Date().toISOString();
|
|
62
|
+
this.saveGlobalRules([
|
|
63
|
+
...["删除或覆盖文件、数据和备份", "对外发送(消息、邮件、发布)、付费、充值、改计费", "停止或重启别人正在使用的服务和设备", "修改可能让你失去远程访问的设置(网络、防火墙、登录方式)"].map((text) => ({ id: newId("r-"), text, mode: "ask" as const, changedAt })),
|
|
64
|
+
...["过去的记录是线索不是现状:动手前先核实实际状态。", "改配置、改数据前先留好退路(备份或记下原值);改完用实际效果验证,不只看命令返回。", "凭据(密码、密钥、令牌、Cookie)只在用的时候读取,不回显,不写进文件和报告,也不存起来。"].map((text) => ({ id: newId("r-"), text, mode: "allow" as const, changedAt })),
|
|
65
|
+
]);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
globalRules(): Rule[] {
|
|
70
|
+
const path = join(this.home, "rules.json");
|
|
71
|
+
if (!existsSync(path)) return [];
|
|
72
|
+
const rules = JSON.parse(readFileSync(path, "utf8")) as unknown;
|
|
73
|
+
if (!Array.isArray(rules)) throw new Error("全局规则文件格式无效");
|
|
74
|
+
return rules as Rule[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
saveGlobalRules(rules: Rule[]): void {
|
|
78
|
+
writeJsonAtomic(join(this.home, "rules.json"), rules);
|
|
59
79
|
}
|
|
60
80
|
|
|
61
81
|
// ---------- 助手 ----------
|
package/server/src/transcript.ts
CHANGED
|
@@ -5,7 +5,17 @@ import type { JobStep } from "../../shared/types.ts";
|
|
|
5
5
|
import { showPath } from "./text.ts";
|
|
6
6
|
|
|
7
7
|
/** 沉淀技能那一轮发给任务的话的开头:任务详情里这句显示成「沉淀技能」。 */
|
|
8
|
-
export const SEDIMENT_PROMPT_HEAD = "
|
|
8
|
+
export const SEDIMENT_PROMPT_HEAD = "回顾刚才的过程,看看有没有能复用的做法。这类事以后还会再做的,把做法写成技能";
|
|
9
|
+
|
|
10
|
+
/** 旧版用过的开头:已经存下来的任务记录里还是这些,任务详情照样显示成「沉淀技能」。 */
|
|
11
|
+
const LEGACY_SEDIMENT_HEADS = [
|
|
12
|
+
"这件事以后还会再做。回顾刚才的过程,把能复用的做法写成技能",
|
|
13
|
+
"这件事以后还会再做。回顾刚才的过程,把能复用的做法沉淀成技能",
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
function isSedimentPrompt(text: string): boolean {
|
|
17
|
+
return text.startsWith(SEDIMENT_PROMPT_HEAD) || LEGACY_SEDIMENT_HEADS.some((head) => text.startsWith(head));
|
|
18
|
+
}
|
|
9
19
|
|
|
10
20
|
/** transcript 里单步输出最多这么多字,超出的整段落盘,页面按路径去拉。 */
|
|
11
21
|
export const STEP_OUTPUT_LIMIT = 20 * 1024;
|
|
@@ -14,7 +24,7 @@ export const STEP_EVENT_LIMIT = 4 * 1024;
|
|
|
14
24
|
/** diff 只给看得清的一段,超长的改动不整段塞进接口。 */
|
|
15
25
|
const EDIT_TEXT_LIMIT = 4_000;
|
|
16
26
|
|
|
17
|
-
const AUTO_FOLLOW_UP = "
|
|
27
|
+
const AUTO_FOLLOW_UP = "这一轮结束了,还没有交结果。";
|
|
18
28
|
|
|
19
29
|
function oneLine(value: string, max = 120): string {
|
|
20
30
|
const line = value.replace(/\s+/g, " ").trim();
|
|
@@ -313,12 +323,12 @@ export function deriveSteps(file: string, options: DeriveOptions = {}): JobStep[
|
|
|
313
323
|
|
|
314
324
|
if (message.role === "user") {
|
|
315
325
|
const text = textOfContent(message.content).trim();
|
|
316
|
-
if (!text || text.startsWith(AUTO_FOLLOW_UP)) continue;
|
|
326
|
+
if (!text || text.startsWith(AUTO_FOLLOW_UP) || text.startsWith("你还没有调用 report")) continue;
|
|
317
327
|
if (!seenUser) {
|
|
318
328
|
seenUser = true; // 第一句是任务说明,详情头部已经有
|
|
319
329
|
continue;
|
|
320
330
|
}
|
|
321
|
-
steps.push({ id: `u${message.timestamp ?? at}`, kind: "text", at, from: "user", text: text
|
|
331
|
+
steps.push({ id: `u${message.timestamp ?? at}`, kind: "text", at, from: "user", text: isSedimentPrompt(text) ? "沉淀技能" : text });
|
|
322
332
|
}
|
|
323
333
|
}
|
|
324
334
|
return steps;
|