hob-bot 1.0.3 → 1.1.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 +75 -5
- package/README.zh-CN.md +75 -5
- package/dist/web/assets/DesktopView-rVxacveF.js +5 -0
- package/dist/web/assets/index-B-8ISOr1.js +267 -0
- package/dist/web/assets/index-Cu1Pf8yX.css +1 -0
- package/dist/web/assets/{math-zZ4JA8eM.js → math-weKGDUmL.js} +2 -2
- package/dist/web/index.html +2 -2
- package/dist/web/mock/desktop.jpg +0 -0
- package/dist/web/mock/photo.jpg +0 -0
- package/dist/web/mock/photo2.jpg +0 -0
- package/dist/web/mock/sunset.html +90 -0
- package/dist/web/mock//347/201/253/346/230/237/346/227/245/350/220/275/347/244/272/346/204/217/345/233/276.md +34 -0
- package/package.json +10 -2
- package/server/src/actions.ts +24 -57
- package/server/src/assistant-skills.ts +201 -0
- package/server/src/boot.ts +1 -0
- package/server/src/claude-status.ts +47 -0
- package/server/src/config.ts +3 -1
- package/server/src/delegations.ts +40 -12
- package/server/src/desktop-control.ts +152 -0
- package/server/src/desktop-events.ts +63 -0
- package/server/src/desktop-public.ts +43 -0
- package/server/src/desktop-tools.ts +161 -0
- package/server/src/desktop.ts +76 -0
- package/server/src/embedding.ts +52 -0
- package/server/src/files.ts +63 -43
- package/server/src/http.ts +334 -15
- package/server/src/jobs.ts +330 -59
- package/server/src/knowledge-index.ts +123 -0
- package/server/src/knowledge-tools.ts +94 -0
- package/server/src/knowledge.ts +451 -0
- package/server/src/lore-entry.ts +10 -0
- package/server/src/main.ts +6 -0
- package/server/src/mcp.ts +745 -0
- package/server/src/memory.ts +11 -4
- package/server/src/now.ts +33 -0
- package/server/src/overview.ts +1 -1
- package/server/src/pages.ts +1 -1
- package/server/src/pi.ts +92 -16
- package/server/src/prompt.ts +46 -154
- package/server/src/providers.ts +3 -2
- package/server/src/push.ts +42 -7
- package/server/src/router-activity.ts +4 -0
- package/server/src/router-tools.ts +132 -38
- package/server/src/router.ts +159 -19
- package/server/src/routines.ts +14 -4
- package/server/src/runtime.ts +15 -0
- package/server/src/settings.ts +43 -1
- package/server/src/store.ts +4 -3
- package/server/src/thumbs.ts +49 -0
- package/server/src/transcript.ts +57 -4
- package/server/src/worker-tools.ts +14 -12
- package/shared/knowledge.ts +92 -0
- package/shared/mcp.ts +91 -0
- package/shared/media.ts +6 -0
- package/shared/plain-text.ts +31 -0
- package/shared/skills.ts +18 -0
- package/shared/sprites.ts +198 -0
- package/shared/types.ts +105 -20
- package/vendor/claude-bridge/HOB-PATCH.md +10 -0
- package/vendor/claude-bridge/LICENSE +21 -0
- package/vendor/claude-bridge/package.json +62 -0
- package/vendor/claude-bridge/src/agents-md.ts +14 -0
- package/vendor/claude-bridge/src/askclaude-schema.ts +92 -0
- package/vendor/claude-bridge/src/askclaude-ui.ts +90 -0
- package/vendor/claude-bridge/src/attachments.ts +135 -0
- package/vendor/claude-bridge/src/config.ts +105 -0
- package/vendor/claude-bridge/src/convert.ts +222 -0
- package/vendor/claude-bridge/src/extract-tool-results.ts +47 -0
- package/vendor/claude-bridge/src/index.ts +2503 -0
- package/vendor/claude-bridge/src/log-paths.ts +19 -0
- package/vendor/claude-bridge/src/mcp-server.ts +169 -0
- package/vendor/claude-bridge/src/models.ts +164 -0
- package/vendor/claude-bridge/src/prompt-capture.ts +420 -0
- package/vendor/claude-bridge/src/prompt-stream.ts +102 -0
- package/vendor/claude-bridge/src/query-state.ts +118 -0
- package/vendor/claude-bridge/src/rate-limit.ts +48 -0
- package/vendor/claude-bridge/src/session-cwd.ts +38 -0
- package/vendor/claude-bridge/src/session-verify.ts +38 -0
- package/vendor/claude-bridge/src/skills.ts +20 -0
- package/vendor/claude-bridge/src/transcript.ts +86 -0
- package/vendor/claude-bridge/src/usage.ts +53 -0
- package/vendor/lore-pi-extension/package.json +1 -1
- package/vendor/lore-pi-extension/tools.ts +8 -5
- package/dist/web/assets/index-C72gAwgN.css +0 -1
- package/dist/web/assets/index-Dyw5G8Pg.js +0 -230
package/server/src/router.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { getSupportedThinkingLevels } from "@earendil-works/pi-ai";
|
|
|
2
2
|
import { parseStreamingJson } from "@earendil-works/pi-ai/utils/json-parse";
|
|
3
3
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { mkdirSync } from "node:fs";
|
|
5
|
-
import { dirname } from "node:path";
|
|
5
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
6
6
|
import type { AgentSession, AgentSessionEvent, SessionEntry, SessionManager } from "@earendil-works/pi-coding-agent";
|
|
7
7
|
import { SessionManager as SessionManagerImpl } from "@earendil-works/pi-coding-agent";
|
|
8
8
|
import type {
|
|
@@ -21,21 +21,28 @@ import type { HobRuntime } from "./runtime.ts";
|
|
|
21
21
|
import { refreshSystemPrompt } from "./pi.ts";
|
|
22
22
|
import { conversationPick, defaultWorkRef, jobModel, nameOf, noteOf, thinkingForPick, thinkingLevelsOf, fitThinking, usableRefs, NO_MODEL_NOTE, NoModelError } from "./models.ts";
|
|
23
23
|
import { buildRouterTools } from "./router-tools.ts";
|
|
24
|
+
import { boundKnowledge, formatKnowledge, KNOWLEDGE_INTRO } from "./knowledge-tools.ts";
|
|
24
25
|
import { MEMORY_WRITE_TOOLS, memoryNoteText } from "./memory.ts";
|
|
25
26
|
import { buildSystemPrompt, type PromptModel } from "./prompt.ts";
|
|
26
27
|
import { availableSkills } from "./skills.ts";
|
|
28
|
+
import { usableAssistantSkills } from "./assistant-skills.ts";
|
|
27
29
|
import { activityLabel, thinkingLine } from "./router-activity.ts";
|
|
30
|
+
import { imageContents } from "./jobs.ts";
|
|
31
|
+
import { nowBlock } from "./now.ts";
|
|
32
|
+
import { monthDayTimeText } from "./time.ts";
|
|
28
33
|
|
|
29
34
|
export const ITEM_TYPE = "hob.item";
|
|
35
|
+
export const ITEM_UPDATE_TYPE = "hob.item.update";
|
|
30
36
|
export const USER_TYPE = "hob.user";
|
|
31
37
|
export const EVENT_TYPE = "hob.event";
|
|
32
38
|
|
|
33
39
|
/** 会话条目的 id 和 at 由会话文件给出,写卡片时不用传。 */
|
|
34
40
|
/** 事件附带的信息,落在会话的事件条目上:routine 给回复挂「⏱ 名字」,cont 见 walk。 */
|
|
35
41
|
export interface EventDetails {
|
|
36
|
-
routine?: { id: string; name: string };
|
|
42
|
+
routine?: { id: string; name: string; jobId?: string };
|
|
37
43
|
cont?: true;
|
|
38
44
|
delegationId?: string;
|
|
45
|
+
sourceUserId?: string;
|
|
39
46
|
}
|
|
40
47
|
|
|
41
48
|
export type NewItem = Item extends infer T ? (T extends Item ? Omit<T, "id" | "at"> : never) : never;
|
|
@@ -65,6 +72,10 @@ interface RouterState {
|
|
|
65
72
|
/** 还在跑的记忆写入工具:参数只在 tool_execution_start 里有,按 toolCallId 存着给 note 用 */
|
|
66
73
|
memoryWriteArgs: Map<string, Record<string, unknown>>;
|
|
67
74
|
currentDelegationId?: string;
|
|
75
|
+
currentUserId?: string;
|
|
76
|
+
pendingUsers?: { prompt: string; id: string }[];
|
|
77
|
+
pendingDelivery?: Promise<void>;
|
|
78
|
+
knowledgeNames?: string[];
|
|
68
79
|
delegationReply?: string[];
|
|
69
80
|
terminatingTools?: Set<string>;
|
|
70
81
|
}
|
|
@@ -113,7 +124,7 @@ export function briefModelError(raw: string | undefined): string {
|
|
|
113
124
|
function walk(entries: SessionEntry[]): { items: Item[]; lead: boolean } {
|
|
114
125
|
const items: Item[] = [];
|
|
115
126
|
let assistantIndex = 0;
|
|
116
|
-
let routine:
|
|
127
|
+
let routine: EventDetails["routine"];
|
|
117
128
|
let lead = false;
|
|
118
129
|
const push = (item: Item) => {
|
|
119
130
|
items.push(lead ? { ...item, lead: true } : item);
|
|
@@ -140,6 +151,12 @@ function walk(entries: SessionEntry[]): { items: Item[]; lead: boolean } {
|
|
|
140
151
|
push({ ...(entry.data as object), id: entry.id, at: entry.timestamp } as Item);
|
|
141
152
|
continue;
|
|
142
153
|
}
|
|
154
|
+
if (entry.type === "custom" && entry.customType === ITEM_UPDATE_TYPE) {
|
|
155
|
+
const update = entry.data as { id: string; status: "done" | "failed"; path?: string; error?: string };
|
|
156
|
+
const index = items.findIndex((item) => item.id === update.id && item.kind === "image");
|
|
157
|
+
if (index !== -1) items[index] = { ...items[index], ...update } as Item;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
143
160
|
if (entry.type === "custom_message" && entry.customType === EVENT_TYPE) {
|
|
144
161
|
const details = entry.details as EventDetails | undefined;
|
|
145
162
|
routine = details?.routine;
|
|
@@ -202,7 +219,16 @@ export class RouterManager {
|
|
|
202
219
|
/** 测试可改小:不给就看设置里的“回复多久无响应视为受阻” */
|
|
203
220
|
stallOverrideMs?: number;
|
|
204
221
|
|
|
205
|
-
constructor(private readonly runtime: HobRuntime) {
|
|
222
|
+
constructor(private readonly runtime: HobRuntime) {
|
|
223
|
+
for (const assistant of runtime.store.assistants()) {
|
|
224
|
+
const file = runtime.store.routerFile(assistant.id);
|
|
225
|
+
if (!existsSync(file)) continue;
|
|
226
|
+
const pending = deriveItems(readEntries(file)).filter((item) => item.kind === "image" && item.status === "pending");
|
|
227
|
+
if (!pending.length) continue;
|
|
228
|
+
const manager = SessionManagerImpl.open(file, dirname(file), runtime.home);
|
|
229
|
+
for (const item of pending) manager.appendCustomEntry(ITEM_UPDATE_TYPE, { id: item.id, status: "failed", error: "生成被中断" });
|
|
230
|
+
}
|
|
231
|
+
}
|
|
206
232
|
|
|
207
233
|
private get store() {
|
|
208
234
|
return this.runtime.store;
|
|
@@ -244,8 +270,10 @@ export class RouterManager {
|
|
|
244
270
|
// 提示词里要写模型的显示名和定位,所以先把注册表拉一遍
|
|
245
271
|
await this.runtime.models.get();
|
|
246
272
|
const pick = conversationPick(this.runtime.settings.settings.models, assistant);
|
|
273
|
+
state.knowledgeNames = (await boundKnowledge(this.runtime, assistant.id)).map((collection) => collection.name);
|
|
247
274
|
const opened = await this.runtime.pi.open({
|
|
248
275
|
file: this.store.routerFile(assistant.id),
|
|
276
|
+
spillDir: join(dirname(this.store.routerFile(assistant.id)), "spill"),
|
|
249
277
|
cwd: this.store.home,
|
|
250
278
|
model: pick.ref,
|
|
251
279
|
thinking: thinkingForPick(pick, this.runtime.models),
|
|
@@ -258,19 +286,49 @@ export class RouterManager {
|
|
|
258
286
|
memory: this.runtime.settings.memory().provider,
|
|
259
287
|
models: this.promptModels(assistant.id),
|
|
260
288
|
skills: availableSkills(this.runtime.home, undefined, this.runtime.settings.settings.skills?.disabled, this.runtime.settings.memory().provider === "lore" ? this.runtime.pi.loreSkillPaths : []),
|
|
289
|
+
assistantSkills: usableAssistantSkills(this.runtime.home, assistant.id),
|
|
261
290
|
thinkingChoice: this.runtime.settings.settings.models.thinkingChoice,
|
|
291
|
+
knowledgeNames: state.knowledgeNames,
|
|
262
292
|
});
|
|
263
293
|
},
|
|
264
294
|
tools: buildRouterTools(this.runtime, assistant.id).map((tool) => tool.name),
|
|
265
295
|
customTools: buildRouterTools(this.runtime, assistant.id),
|
|
266
296
|
});
|
|
267
297
|
state.session = opened.session;
|
|
298
|
+
if (!state.knowledgeNames.length) refreshSystemPrompt(opened.session, opened.session.getActiveToolNames().filter((name) => name !== "search_knowledge"));
|
|
268
299
|
state.manager = opened.manager;
|
|
269
300
|
state.opening = undefined;
|
|
270
301
|
opened.session.subscribe((event) => this.onSessionEvent(state, assistant.id, event));
|
|
271
302
|
this.touch(assistant.id);
|
|
272
303
|
}
|
|
273
304
|
|
|
305
|
+
private async refreshTools(state: RouterState): Promise<void> {
|
|
306
|
+
state.knowledgeNames = (await boundKnowledge(this.runtime, state.assistantId)).map((collection) => collection.name);
|
|
307
|
+
const names = state.session!.getActiveToolNames().filter((name) => name !== "search_knowledge");
|
|
308
|
+
if (state.knowledgeNames.length) names.push("search_knowledge");
|
|
309
|
+
refreshSystemPrompt(state.session!, names);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
private async automaticKnowledge(state: RouterState, query: string): Promise<string> {
|
|
313
|
+
const ids = this.store.assistant(state.assistantId)?.knowledge ?? [];
|
|
314
|
+
if ([...query.trim()].length < 4 || !ids.length || this.runtime.settings.settings.knowledge?.embedding.source !== "openai") return "";
|
|
315
|
+
this.setActivity(state, { state: "tool", label: "正在查知识库" });
|
|
316
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
317
|
+
try {
|
|
318
|
+
const results = await Promise.race([
|
|
319
|
+
this.runtime.knowledge.search(query, ids, { limit: 3, gate: true }),
|
|
320
|
+
new Promise<never>((_, reject) => { timer = setTimeout(() => reject(new Error("知识库检索超时")), 3000); }),
|
|
321
|
+
]);
|
|
322
|
+
const text = formatKnowledge(results, 2400 - [...KNOWLEDGE_INTRO].length - 1);
|
|
323
|
+
return text ? `\n\n${KNOWLEDGE_INTRO}\n${text}` : "";
|
|
324
|
+
} catch {
|
|
325
|
+
return "";
|
|
326
|
+
} finally {
|
|
327
|
+
if (timer) clearTimeout(timer);
|
|
328
|
+
if (state.activity?.label === "正在查知识库") this.setActivity(state, null);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
274
332
|
/** 这个助手能派、当前可用的模型(提示词里那一节)。 */
|
|
275
333
|
private promptModels(assistantId: string): PromptModel[] {
|
|
276
334
|
const assistant = this.store.assistant(assistantId);
|
|
@@ -326,9 +384,17 @@ export class RouterManager {
|
|
|
326
384
|
}
|
|
327
385
|
}
|
|
328
386
|
if (event.type === "agent_end" || (event.type === "message_end" && event.message.role === "assistant" && ["error", "aborted"].includes(event.message.stopReason))) this.setActivity(state, null);
|
|
387
|
+
if (event.type === "message_start" && event.message.role === "user") {
|
|
388
|
+
const prompt = messageText(event.message.content);
|
|
389
|
+
const index = state.pendingUsers?.findIndex((user) => user.prompt.trim() === prompt) ?? -1;
|
|
390
|
+
if (index >= 0) state.currentUserId = state.pendingUsers!.splice(index, 1)[0].id;
|
|
391
|
+
}
|
|
329
392
|
if (event.type === "message_start" && event.message.role === "custom" && event.message.customType === EVENT_TYPE) {
|
|
330
393
|
const details = event.message.details as EventDetails | undefined;
|
|
331
|
-
|
|
394
|
+
const users = deriveItems(this.entries(assistantId)).filter((item) => item.kind === "user");
|
|
395
|
+
const source = details?.sourceUserId ? users.find((item) => item.id === details.sourceUserId) : undefined;
|
|
396
|
+
state.currentUserId = (source ?? users.at(-1))?.id;
|
|
397
|
+
if (details?.delegationId && details.delegationId !== state.currentDelegationId) {
|
|
332
398
|
state.currentDelegationId = details.delegationId;
|
|
333
399
|
state.delegationReply = [];
|
|
334
400
|
state.terminatingTools = new Set();
|
|
@@ -340,7 +406,7 @@ export class RouterManager {
|
|
|
340
406
|
const failed = event.message.stopReason === "error" || event.message.stopReason === "aborted";
|
|
341
407
|
if (failed || !event.toolResults.length || event.toolResults.every((tool) => state.terminatingTools?.has(tool.toolCallId))) {
|
|
342
408
|
const id = state.currentDelegationId;
|
|
343
|
-
const reply = state.delegationReply?.join("\n") || (failed ? event.message.errorMessage || "目标助手处理失败" : "
|
|
409
|
+
const reply = state.delegationReply?.join("\n") || (failed ? event.message.errorMessage || "目标助手处理失败" : "");
|
|
344
410
|
state.currentDelegationId = undefined;
|
|
345
411
|
state.delegationReply = undefined;
|
|
346
412
|
state.terminatingTools = undefined;
|
|
@@ -528,7 +594,7 @@ export class RouterManager {
|
|
|
528
594
|
}
|
|
529
595
|
}
|
|
530
596
|
|
|
531
|
-
/**
|
|
597
|
+
/** 助手没了(归档):关掉它的会话,内存里也不留 —— 它的目录随后会被移进归档。 */
|
|
532
598
|
forget(assistantId: string): void {
|
|
533
599
|
const state = this.routers.get(assistantId);
|
|
534
600
|
if (!state) return;
|
|
@@ -555,18 +621,67 @@ export class RouterManager {
|
|
|
555
621
|
const id = state.manager!.appendCustomEntry(ITEM_TYPE, item);
|
|
556
622
|
const full = { ...item, ...(lead ? { lead: true } : {}), id, at: this.entryAt(state, id) } as Item;
|
|
557
623
|
this.bus.emit({ type: "item", assistantId, item: full });
|
|
624
|
+
if (full.kind === "file") {
|
|
625
|
+
const rel = relative(join(this.runtime.home, "pages"), resolve(full.path));
|
|
626
|
+
const page = rel !== ".." && !rel.startsWith(`..${sep}`);
|
|
627
|
+
void this.runtime.knowledge.collect({ origin: "output", path: full.path, assistantId, itemId: id, ...(page ? { name: full.name, description: full.meta } : {}) }).catch((error) => console.error("知识库收集失败:", error));
|
|
628
|
+
}
|
|
558
629
|
return full;
|
|
559
630
|
}
|
|
560
631
|
|
|
632
|
+
async updateImage(assistantId: string, id: string, update: { status: "done" | "failed"; path?: string; error?: string }): Promise<void> {
|
|
633
|
+
const state = await this.ensure(assistantId);
|
|
634
|
+
const item = deriveItems(this.entries(assistantId)).find((entry) => entry.id === id && entry.kind === "image");
|
|
635
|
+
if (!item) throw new Error(`图片条目不存在:${id}`);
|
|
636
|
+
state.manager!.appendCustomEntry(ITEM_UPDATE_TYPE, { id, ...update });
|
|
637
|
+
this.bus.emit({ type: "item", assistantId, item: { ...item, ...update } });
|
|
638
|
+
if (item.kind === "image" && update.status === "done" && update.path) {
|
|
639
|
+
void this.runtime.knowledge.collect({ origin: "output", path: update.path, description: item.prompt, assistantId, itemId: id }).catch((error) => console.error("知识库收集失败:", error));
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
561
643
|
currentDelegation(assistantId: string): string | undefined {
|
|
562
644
|
return this.routers.get(assistantId)?.currentDelegationId;
|
|
563
645
|
}
|
|
564
646
|
|
|
565
|
-
|
|
647
|
+
/** 取当前回合来源及其之前的可见对话;事件取最近用户消息。 */
|
|
648
|
+
originalWords(assistantId: string, note?: string, recent = false, extraFiles: string[] = []): { text: string; files: string[] } {
|
|
649
|
+
const items = deriveItems(this.entries(assistantId));
|
|
650
|
+
const state = this.routers.get(assistantId);
|
|
651
|
+
const source = items.find((item) => item.kind === "user" && item.id === state?.currentUserId)
|
|
652
|
+
?? items.findLast((item) => item.kind === "user");
|
|
653
|
+
const user = source?.kind === "user" ? source : undefined;
|
|
654
|
+
const files = [...new Set([...(user?.files ?? []), ...extraFiles])];
|
|
655
|
+
const timezone = this.runtime.settings.settings.general.timezone;
|
|
656
|
+
const name = this.assistant(assistantId).name;
|
|
657
|
+
const sections: string[] = [];
|
|
658
|
+
if (user) {
|
|
659
|
+
const header = user.from ? `${user.from.name} 转来` : "用户原话";
|
|
660
|
+
const lines = [`${header}(${monthDayTimeText(timezone, new Date(user.at))}):`, user.text];
|
|
661
|
+
if (user.quote) lines.push(`(引用:「${user.quote.text}」)`);
|
|
662
|
+
sections.push(lines.filter(Boolean).join("\n"));
|
|
663
|
+
if (recent) {
|
|
664
|
+
const previous = items.slice(0, items.indexOf(user)).filter((item) => item.kind === "user" || item.kind === "assistant").slice(-6);
|
|
665
|
+
if (previous.length) sections.push(`最近的对话:\n${previous.map((item) => {
|
|
666
|
+
if (item.kind !== "user" && item.kind !== "assistant") return "";
|
|
667
|
+
const clock = new Intl.DateTimeFormat("sv-SE", { timeZone: timezone, hour: "2-digit", minute: "2-digit", hour12: false }).format(new Date(item.at));
|
|
668
|
+
const characters = [...item.text];
|
|
669
|
+
const text = characters.length > 1500 ? `${characters.slice(0, 1500).join("")}…` : item.text;
|
|
670
|
+
return `${item.kind === "user" ? "用户" : name}(${clock}):${text}`;
|
|
671
|
+
}).join("\n")}`);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
if (files.length) sections.push(`附件:${files.join("、")}`);
|
|
675
|
+
if (note) sections.push(`${name} 的备注:${note}`);
|
|
676
|
+
return { text: sections.join("\n\n"), files };
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
async addForwarded(assistantId: string, text: string, delegationId: string, sourceId = "hob", files: string[] = []): Promise<Item> {
|
|
566
680
|
const state = await this.ensure(assistantId);
|
|
567
681
|
const from = { assistantId: sourceId, name: this.store.assistant(sourceId)?.name ?? sourceId, delegationId };
|
|
568
|
-
const
|
|
569
|
-
const
|
|
682
|
+
const data = { text, from, ...(files.length ? { files } : {}) };
|
|
683
|
+
const id = state.manager!.appendCustomEntry(USER_TYPE, data);
|
|
684
|
+
const item: Item = { kind: "user", id, at: this.entryAt(state, id), ...data };
|
|
570
685
|
this.bus.emit({ type: "item", assistantId, item });
|
|
571
686
|
return item;
|
|
572
687
|
}
|
|
@@ -581,16 +696,18 @@ export class RouterManager {
|
|
|
581
696
|
assistantId: string,
|
|
582
697
|
text: string,
|
|
583
698
|
details: EventDetails | undefined,
|
|
699
|
+
knowledgeQuery?: string,
|
|
584
700
|
): Promise<boolean> {
|
|
585
701
|
const state = await this.ensure(assistantId);
|
|
586
702
|
const session = state.session!;
|
|
587
|
-
const message = { customType: EVENT_TYPE, content: `[hob-event] ${text}`, display: false, details };
|
|
588
703
|
this.touch(assistantId);
|
|
589
704
|
state.running += 1;
|
|
590
705
|
this.armStall(state);
|
|
591
706
|
this.publishBusy(state);
|
|
592
|
-
refreshSystemPrompt(session);
|
|
593
707
|
try {
|
|
708
|
+
const knowledge = knowledgeQuery === undefined ? "" : await this.automaticKnowledge(state, knowledgeQuery);
|
|
709
|
+
const message = { customType: EVENT_TYPE, content: `${nowBlock(this.runtime, assistantId)}\n\n[hob-event] ${text}${knowledge}`, display: false, details };
|
|
710
|
+
await this.refreshTools(state);
|
|
594
711
|
await this.applyConversationModel(state);
|
|
595
712
|
if (session.isStreaming) {
|
|
596
713
|
await session.sendCustomMessage(message, { deliverAs: "followUp" });
|
|
@@ -672,6 +789,7 @@ export class RouterManager {
|
|
|
672
789
|
};
|
|
673
790
|
const id = manager.appendCustomEntry(USER_TYPE, data);
|
|
674
791
|
const item: Item = { kind: "user", id, at: new Date().toISOString(), ...data };
|
|
792
|
+
for (const path of body.files ?? []) void this.runtime.knowledge.collect({ origin: "chat", path, assistantId, itemId: id }).catch((error) => console.error("知识库收集失败:", error));
|
|
675
793
|
this.bus.emit({ type: "item", assistantId, item });
|
|
676
794
|
const noteId = manager.appendCustomEntry(ITEM_TYPE, { kind: "note", icon: "info", text: note });
|
|
677
795
|
this.bus.emit({
|
|
@@ -683,6 +801,13 @@ export class RouterManager {
|
|
|
683
801
|
}
|
|
684
802
|
|
|
685
803
|
async send(assistantId: string, body: SendMessageBody): Promise<Item> {
|
|
804
|
+
this.runtime.delegations.takeOver(assistantId);
|
|
805
|
+
for (const approval of this.store.approvals()) {
|
|
806
|
+
if (approval.assistantId !== assistantId || approval.source !== "router" || approval.status !== "pending") continue;
|
|
807
|
+
const expired = { ...approval, status: "expired" as const };
|
|
808
|
+
this.store.saveApproval(expired);
|
|
809
|
+
this.bus.emit({ type: "approval", approval: expired });
|
|
810
|
+
}
|
|
686
811
|
const gap = await this.modelGap(assistantId);
|
|
687
812
|
if (gap) return this.sendWithoutModel(assistantId, body, gap);
|
|
688
813
|
const state = await this.ensure(assistantId);
|
|
@@ -701,11 +826,16 @@ export class RouterManager {
|
|
|
701
826
|
...(body.files?.length ? { files: body.files } : {}),
|
|
702
827
|
};
|
|
703
828
|
this.bus.emit({ type: "item", assistantId, item });
|
|
829
|
+
for (const path of body.files ?? []) void this.runtime.knowledge.collect({ origin: "chat", path, assistantId, itemId: id }).catch((error) => console.error("知识库收集失败:", error));
|
|
704
830
|
|
|
705
831
|
const lines = [body.text];
|
|
706
832
|
if (body.quote) lines.unshift(`(引用我之前的话:「${body.quote.text}」)`);
|
|
707
833
|
if (body.files?.length) lines.push(`附件:${body.files.join("、")}`);
|
|
708
|
-
const
|
|
834
|
+
const basePrompt = `${nowBlock(this.runtime, assistantId)}\n\n${lines.filter(Boolean).join("\n")}`;
|
|
835
|
+
const previousDelivery = state.pendingDelivery;
|
|
836
|
+
let releaseDelivery!: () => void;
|
|
837
|
+
const delivery = new Promise<void>((resolve) => { releaseDelivery = resolve; });
|
|
838
|
+
state.pendingDelivery = delivery;
|
|
709
839
|
|
|
710
840
|
this.touch(assistantId);
|
|
711
841
|
state.running += 1;
|
|
@@ -713,16 +843,25 @@ export class RouterManager {
|
|
|
713
843
|
this.publishBusy(state);
|
|
714
844
|
void (async () => {
|
|
715
845
|
try {
|
|
846
|
+
const promptText = basePrompt + await this.automaticKnowledge(state, body.text);
|
|
847
|
+
await previousDelivery;
|
|
848
|
+
(state.pendingUsers ??= []).push({ prompt: promptText, id });
|
|
716
849
|
await this.applyConversationModel(state);
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
850
|
+
await this.refreshTools(state);
|
|
851
|
+
// 对话模型能看图就把图片直接给它(路径照样写在「附件」里,派任务时还能交出去);看不了图就只有路径
|
|
852
|
+
const images = state.session!.model?.input.includes("image") ? imageContents(body.files) : [];
|
|
853
|
+
if (state.session!.isStreaming) {
|
|
854
|
+
await state.session!.steer(promptText, images.length ? images : undefined);
|
|
855
|
+
releaseDelivery();
|
|
856
|
+
} else {
|
|
857
|
+
await state.session!.prompt(promptText, { ...(images.length ? { images } : {}), preflightResult: releaseDelivery });
|
|
721
858
|
await this.reportTurnError(state);
|
|
722
859
|
}
|
|
723
860
|
} catch (error) {
|
|
724
861
|
await this.fail(state, error);
|
|
725
862
|
} finally {
|
|
863
|
+
releaseDelivery();
|
|
864
|
+
if (state.pendingDelivery === delivery) state.pendingDelivery = undefined;
|
|
726
865
|
state.running = Math.max(0, state.running - 1);
|
|
727
866
|
if (!state.running) this.setActivity(state, null);
|
|
728
867
|
this.clearStall(state);
|
|
@@ -869,8 +1008,9 @@ export class RouterManager {
|
|
|
869
1008
|
}
|
|
870
1009
|
|
|
871
1010
|
/** 直接走事件的 followUp 队列,实际收到这条消息时才开始关联任务。 */
|
|
872
|
-
|
|
873
|
-
|
|
1011
|
+
/** 把事件当作转交的一轮送给目标助手;这一轮没正常收尾就把转交记为受阻,不会一直挂着。 */
|
|
1012
|
+
async relay(targetAssistantId: string, text: string, delegationId: string, details?: EventDetails, knowledgeQuery?: string): Promise<void> {
|
|
1013
|
+
const success = await this.pushEvent(targetAssistantId, text, { ...details, delegationId }, knowledgeQuery);
|
|
874
1014
|
if (!success || !this.store.delegation(delegationId)?.turnFinished) {
|
|
875
1015
|
this.runtime.delegations.turnEnded(delegationId, "目标助手处理失败或转交被中断", true);
|
|
876
1016
|
}
|
package/server/src/routines.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { Cron } from "croner";
|
|
|
2
2
|
import type { Job, Routine } from "../../shared/types.ts";
|
|
3
3
|
import type { HobRuntime } from "./runtime.ts";
|
|
4
4
|
import { newId } from "./ids.ts";
|
|
5
|
+
import { listAssistantSkills, usableAssistantSkills } from "./assistant-skills.ts";
|
|
5
6
|
|
|
6
7
|
export interface RoutineInput {
|
|
7
8
|
assistantId: string;
|
|
@@ -55,13 +56,18 @@ export class RoutineScheduler {
|
|
|
55
56
|
this.timers.set(routine.id, list);
|
|
56
57
|
}
|
|
57
58
|
|
|
59
|
+
private instruction(routine: Routine): string {
|
|
60
|
+
const skill = routine.skill && usableAssistantSkills(this.runtime.home, routine.assistantId).find((item) => item.name === routine.skill);
|
|
61
|
+
return skill ? `按技能「${skill.title}」做(先读它的 SKILL.md)。\n${routine.instruction}` : routine.instruction;
|
|
62
|
+
}
|
|
63
|
+
|
|
58
64
|
private async fire(routineId: string): Promise<void> {
|
|
59
65
|
const routine = this.runtime.store.routine(routineId);
|
|
60
66
|
if (!routine || !routine.active) return;
|
|
61
67
|
try {
|
|
62
68
|
await this.runtime.jobs.start(routine.assistantId, {
|
|
63
69
|
title: routine.name,
|
|
64
|
-
instruction:
|
|
70
|
+
instruction: this.instruction(routine),
|
|
65
71
|
routineId: routine.id,
|
|
66
72
|
});
|
|
67
73
|
} catch (error) {
|
|
@@ -91,11 +97,15 @@ export class RoutineScheduler {
|
|
|
91
97
|
return routine;
|
|
92
98
|
}
|
|
93
99
|
|
|
94
|
-
update(id: string, patch: Partial<Pick<Routine, "name" | "instruction" | "schedules" | "active">>): Routine {
|
|
100
|
+
update(id: string, patch: Partial<Pick<Routine, "name" | "instruction" | "schedules" | "active">> & { skill?: string | null }): Routine {
|
|
95
101
|
const routine = this.runtime.store.routine(id);
|
|
96
102
|
if (!routine) throw new Error(`定时任务不存在:${id}`);
|
|
97
103
|
if (patch.schedules) validate(patch.schedules, this.runtime.settings.settings.general.timezone);
|
|
98
|
-
|
|
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("绑定的技能无效");
|
|
105
|
+
const { skill, ...rest } = patch;
|
|
106
|
+
const next: Routine = { ...routine, ...rest };
|
|
107
|
+
if (skill === null) delete next.skill;
|
|
108
|
+
else if (skill !== undefined) next.skill = skill;
|
|
99
109
|
this.runtime.store.saveRoutine(next);
|
|
100
110
|
this.apply(next);
|
|
101
111
|
this.runtime.bus.emit({ type: "routine", routine: next });
|
|
@@ -122,7 +132,7 @@ export class RoutineScheduler {
|
|
|
122
132
|
if (!routine) throw new Error(`定时任务不存在:${id}`);
|
|
123
133
|
return await this.runtime.jobs.start(routine.assistantId, {
|
|
124
134
|
title: routine.name,
|
|
125
|
-
instruction:
|
|
135
|
+
instruction: this.instruction(routine),
|
|
126
136
|
routineId: routine.id,
|
|
127
137
|
});
|
|
128
138
|
}
|
package/server/src/runtime.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import type { Bus } from "./bus.ts";
|
|
2
|
+
import { DesktopService } from "./desktop.ts";
|
|
3
|
+
import { KnowledgeStore } from "./knowledge.ts";
|
|
4
|
+
import { DesktopControl } from "./desktop-control.ts";
|
|
5
|
+
import { DesktopEvents } from "./desktop-events.ts";
|
|
6
|
+
import { DesktopPublicLock } from "./desktop-public.ts";
|
|
2
7
|
import { DelegationManager } from "./delegations.ts";
|
|
3
8
|
import type { HobConfig } from "./config.ts";
|
|
4
9
|
import { memoryDir } from "./config.ts";
|
|
@@ -30,9 +35,14 @@ export interface HobRuntime {
|
|
|
30
35
|
config: HobConfig;
|
|
31
36
|
store: Store;
|
|
32
37
|
bus: Bus;
|
|
38
|
+
knowledge: KnowledgeStore;
|
|
33
39
|
pi: PiHost;
|
|
34
40
|
/** 界面可改的设置(settings.json),每次现读所以改完立即生效 */
|
|
35
41
|
settings: SettingsStore;
|
|
42
|
+
desktop: DesktopService;
|
|
43
|
+
desktopControl: DesktopControl;
|
|
44
|
+
desktopEvents: DesktopEvents;
|
|
45
|
+
desktopPublic: DesktopPublicLock;
|
|
36
46
|
/** 服务商:设置里存不敏感的,Key 存 secrets.json,变动套进引擎 */
|
|
37
47
|
providers: ProviderService;
|
|
38
48
|
/** 引擎注册表里现在能用的模型(设置页、派任务时看) */
|
|
@@ -86,11 +96,16 @@ export function createRuntime(options: CreateRuntimeOptions): HobRuntime {
|
|
|
86
96
|
config: options.config,
|
|
87
97
|
store,
|
|
88
98
|
bus,
|
|
99
|
+
knowledge: new KnowledgeStore(options.home, bus, store, undefined, () => settings.settings.knowledge?.embedding ?? { source: "off" }),
|
|
89
100
|
pi: options.pi,
|
|
90
101
|
settings,
|
|
102
|
+
desktop: new DesktopService(options.home, settings),
|
|
91
103
|
providers,
|
|
92
104
|
models,
|
|
93
105
|
} as HobRuntime;
|
|
106
|
+
runtime.desktopControl = new DesktopControl(runtime);
|
|
107
|
+
runtime.desktopEvents = new DesktopEvents(runtime);
|
|
108
|
+
runtime.desktopPublic = new DesktopPublicLock();
|
|
94
109
|
runtime.router = new RouterManagerImpl(runtime);
|
|
95
110
|
runtime.jobs = new JobManagerImpl(runtime);
|
|
96
111
|
runtime.delegations = new DelegationManager(runtime);
|
package/server/src/settings.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import type {
|
|
5
|
+
ClaudeCodeSettings,
|
|
5
6
|
MemoryConfig,
|
|
6
7
|
MemoryProvider,
|
|
7
8
|
ModelPick,
|
|
@@ -17,10 +18,11 @@ import type {
|
|
|
17
18
|
} from "../../shared/types.ts";
|
|
18
19
|
import { defaultSearch, normalizeSearch } from "../../shared/search.ts";
|
|
19
20
|
import { checkImageSettings, normalizeImageSettings } from "./image.ts";
|
|
21
|
+
import { checkEmbeddingSettings, normalizeEmbeddingSettings } from "./embedding.ts";
|
|
20
22
|
import { checkThinkingMap, isThinkingLevel } from "./models.ts";
|
|
21
23
|
import { normalizeThinkingMap } from "../../shared/thinking.ts";
|
|
22
24
|
import type { ModelScores } from "../../shared/types.ts";
|
|
23
|
-
import { isMemoryProvider, loreConnection, resolveMemoryProvider } from "./memory.ts";
|
|
25
|
+
import { isMemoryProvider, LORE_PLUGIN_VERSION, loreConnection, resolveMemoryProvider } from "./memory.ts";
|
|
24
26
|
import { checkProvider } from "./providers.ts";
|
|
25
27
|
import { checkTimezone } from "./time.ts";
|
|
26
28
|
|
|
@@ -89,6 +91,8 @@ export function defaultSettings(timezone = systemTimezone(), models: ModelSettin
|
|
|
89
91
|
models,
|
|
90
92
|
image: { source: "off" },
|
|
91
93
|
search: defaultSearch(),
|
|
94
|
+
desktop: { url: "", enabled: false, publicAssistants: [] },
|
|
95
|
+
claude: { enabled: false, plan: "pro", executable: "" },
|
|
92
96
|
work: { maxJobs: 2, jobStallMinutes: 15, routerStallMinutes: 6 },
|
|
93
97
|
push: { email: "" },
|
|
94
98
|
};
|
|
@@ -211,7 +215,18 @@ function normalize(raw: unknown, base: Settings): Settings {
|
|
|
211
215
|
models: normalizeModels(input.models, base.models),
|
|
212
216
|
providers: normalizeProviders(input.providers ?? base.providers),
|
|
213
217
|
image: normalizeImageSettings(input.image, base.image),
|
|
218
|
+
knowledge: { embedding: normalizeEmbeddingSettings(input.knowledge?.embedding, base.knowledge?.embedding ?? { source: "off" }) },
|
|
214
219
|
search: normalizeSearch(input.search ?? base.search),
|
|
220
|
+
desktop: {
|
|
221
|
+
url: typeof input.desktop?.url === "string" ? input.desktop.url : (base.desktop?.url ?? ""),
|
|
222
|
+
enabled: typeof input.desktop?.enabled === "boolean" ? input.desktop.enabled : (base.desktop?.enabled ?? false),
|
|
223
|
+
publicAssistants: Array.isArray(input.desktop?.publicAssistants) ? input.desktop.publicAssistants.filter((id): id is string => typeof id === "string") : (base.desktop?.publicAssistants ?? []),
|
|
224
|
+
},
|
|
225
|
+
claude: {
|
|
226
|
+
enabled: input.claude?.enabled === true,
|
|
227
|
+
plan: input.claude?.plan === "max" ? "max" : "pro",
|
|
228
|
+
executable: typeof input.claude?.executable === "string" ? input.claude.executable.trim() : "",
|
|
229
|
+
},
|
|
215
230
|
skills: { disabled: Array.isArray(input.skills?.disabled) ? input.skills.disabled.filter((v): v is string => typeof v === "string") : (base.skills?.disabled ?? []) },
|
|
216
231
|
work: {
|
|
217
232
|
maxJobs: int(work.maxJobs, base.work.maxJobs, 1, 8),
|
|
@@ -317,6 +332,7 @@ export class SettingsStore {
|
|
|
317
332
|
provider: resolveMemoryProvider(this.memoryProvider, lore.configured),
|
|
318
333
|
providerExplicit: this.memoryProvider !== undefined,
|
|
319
334
|
lore,
|
|
335
|
+
lorePlugin: LORE_PLUGIN_VERSION,
|
|
320
336
|
};
|
|
321
337
|
}
|
|
322
338
|
|
|
@@ -368,7 +384,10 @@ export class SettingsStore {
|
|
|
368
384
|
},
|
|
369
385
|
// image / providers 不在这份补丁里(各走自己的接口),别把它们冲回默认
|
|
370
386
|
image: current.image,
|
|
387
|
+
knowledge: current.knowledge,
|
|
371
388
|
search: current.search,
|
|
389
|
+
desktop: current.desktop,
|
|
390
|
+
claude: current.claude,
|
|
372
391
|
skills: patch.skills ?? current.skills,
|
|
373
392
|
providers: current.providers,
|
|
374
393
|
work: { ...current.work, ...(patch.work ?? {}) },
|
|
@@ -392,6 +411,29 @@ export class SettingsStore {
|
|
|
392
411
|
return this.get();
|
|
393
412
|
}
|
|
394
413
|
|
|
414
|
+
setEmbedding(revision: number, embedding: unknown): SettingsFile | undefined {
|
|
415
|
+
if (revision !== this.file.revision) return undefined;
|
|
416
|
+
const next = checkEmbeddingSettings(embedding);
|
|
417
|
+
this.file = { revision: this.file.revision + 1, settings: { ...this.get().settings, knowledge: { embedding: next } } };
|
|
418
|
+
this.write();
|
|
419
|
+
return this.get();
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
setClaude(revision: number, claude: ClaudeCodeSettings): SettingsFile | undefined {
|
|
423
|
+
if (revision !== this.file.revision) return undefined;
|
|
424
|
+
if (typeof claude.enabled !== "boolean" || (claude.plan !== "pro" && claude.plan !== "max") || typeof claude.executable !== "string") throw new Error("Claude Code 设置无效。");
|
|
425
|
+
this.file = { revision: this.file.revision + 1, settings: { ...this.get().settings, claude: { ...claude, executable: claude.executable.trim() } } };
|
|
426
|
+
this.write();
|
|
427
|
+
return this.get();
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
setDesktop(revision: number, desktop: { url: string; enabled: boolean; publicAssistants?: string[] }): SettingsFile | undefined {
|
|
431
|
+
if (revision !== this.file.revision) return undefined;
|
|
432
|
+
this.file = { revision: this.file.revision + 1, settings: { ...this.get().settings, desktop: { ...this.get().settings.desktop, ...desktop } } };
|
|
433
|
+
this.write();
|
|
434
|
+
return this.get();
|
|
435
|
+
}
|
|
436
|
+
|
|
395
437
|
setSearch(revision: number, search: unknown): SettingsFile | undefined {
|
|
396
438
|
if (revision !== this.file.revision) return undefined;
|
|
397
439
|
if (!Array.isArray(search)) throw new Error("联网搜索配置无效。");
|
package/server/src/store.ts
CHANGED
|
@@ -90,10 +90,11 @@ export class Store {
|
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
/**
|
|
93
|
-
*
|
|
94
|
-
* 归档里的 profile.json
|
|
93
|
+
* 归档助手:整个助手目录移进 archive/assistants/<id>-<时间>/。同盘 rename,不复制也不删数据,
|
|
94
|
+
* 归档里的 profile.json 就是它原来的档案;它的定时存成目录里的 routines.json。
|
|
95
95
|
*/
|
|
96
|
-
archiveAssistant(id: string, name: string): string {
|
|
96
|
+
archiveAssistant(id: string, name: string, routines: Routine[] = []): string {
|
|
97
|
+
if (routines.length) writeJsonAtomic(join(this.assistantDir(id), "routines.json"), routines);
|
|
97
98
|
const target = join(this.home, "archive", "assistants", name);
|
|
98
99
|
mkdirSync(dirname(target), { recursive: true });
|
|
99
100
|
renameSync(this.assistantDir(id), target);
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// 对话里的图片缩略图:长边 720px 的 JPEG/PNG,按(路径、大小、修改时间)缓存在 HOB_HOME/cache/thumbs。
|
|
2
|
+
// 对话列表只拉缩略图,点开预览才拉原图。原图本来就小、GIF(可能是动图)、缩不了的,都直接给原图。
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { resizeImage } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
|
|
8
|
+
const THUMB_EDGE = 720;
|
|
9
|
+
const RESIZABLE = new Set(["image/jpeg", "image/png", "image/webp"]);
|
|
10
|
+
const inflight = new Map<string, Promise<Thumb | null>>();
|
|
11
|
+
|
|
12
|
+
export interface Thumb {
|
|
13
|
+
data: Uint8Array;
|
|
14
|
+
mime: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function thumbnail(home: string, file: { path: string; size: number; modifiedAt: string; mime: string }): Promise<Thumb | null> {
|
|
18
|
+
if (!RESIZABLE.has(file.mime)) return null;
|
|
19
|
+
const key = createHash("sha1").update(`${file.path}\0${file.size}\0${file.modifiedAt}`).digest("hex");
|
|
20
|
+
const dir = join(home, "cache", "thumbs");
|
|
21
|
+
for (const [ext, mime] of [["jpg", "image/jpeg"], ["png", "image/png"]] as const) {
|
|
22
|
+
const cached = join(dir, `${key}.${ext}`);
|
|
23
|
+
if (existsSync(cached)) return { data: readFileSync(cached), mime };
|
|
24
|
+
}
|
|
25
|
+
// 原图大小就不值得缩:记一个空标记,下次直接给原图
|
|
26
|
+
if (existsSync(join(dir, `${key}.orig`))) return null;
|
|
27
|
+
let job = inflight.get(key);
|
|
28
|
+
if (!job) {
|
|
29
|
+
job = make(dir, key, file).finally(() => inflight.delete(key));
|
|
30
|
+
inflight.set(key, job);
|
|
31
|
+
}
|
|
32
|
+
return job;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function make(dir: string, key: string, file: { path: string; mime: string }): Promise<Thumb | null> {
|
|
36
|
+
const resized = await resizeImage(readFileSync(file.path), file.mime, { maxWidth: THUMB_EDGE, maxHeight: THUMB_EDGE, maxBytes: 400_000, jpegQuality: 82 }).catch(() => null);
|
|
37
|
+
mkdirSync(dir, { recursive: true });
|
|
38
|
+
if (!resized || !resized.wasResized) {
|
|
39
|
+
writeFileSync(join(dir, `${key}.orig`), "");
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
const data = Buffer.from(resized.data, "base64");
|
|
43
|
+
const target = join(dir, `${key}.${resized.mimeType === "image/png" ? "png" : "jpg"}`);
|
|
44
|
+
// 先写临时文件再改名:并发读到的不会是半截图
|
|
45
|
+
const tmp = `${target}.${process.pid}.tmp`;
|
|
46
|
+
writeFileSync(tmp, data);
|
|
47
|
+
renameSync(tmp, target);
|
|
48
|
+
return { data, mime: resized.mimeType };
|
|
49
|
+
}
|