hob-bot 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +42 -23
  2. package/README.zh-CN.md +43 -24
  3. package/bin/hob.mjs +8 -2
  4. package/dist/web/assets/{DesktopView-lixZfbYr.js → DesktopView-3QDYo0UU.js} +1 -1
  5. package/dist/web/assets/index-Dzxrt9T3.js +283 -0
  6. package/dist/web/assets/index-FUtuyxyO.css +1 -0
  7. package/dist/web/index.html +2 -2
  8. package/dist/web/mock/readme-cover.svg +13 -0
  9. package/dist/web/mock/readme-page.html +7 -0
  10. package/package.json +2 -1
  11. package/server/src/actions.ts +2 -1
  12. package/server/src/assistant-skills.ts +2 -2
  13. package/server/src/boot.ts +8 -0
  14. package/server/src/config.ts +52 -11
  15. package/server/src/cron-text.ts +14 -1
  16. package/server/src/delete-skill.ts +19 -0
  17. package/server/src/desktop-tools.ts +6 -6
  18. package/server/src/files.ts +27 -7
  19. package/server/src/http.ts +41 -9
  20. package/server/src/job-view.ts +2 -2
  21. package/server/src/jobs.ts +108 -62
  22. package/server/src/knowledge-tools.ts +1 -1
  23. package/server/src/knowledge.ts +9 -4
  24. package/server/src/layout-cli.ts +25 -0
  25. package/server/src/layout.ts +749 -0
  26. package/server/src/main.ts +5 -0
  27. package/server/src/mcp.ts +3 -2
  28. package/server/src/memory.ts +2 -2
  29. package/server/src/now.ts +1 -1
  30. package/server/src/overview.ts +1 -2
  31. package/server/src/pages.ts +9 -7
  32. package/server/src/prompt.ts +47 -110
  33. package/server/src/push.ts +3 -2
  34. package/server/src/router-tools.ts +96 -40
  35. package/server/src/router.ts +5 -4
  36. package/server/src/routines.ts +177 -22
  37. package/server/src/runtime.ts +12 -1
  38. package/server/src/search.ts +1 -1
  39. package/server/src/secrets.ts +3 -2
  40. package/server/src/settings.ts +44 -6
  41. package/server/src/store.ts +123 -40
  42. package/server/src/transcript.ts +25 -7
  43. package/server/src/usage-prices.ts +166 -0
  44. package/server/src/usage.ts +291 -0
  45. package/server/src/worker-tools.ts +6 -9
  46. package/shared/types.ts +110 -3
  47. package/dist/web/assets/index-BZhge0qe.js +0 -267
  48. package/dist/web/assets/index-DIo1ArRa.css +0 -1
@@ -4,10 +4,10 @@ import { join, resolve } from "node:path";
4
4
  import { Type } from "@earendil-works/pi-ai";
5
5
  import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
6
6
  import { chromium, type Browser, type Page } from "playwright-core";
7
+ import { assistantFilesDir } from "./config.ts";
7
8
  import type { HobRuntime } from "./runtime.ts";
8
9
 
9
- const LOGIN = "需要登录的页面不要自己登录,用 ask_user 请用户打开云桌面登录后回复。";
10
- const TAKEN = "用户正在操作云桌面,请稍后再试或用 ask_user 询问";
10
+ const TAKEN = "用户正在操作云桌面,等用户操作完再试;着急的话,用 ask_user 问一声。";
11
11
  const point = Type.Object({ x: Type.Integer(), y: Type.Integer() });
