hob-bot 1.3.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -23
- package/README.zh-CN.md +43 -24
- package/bin/hob.mjs +8 -2
- package/dist/web/assets/{DesktopView-lixZfbYr.js → DesktopView-3QDYo0UU.js} +1 -1
- package/dist/web/assets/index-Dzxrt9T3.js +283 -0
- package/dist/web/assets/index-FUtuyxyO.css +1 -0
- package/dist/web/index.html +2 -2
- package/dist/web/mock/readme-cover.svg +13 -0
- package/dist/web/mock/readme-page.html +7 -0
- package/package.json +2 -1
- package/server/src/actions.ts +2 -1
- package/server/src/assistant-skills.ts +2 -2
- package/server/src/boot.ts +8 -0
- package/server/src/config.ts +52 -11
- package/server/src/cron-text.ts +14 -1
- package/server/src/delete-skill.ts +19 -0
- package/server/src/desktop-tools.ts +6 -6
- package/server/src/files.ts +27 -7
- package/server/src/http.ts +41 -9
- package/server/src/job-view.ts +2 -2
- package/server/src/jobs.ts +108 -62
- package/server/src/knowledge-tools.ts +1 -1
- package/server/src/knowledge.ts +9 -4
- package/server/src/layout-cli.ts +25 -0
- package/server/src/layout.ts +749 -0
- package/server/src/main.ts +5 -0
- package/server/src/mcp.ts +3 -2
- package/server/src/memory.ts +2 -2
- package/server/src/now.ts +1 -1
- package/server/src/overview.ts +1 -2
- package/server/src/pages.ts +9 -7
- package/server/src/prompt.ts +47 -110
- package/server/src/push.ts +3 -2
- package/server/src/router-tools.ts +96 -40
- package/server/src/router.ts +5 -4
- package/server/src/routines.ts +177 -22
- package/server/src/runtime.ts +12 -1
- package/server/src/search.ts +1 -1
- package/server/src/secrets.ts +3 -2
- package/server/src/settings.ts +44 -6
- package/server/src/store.ts +123 -40
- package/server/src/transcript.ts +25 -7
- package/server/src/usage-prices.ts +166 -0
- package/server/src/usage.ts +291 -0
- package/server/src/worker-tools.ts +6 -9
- package/shared/types.ts +110 -3
- package/dist/web/assets/index-BZhge0qe.js +0 -267
- package/dist/web/assets/index-DIo1ArRa.css +0 -1
|
@@ -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
|
|
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 =
|
|
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
|
|
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 ? "
|
|
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", "读取页面", "
|
|
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 "已按键。"; }),
|
package/server/src/files.ts
CHANGED
|
@@ -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
|
-
/**
|
|
8
|
+
/** 数据目录根上不对页面开放的文件:半搬的老目录里也可能有这些。 */
|
|
8
9
|
const DENY_ROOT_FILE = /^(secrets|config|settings|push)\.json/i;
|
|
9
|
-
/**
|
|
10
|
-
const DENY_ROOT_DIRS = new Set(["
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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), ...(
|
|
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)) {
|
package/server/src/http.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
927
|
-
return c.json(
|
|
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
|
|
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 =
|
|
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]));
|
package/server/src/job-view.ts
CHANGED
|
@@ -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: "
|
|
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(
|
|
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) {
|