12
12
  const action = Type.Object({
13
13
  type: Type.Union([Type.Literal("click"), Type.Literal("move"), Type.Literal("drag"), Type.Literal("type"), Type.Literal("key"), Type.Literal("scroll"), Type.Literal("wait")]),
@@ -27,7 +27,7 @@ export class DesktopToolSession {
27
27
  private active = new Set<Promise<unknown>>();
28
28
  private disposed = false;
29
29
  private profile: string;
30
- constructor(private runtime: HobRuntime, private assistantId: string, private canSeeImages: boolean | (() => boolean), private jobTitle?: string, private router = false, private screenshotDir = join(runtime.home, "assistants", assistantId, "screenshots")) {
30
+ constructor(private runtime: HobRuntime, private assistantId: string, private canSeeImages: boolean | (() => boolean), private jobTitle?: string, private router = false, private screenshotDir = assistantFilesDir(runtime.home, assistantId, "screenshots")) {
31
31
  this.profile = desktopProfile(assistantId);
32
32
  }
33
33
 
@@ -110,7 +110,7 @@ export class DesktopToolSession {
110
110
  tools(): ToolDefinition<any, any>[] {
111
111
  const allowPublic = this.runtime.settings.settings.desktop?.publicAssistants?.includes(this.assistantId) ?? false;
112
112
  const make = (name: string, label: string, description: string, parameters: any, run: (params: any, screen: string) => Promise<string | Buffer>): ToolDefinition<any, any> => ({
113
- name, label, description: `${description} ${LOGIN}`,
113
+ name, label, description,
114
114
  parameters: Type.Object({ ...parameters.properties, ...(allowPublic ? { screen: Type.Optional(Type.Union([Type.Literal("own"), Type.Literal("public")])) } : {}) }),
115
115
  execute: async (_id, params) => {
116
116
  const operation = (async () => {
@@ -147,12 +147,12 @@ export class DesktopToolSession {
147
147
  },
148
148
  });
149
149
  return [
150
- make("browser_open", "打开网页", this.router ? "在你的云桌面浏览器里打开 HTTP(S) 网页。要边看边和用户确认的操作自己做;步骤多、不需要用户盯着的交给任务。" : "在助手的浏览器中打开 HTTP(S) 网页。" , Type.Object({ url: Type.String() }), async ({ url }, screen) => {
150
+ make("browser_open", "打开网页", this.router ? "在你的云桌面浏览器里打开网页(HTTP 或 HTTPS)。" : "在助手的云桌面浏览器里打开网页(HTTP 或 HTTPS)。" , Type.Object({ url: Type.String() }), async ({ url }, screen) => {
151
151
  const target = new URL(url);
152
152
  if (target.protocol !== "http:" && target.protocol !== "https:") throw new Error("只支持 HTTP(S) 网页。");
153
153
  const page = await this.getPage(screen); this.check(screen); await page.goto(target.toString(), { waitUntil: "domcontentloaded", timeout: 30_000 }); return `已打开 ${page.url()}`;
154
154
  }),
155
- make("browser_snapshot", "读取页面", "读取当前页面的无障碍快照;引用用于点击或输入。", Type.Object({}), async (_, screen) => { const page = await this.getPage(screen); this.check(screen); return page.ariaSnapshot({ mode: "ai" }); }),
155
+ make("browser_snapshot", "读取页面", "读取当前页面的无障碍快照;里面的引用用于点击或输入。", Type.Object({}), async (_, screen) => { const page = await this.getPage(screen); this.check(screen); return page.ariaSnapshot({ mode: "ai" }); }),
156
156
  make("browser_click", "点击网页", "点击最新页面快照中的引用。", Type.Object({ ref: Type.String() }), async ({ ref }, screen) => { const page = await this.getPage(screen); this.check(screen); await page.getByRef(ref).click(); return "已点击。"; }),
157
157
  make("browser_type", "输入文字", "向最新页面快照中的引用输入文字。", Type.Object({ ref: Type.String(), text: Type.String(), submit: Type.Optional(Type.Boolean()) }), async ({ ref, text, submit }, screen) => { const page = await this.getPage(screen); this.check(screen); const target = page.getByRef(ref); await target.fill(text); if (submit) { this.check(screen); await target.press("Enter"); } return "已输入。"; }),
158
158
  make("browser_press", "按键", "向当前网页发送按键,如 Enter、Control+L。", Type.Object({ key: Type.String() }), async ({ key }, screen) => { const page = await this.getPage(screen); this.check(screen); await page.keyboard.press(key); return "已按键。"; }),
@@ -3,11 +3,14 @@ import { closeSync, existsSync, lstatSync, openSync, readSync, readdirSync, real
3
3
  import { basename, isAbsolute, join, relative, resolve, sep } from "node:path";
4
4
  import { fileKind, isTextKind, mimeOf } from "../../shared/files.ts";
5
5
  import type { FileKind, JobFileEntry, JobFilesView } from "../../shared/types.ts";
6
+ import { resolveMovedPath } from "./layout.ts";
6
7
 
7
- /** 数据目录根上不对页面开放的文件:Hob 自己的凭据和内部状态(连同它们的临时文件、备份)。 */
8
+ /** 数据目录根上不对页面开放的文件:半搬的老目录里也可能有这些。 */
8
9
  const DENY_ROOT_FILE = /^(secrets|config|settings|push)\.json/i;
9
- /** 数据目录根上不对页面开放的目录:引擎目录(凭据、bin)和 Lore 连接。 */
10
- const DENY_ROOT_DIRS = new Set(["engine", "lore"]);
10
+ /** 数据目录根上整个不对页面开放的目录:配置、凭据、引擎、Lore、记忆都在 system/ 里。 */
11
+ const DENY_ROOT_DIRS = new Set(["system"]);
12
+ /** 迁移前的备份里面有整份凭据:不自动删,但也不给页面看。 */
13
+ const DENY_ROOT_PREFIX = /^backup/i;
11
14
  /** 备份文件一律不给(凭据的备份也在里面)。 */
12
15
  const DENY_ANY_FILE = /\.bak/i;
13
16
 
@@ -38,9 +41,10 @@ export type Lookup = { ok: true; info: FileInfo } | { ok: false; status: 403 | 4
38
41
  function denied(root: string, path: string): boolean {
39
42
  const rel = relative(root, path);
40
43
  const parts = rel.split(sep);
41
- if (DENY_ROOT_DIRS.has(parts[0] ?? "")) return true;
44
+ const top = parts[0] ?? "";
45
+ if (DENY_ROOT_DIRS.has(top) || DENY_ROOT_PREFIX.test(top)) return true;
42
46
  // 根上的凭据文件(含 settings.json.1234.tmp 这种原子写临时文件)
43
- if (parts.length === 1 && DENY_ROOT_FILE.test(parts[0] ?? "")) return true;
47
+ if (parts.length === 1 && DENY_ROOT_FILE.test(top)) return true;
44
48
  return DENY_ANY_FILE.test(basename(path));
45
49
  }
46
50
 
@@ -79,8 +83,11 @@ export function lookupFile(home: string, requested: string, workDirs: string[] =
79
83
  let path: string;
80
84
  let size: number;
81
85
  let modifiedAt: string;
86
+ // 迁移把文件搬走了,会话和卡片里还是老路径:不存在时按 moves.json 找新位置
87
+ const wanted = resolve(requested);
88
+ const candidate = existsSync(wanted) ? wanted : resolveMovedPath(home, wanted);
82
89
  try {
83
- path = realpathSync(resolve(requested));
90
+ path = realpathSync(candidate);
84
91
  const stats = statSync(path);
85
92
  if (!stats.isFile()) return { ok: false, status: 404, error: "文件不存在" };
86
93
  size = stats.size;
@@ -100,10 +107,23 @@ export function lookupFile(home: string, requested: string, workDirs: string[] =
100
107
  const text = isTextKind(kind);
101
108
  return {
102
109
  ok: true,
103
- info: { path, name, size, modifiedAt, kind, text, mime: mimeOf(name), ...(/^(?:pages[/\\][0-9a-f-]{36}|knowledge[/\\]pages[/\\][0-9a-f]{64})\.html$/i.test(relative(root, path)) ? { page: true } : {}) },
110
+ info: { path, name, size, modifiedAt, kind, text, mime: mimeOf(name), ...(isPagePath(relative(root, path)) ? { page: true } : {}) },
104
111
  };
105
112
  }
106
113
 
114
+ /**
115
+ * 展示页面:现在的布局是 assistants/<id>/files/pages/<uuid>.html,老数据还在根上的 pages/。
116
+ * knowledge/pages/<hash>.html 是进知识库的副本,也算页面。
117
+ */
118
+ export function isPagePath(relativePath: string): boolean {
119
+ return /^(?:(?:assistants[/\\][^/\\]+[/\\]files[/\\])?pages[/\\][0-9a-f-]{36}|knowledge[/\\]pages[/\\][0-9a-f]{64})\.html$/i.test(relativePath);
120
+ }
121
+
122
+ /** 文件是不是在某个助手的展示页面目录里(或老布局根上的 pages/):不看文件名。 */
123
+ export function isInPagesDir(relativePath: string): boolean {
124
+ return /^(?:assistants[/\\][^/\\]+[/\\]files[/\\])?pages[/\\][^/\\]+$/.test(relativePath);
125
+ }
126
+
107
127
  function workRoots(root: string, dirs: string[]): string[] {
108
128
  const out: string[] = [];
109
129
  for (const dir of new Set(dirs)) {
@@ -20,11 +20,14 @@ import type {
20
20
  ModelThinkingSaved,
21
21
  ModelsView,
22
22
  PushSubscriptionJson,
23
+ RoutineCheck,
23
24
  SettingsPatch,
25
+ UsagePricePatch,
26
+ UsagePriceSaved,
24
27
  } from "../../shared/types.ts";
25
28
  import { parseSprite } from "../../shared/sprites.ts";
26
29
  import { answerApproval, archiveAssistant, createFromDraft, dismissDraft } from "./actions.ts";
27
- import { setPassword } from "./config.ts";
30
+ import { setPassword, assistantFilesDir, cacheDir } from "./config.ts";
28
31
  import { newId } from "./ids.ts";
29
32
  import { claudeStatus } from "./claude-status.ts";
30
33
  import { checkDesktopUrl } from "./desktop.ts";
@@ -45,7 +48,8 @@ import { SEARCH_PROVIDERS } from "../../shared/search.ts";
45
48
  import { WebSearch } from "./search.ts";
46
49
  import { clampDuty } from "./text.ts";
47
50
  import { deleteOwnSkill, importSkills, listImportSkills, skillViews } from "./skills.ts";
48
- import { archiveAssistantSkill, listAssistantSkills, rollbackAssistantSkill, setAssistantSkillEnabled } from "./assistant-skills.ts";
51
+ import { listAssistantSkills, rollbackAssistantSkill, setAssistantSkillEnabled } from "./assistant-skills.ts";
52
+ import { deleteSkill } from "./delete-skill.ts";
49
53
  import type { SkillImportSource } from "../../shared/skills.ts";
50
54
 
51
55
  const COOKIE = "hob";
@@ -347,6 +351,24 @@ export function createApp(runtime: HobRuntime, options: { webDir?: string; home:
347
351
  return c.json({ settings: next, thinking } satisfies ModelThinkingSaved);
348
352
  });
349
353
 
354
+ // 设置 → 消耗:会话文件里的用量和价格表;改单价走 settings.json,price 为 null 恢复默认
355
+ app.get("/api/usage", async (c) => c.json(await runtime.usage.view()));
356
+
357
+ app.put("/api/usage/price", async (c) => {
358
+ const body = (await c.req.json().catch(() => ({}))) as Partial<UsagePricePatch>;
359
+ if (typeof body.revision !== "number") return c.json({ error: "缺 revision" }, 400);
360
+ const model = typeof body.model === "string" ? body.model.trim() : "";
361
+ if (!model) return c.json({ error: "未指定模型。" }, 400);
362
+ let next;
363
+ try {
364
+ next = runtime.settings.setUsagePrice(body.revision, model, body.price ?? null);
365
+ } catch (error) {
366
+ return c.json({ error: error instanceof Error ? error.message : String(error) }, 400);
367
+ }
368
+ if (!next) return c.json({ error: "设置已在别处修改,已刷新" }, 409);
369
+ return c.json({ settings: next, price: await runtime.usage.priceOf(model) } satisfies UsagePriceSaved);
370
+ });
371
+
350
372
  // 设置 → 服务商:列表(Key 只报配没配)、引擎自带的服务商目录、整份设置
351
373
  app.get("/api/providers/claude-status", async (c) => c.json(await claudeStatus(runtime.home, runtime.settings.settings.claude?.executable || undefined)));
352
374
  app.get("/api/claude", async (c) => {
@@ -549,7 +571,7 @@ export function createApp(runtime: HobRuntime, options: { webDir?: string; home:
549
571
  const path = await generateImage(
550
572
  {
551
573
  home: options.home,
552
- dir: join(options.home, "previews"),
574
+ dir: join(cacheDir(options.home), "previews"),
553
575
  config,
554
576
  ...(typeof body.apiKey === "string" && body.apiKey.trim() ? { apiKey: body.apiKey.trim() } : {}),
555
577
  },
@@ -711,8 +733,7 @@ export function createApp(runtime: HobRuntime, options: { webDir?: string; home:
711
733
  const id = c.req.param("id");
712
734
  if (!store.assistant(id)) return c.json({ error: "助手不存在" }, 404);
713
735
  try {
714
- archiveAssistantSkill(options.home, id, c.req.param("name"));
715
- bus.emit({ type: "skills", assistantId: id });
736
+ deleteSkill(runtime, id, c.req.param("name"));
716
737
  return c.json({ ok: true });
717
738
  } catch (error) { return c.json({ error: error instanceof Error ? error.message : String(error) }, 400); }
718
739
  });
@@ -812,6 +833,8 @@ export function createApp(runtime: HobRuntime, options: { webDir?: string; home:
812
833
  ...(typeof body.dutyLong === "string" ? { dutyLong: body.dutyLong } : {}),
813
834
  ...(typeof body.sprite === "string" ? { sprite: body.sprite } : {}),
814
835
  ...(typeof body.pinned === "boolean" ? { pinned: body.pinned } : {}),
836
+ // 置顶区里的位置:拖动头像换位置后按 0、1、2… 存,取整且不为负
837
+ ...(typeof body.pinOrder === "number" && Number.isFinite(body.pinOrder) ? { pinOrder: Math.max(0, Math.round(body.pinOrder)) } : {}),
815
838
  ...(typeof body.hidden === "boolean" ? { hidden: body.hidden } : {}),
816
839
  ...(typeof body.notify === "boolean" ? { notify: body.notify } : {}),
817
840
  ...(models ? { models } : {}),
@@ -878,6 +901,8 @@ export function createApp(runtime: HobRuntime, options: { webDir?: string; home:
878
901
  name?: string;
879
902
  instruction?: string;
880
903
  schedules?: string[];
904
+ check?: RoutineCheck;
905
+ once?: boolean;
881
906
  };
882
907
  if (!body.assistantId || !body.name || !body.instruction || !body.schedules?.length) {
883
908
  return c.json({ error: "缺字段:assistantId、name、instruction、schedules" }, 400);
@@ -888,6 +913,8 @@ export function createApp(runtime: HobRuntime, options: { webDir?: string; home:
888
913
  name: body.name,
889
914
  instruction: body.instruction,
890
915
  schedules: body.schedules,
916
+ ...(body.check ? { check: body.check } : {}),
917
+ ...(body.once ? { once: true } : {}),
891
918
  });
892
919
  return c.json({ routine });
893
920
  } catch (error) {
@@ -902,6 +929,8 @@ export function createApp(runtime: HobRuntime, options: { webDir?: string; home:
902
929
  schedules?: string[];
903
930
  active?: boolean;
904
931
  skill?: string | null;
932
+ check?: RoutineCheck | null;
933
+ once?: boolean;
905
934
  };
906
935
  try {
907
936
  const routine = routines.update(c.req.param("id"), body);
@@ -923,8 +952,8 @@ export function createApp(runtime: HobRuntime, options: { webDir?: string; home:
923
952
 
924
953
  app.post("/api/routines/:id/run", async (c) => {
925
954
  try {
926
- const job = await routines.runNow(c.req.param("id"));
927
- return c.json({ job });
955
+ const result = await routines.runNow(c.req.param("id"));
956
+ return c.json(result);
928
957
  } catch (error) {
929
958
  return c.json({ error: error instanceof Error ? error.message : String(error) }, 404);
930
959
  }
@@ -933,7 +962,7 @@ export function createApp(runtime: HobRuntime, options: { webDir?: string; home:
933
962
  app.post("/api/jobs/:id/sediment", (c) => {
934
963
  const id = c.req.param("id");
935
964
  if (!store.job(id)) return c.json({ error: "任务不存在" }, 404);
936
- try { jobs.startSediment(id, "manual"); return c.json({ ok: true }); }
965
+ try { jobs.startSediment(id); return c.json({ ok: true }); }
937
966
  catch (error) { return c.json({ error: error instanceof Error ? error.message : String(error) }, 409); }
938
967
  });
939
968
 
@@ -1033,8 +1062,11 @@ export function createApp(runtime: HobRuntime, options: { webDir?: string; home:
1033
1062
  });
1034
1063
 
1035
1064
  app.post("/api/uploads", async (c) => {
1065
+ const assistantId = (c.req.query("assistantId") ?? "").trim();
1066
+ if (!assistantId) return c.json({ error: "缺少 assistantId:上传的文件要放在某个助手的目录下" }, 400);
1067
+ if (!store.assistant(assistantId)) return c.json({ error: `助手不存在:${assistantId}` }, 404);
1036
1068
  const body = await c.req.parseBody({ all: true });
1037
- const uploads = join(options.home, "uploads");
1069
+ const uploads = assistantFilesDir(options.home, assistantId, "uploads");
1038
1070
  mkdirSync(uploads, { recursive: true });
1039
1071
  const saved: { path: string; name: string }[] = [];
1040
1072
  const values = Object.values(body).flatMap((value) => (Array.isArray(value) ? value : [value]));
@@ -1,7 +1,7 @@
1
1
  import type { Approval, Job, JobStatus, JobStep } from "../../shared/types.ts";
2
2
  import type { AssistantSkill } from "../../shared/skills.ts";
3
3
 
4
- const statusText: Record<JobStatus, string> = { queued: "排队中", running: "进行中", waiting: "等用户确认", done: "已完成", stuck: "受阻", failed: "失败", stopped: "已停止" };
4
+ const statusText: Record<JobStatus, string> = { queued: "排队中", running: "进行中", waiting: "等你回复", done: "已完成", stuck: "受阻", failed: "失败", stopped: "已停止" };
5
5
 
6
6
  export function duration(ms: number): string {
7
7
  const seconds = Math.max(0, Math.floor(ms / 1000));
@@ -35,7 +35,7 @@ export function jobView(job: Job, steps: JobStep[], approvals: Approval[], skill
35
35
  : `${statusText[job.status]},已运行 ${elapsed < 60000 ? "不到 1 分钟" : `${Math.floor(elapsed / 60000)} 分钟`}`;
36
36
  const lines = [`任务「${job.title}」(${job.id}):${state}${job.rounds?.length ? `(第 ${job.rounds.length + 1} 轮)` : ""}`];
37
37
  if (job.statusReason) lines.push(`原因:${job.statusReason}`);
38
- for (const approval of approvals.filter((item) => item.jobId === job.id && item.status === "pending")) lines.push(`在等用户回答:${approval.title}`);
38
+ for (const approval of approvals.filter((item) => item.jobId === job.id && item.status === "pending")) lines.push(`在等你回复:${approval.title}`);
39
39
  if (job.skillsUsed?.length) lines.push(`用到的技能:${job.skillsUsed.map((name) => { const skill = skills.find((item) => item.name === name); return skill ? `${skill.title}(第 ${skill.version} 版)` : name; }).join("、")}`);
40
40
  if (ended) {
41
41
  if (job.summary) {