pi-web-ui 0.91.0 → 0.92.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 (37) hide show
  1. package/CHANGELOG.md +34 -1
  2. package/bin/pi-web-ui.mjs +21 -2
  3. package/dist/server/agent-service.js +443 -45
  4. package/dist/server/claim-files-tool.js +116 -0
  5. package/dist/server/claim-store.js +301 -0
  6. package/dist/server/client-state.js +49 -0
  7. package/dist/server/conversation-read-tool.js +369 -27
  8. package/dist/server/conversation-touches.js +357 -0
  9. package/dist/server/dsh/dsh-agent-service.js +7 -0
  10. package/dist/server/index.js +32 -4
  11. package/dist/server/model-admin.js +124 -2
  12. package/dist/server/model-enrich.js +478 -0
  13. package/dist/server/plugin-schedule.js +27 -2
  14. package/dist/server/plugins.js +65 -10
  15. package/dist/server/present-files-tool.js +303 -0
  16. package/dist/server/read-tool.js +91 -0
  17. package/dist/server/serialize.js +21 -1
  18. package/dist/server/settings-service.js +7 -0
  19. package/dist/server/text-sniff.js +15 -0
  20. package/dist/server/themes.js +17 -2
  21. package/dist/server/tool-manager.js +7 -1
  22. package/package.json +1 -1
  23. package/plugin-sdk/README.md +1 -0
  24. package/plugins/catalog.json +10 -0
  25. package/themes/catppuccin-latte.css +136 -0
  26. package/themes/catppuccin.css +140 -0
  27. package/themes/nord.css +130 -0
  28. package/themes/one-dark.css +131 -0
  29. package/themes/paper.css +12 -12
  30. package/themes/solarized-light.css +131 -0
  31. package/themes/tokyo-night.css +140 -0
  32. package/web/dist/assets/{TerminalPanel-GtDQ1h85.js → TerminalPanel-RACpk6AX.js} +1 -1
  33. package/web/dist/assets/index-BgrnP9Jz.js +364 -0
  34. package/web/dist/assets/index-Cy8dSTZt.css +1 -0
  35. package/web/dist/index.html +2 -2
  36. package/web/dist/assets/index-CdsKltOS.css +0 -1
  37. package/web/dist/assets/index-OoULNF70.js +0 -363
@@ -0,0 +1,478 @@
1
+ const t = (lang, zh, en) => (lang === "zh" ? zh : en);
2
+ /** 反代路由档位后缀(只在精确匹配失败后剥离;-pro/-flash/-lite 等真实
3
+ * 家族区分绝不在此列)。 */
4
+ const TIER_SUFFIXES = ["-thinking", "-high", "-low", "-max", "-preview", "-latest", "-exp"];
5
+ /** 小写 + 下划线转横线 + 去 provider 前缀(a/b → b)。 */
6
+ export function normalizeModelId(id) {
7
+ let s = id.trim().toLowerCase().replace(/_/g, "-");
8
+ const slash = s.lastIndexOf("/");
9
+ if (slash >= 0)
10
+ s = s.slice(slash + 1);
11
+ return s;
12
+ }
13
+ /** 剥离路由档位与日期后缀(-0813 / -20250929),精确匹配失败后的回退用。 */
14
+ export function stripTierSuffix(id) {
15
+ let s = normalizeModelId(id);
16
+ for (let i = 0; i < 3; i++) {
17
+ let next = s.replace(/-((0[1-9]|1[0-2])\d{2}|\d{6,8})$/, "");
18
+ for (const suf of TIER_SUFFIXES) {
19
+ if (next.endsWith(suf) && next.length > suf.length + 2) {
20
+ next = next.slice(0, -suf.length);
21
+ break;
22
+ }
23
+ }
24
+ if (next === s)
25
+ return s;
26
+ s = next;
27
+ }
28
+ return s;
29
+ }
30
+ export function buildIndex(models) {
31
+ const byId = new Map();
32
+ const byName = new Map();
33
+ for (const m of models) {
34
+ const idKey = normalizeModelId(m.id);
35
+ if (idKey) {
36
+ const arr = byId.get(idKey) ?? [];
37
+ arr.push(m);
38
+ byId.set(idKey, arr);
39
+ }
40
+ const nameKey = m.name.trim().toLowerCase();
41
+ if (nameKey && nameKey !== idKey) {
42
+ const arr = byName.get(nameKey) ?? [];
43
+ arr.push(m);
44
+ byName.set(nameKey, arr);
45
+ }
46
+ }
47
+ return { byId, byName, all: models };
48
+ }
49
+ /** OpenRouter /api/v1/models → CatalogModel[](缺字段即缺席,不编造)。 */
50
+ export function parseOpenRouterCatalog(json) {
51
+ const data = json?.data;
52
+ if (!Array.isArray(data))
53
+ return [];
54
+ const out = [];
55
+ for (const item of data) {
56
+ const r = (item ?? {});
57
+ if (typeof r.id !== "string" || !r.id.trim())
58
+ continue;
59
+ const norm = normalizeModelId(r.id);
60
+ const slash = norm.lastIndexOf("/");
61
+ // OpenRouter id 形如 anthropic/claude-sonnet-4-5(normalize 已去前缀,
62
+ // provider 从原始 id 取)。
63
+ const raw = r.id.trim();
64
+ const rawSlash = raw.lastIndexOf("/");
65
+ const provider = rawSlash >= 0 ? raw.slice(0, rawSlash).toLowerCase() : "";
66
+ const tail = slash >= 0 ? norm.slice(slash + 1) : norm;
67
+ const arch = (r.architecture ?? {});
68
+ const inputs = Array.isArray(arch.input_modalities)
69
+ ? arch.input_modalities.filter((x) => typeof x === "string")
70
+ : [];
71
+ const top = (r.top_provider ?? {});
72
+ const num = (v) => typeof v === "number" && Number.isFinite(v) && v > 0 ? v : undefined;
73
+ const params = Array.isArray(r.supported_parameters)
74
+ ? r.supported_parameters.filter((x) => typeof x === "string")
75
+ : [];
76
+ out.push({
77
+ id: tail,
78
+ provider,
79
+ rawId: raw,
80
+ name: typeof r.name === "string" ? r.name : tail,
81
+ contextWindow: num(top.context_length) ?? num(r.context_length),
82
+ maxTokens: num(top.max_completion_tokens),
83
+ vision: inputs.includes("image") ? true : undefined,
84
+ reasoning: params.includes("reasoning") || params.includes("include_reasoning") ? true : undefined,
85
+ });
86
+ }
87
+ return out;
88
+ }
89
+ /** models.dev /api.json → CatalogModel[]({ provider: { models: {...} } })。 */
90
+ export function parseModelsDevCatalog(json) {
91
+ const root = (json ?? {});
92
+ const out = [];
93
+ const num = (v) => typeof v === "number" && Number.isFinite(v) && v > 0 ? v : undefined;
94
+ for (const [providerKey, entry] of Object.entries(root)) {
95
+ const models = entry?.models;
96
+ if (!models || typeof models !== "object")
97
+ continue;
98
+ for (const [key, item] of Object.entries(models)) {
99
+ const r = (item ?? {});
100
+ const rawId = typeof r.id === "string" && r.id.trim() ? r.id.trim() : key;
101
+ const norm = normalizeModelId(rawId);
102
+ const slash = norm.lastIndexOf("/");
103
+ const tail = slash >= 0 ? norm.slice(slash + 1) : norm;
104
+ if (!tail)
105
+ continue;
106
+ const limit = (r.limit ?? {});
107
+ const modalities = (r.modalities ?? {});
108
+ const inputs = Array.isArray(modalities.input)
109
+ ? modalities.input.filter((x) => typeof x === "string")
110
+ : [];
111
+ out.push({
112
+ id: tail,
113
+ provider: (slash >= 0 ? norm.slice(0, slash) : providerKey).toLowerCase(),
114
+ rawId,
115
+ name: typeof r.name === "string" && r.name.trim() ? r.name.trim() : tail,
116
+ family: typeof r.family === "string" ? r.family.toLowerCase() : undefined,
117
+ contextWindow: num(limit.context),
118
+ maxTokens: num(limit.output),
119
+ vision: inputs.includes("image") ? true : undefined,
120
+ reasoning: r.reasoning === true ? true : undefined,
121
+ });
122
+ }
123
+ }
124
+ return out;
125
+ }
126
+ async function fetchJson(fetchFn, url, maxBytes, timeoutMs, signal) {
127
+ if (signal?.aborted)
128
+ throw new Error("aborted");
129
+ const ac = new AbortController();
130
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
131
+ const onAbort = () => ac.abort();
132
+ if (signal)
133
+ signal.addEventListener("abort", onAbort, { once: true });
134
+ try {
135
+ const res = await fetchFn(url, {
136
+ signal: ac.signal,
137
+ headers: { "user-agent": "pi-web-ui model-enrich" },
138
+ });
139
+ if (!res.ok)
140
+ throw new Error(`HTTP ${res.status}`);
141
+ const text = await res.text();
142
+ if (text.length > maxBytes)
143
+ throw new Error("response too large");
144
+ return JSON.parse(text);
145
+ }
146
+ catch (err) {
147
+ if (signal?.aborted)
148
+ throw new Error("aborted");
149
+ if (err.name === "AbortError")
150
+ throw new Error("timeout");
151
+ throw err;
152
+ }
153
+ finally {
154
+ clearTimeout(timer);
155
+ if (signal)
156
+ signal.removeEventListener("abort", onAbort);
157
+ }
158
+ }
159
+ /** 目录抓取缓存(24h TTL,进程内;首跑后无感)。 */
160
+ const catalogCache = new Map();
161
+ const CATALOG_TTL_MS = 24 * 3600 * 1000;
162
+ export function clearEnrichCache() {
163
+ catalogCache.clear();
164
+ }
165
+ export async function getOpenRouterCatalog(fetchFn = fetch, signal) {
166
+ const hit = catalogCache.get("openrouter");
167
+ if (hit && Date.now() - hit.at < CATALOG_TTL_MS)
168
+ return hit.data;
169
+ const data = parseOpenRouterCatalog(await fetchJson(fetchFn, "https://openrouter.ai/api/v1/models", 5_000_000, 25000, signal));
170
+ catalogCache.set("openrouter", { at: Date.now(), data });
171
+ return data;
172
+ }
173
+ export async function getModelsDevCatalog(fetchFn = fetch, signal) {
174
+ const hit = catalogCache.get("modelsdev");
175
+ if (hit && Date.now() - hit.at < CATALOG_TTL_MS)
176
+ return hit.data;
177
+ const data = parseModelsDevCatalog(await fetchJson(fetchFn, "https://models.dev/api.json", 25_000_000, 30000, signal));
178
+ catalogCache.set("modelsdev", { at: Date.now(), data });
179
+ return data;
180
+ }
181
+ /** 从网页正文提取参数(hint URL 兜底;只认上下文/输出上限/识图,不认推理)。 */
182
+ export function extractParamsFromHtml(html) {
183
+ const text = html
184
+ .replace(/<script[\s\S]*?<\/script>/gi, " ")
185
+ .replace(/<style[\s\S]*?<\/style>/gi, " ")
186
+ .replace(/<[^>]+>/g, " ")
187
+ .replace(/\s+/g, " ")
188
+ .slice(0, 200_000);
189
+ const toNum = (n, unit) => {
190
+ const base = Number(n.replace(/[,,]/g, ""));
191
+ if (!Number.isFinite(base) || base <= 0)
192
+ return undefined;
193
+ const mult = unit?.toLowerCase() === "m" ? 1_000_000 : unit?.toLowerCase() === "k" ? 1000 : 1;
194
+ const v = Math.round(base * mult);
195
+ return v >= 1000 && v <= 100_000_000 ? v : undefined;
196
+ };
197
+ const out = {};
198
+ const ctx = text.match(/(\d[\d,.]*)\s*([kKmM])\s*(?:token\s*)?context/i) ??
199
+ text.match(/(\d[\d,.]*)\s*tokens?\s+context/i) ??
200
+ text.match(/context(?:\s+window)?[^.\n]{0,50}?(\d[\d,.]*)\s*([kKmM])?\s*tokens?/i);
201
+ if (ctx) {
202
+ const v = toNum(ctx[1], ctx[2]);
203
+ if (v !== undefined)
204
+ out.contextWindow = v;
205
+ }
206
+ const max = text.match(/(\d[\d,.]*)\s*([kKmM])\s*(?:max(?:imum)?\s+)?output/i) ??
207
+ text.match(/(\d[\d,.]*)\s*tokens?\s+(?:max(?:imum)?\s+)?output/i) ??
208
+ text.match(/(?:max(?:imum)?\s+output|output\s+limit)[^.]{0,50}?(\d[\d,.]*)\s*([kKmM])?\s*tokens?/i);
209
+ if (max) {
210
+ const v = toNum(max[1], max[2]);
211
+ if (v !== undefined)
212
+ out.maxTokens = v;
213
+ }
214
+ if (/image\s*(input|understanding|support)/i.test(text))
215
+ out.vision = true;
216
+ return out;
217
+ }
218
+ async function fetchPageText(fetchFn, url, signal) {
219
+ if (signal?.aborted)
220
+ throw new Error("aborted");
221
+ const ac = new AbortController();
222
+ const timer = setTimeout(() => ac.abort(), 15000);
223
+ const onAbort = () => ac.abort();
224
+ if (signal)
225
+ signal.addEventListener("abort", onAbort, { once: true });
226
+ try {
227
+ const res = await fetchFn(url, {
228
+ signal: ac.signal,
229
+ headers: { "user-agent": "pi-web-ui model-enrich" },
230
+ });
231
+ if (!res.ok)
232
+ throw new Error(`HTTP ${res.status}`);
233
+ const text = await res.text();
234
+ if (text.length > 2_000_000)
235
+ throw new Error("response too large");
236
+ return text;
237
+ }
238
+ catch (err) {
239
+ if (signal?.aborted)
240
+ throw new Error("aborted");
241
+ if (err.name === "AbortError")
242
+ throw new Error("timeout");
243
+ throw err;
244
+ }
245
+ finally {
246
+ clearTimeout(timer);
247
+ if (signal)
248
+ signal.removeEventListener("abort", onAbort);
249
+ }
250
+ }
251
+ /** 单个 id 在给定索引里的精确/别名命中(exact → normalized)。 */
252
+ function lookupIndex(norm, index) {
253
+ const exact = index.byId.get(norm);
254
+ if (exact?.length)
255
+ return { model: exact[0], matchType: "exact" };
256
+ const stripped = stripTierSuffix(norm);
257
+ if (stripped !== norm) {
258
+ const hit = index.byId.get(stripped);
259
+ if (hit?.length)
260
+ return { model: hit[0], matchType: "normalized" };
261
+ }
262
+ const nameHit = index.byName.get(norm);
263
+ if (nameHit?.length)
264
+ return { model: nameHit[0], matchType: "exact" };
265
+ return null;
266
+ }
267
+ /** 家族候选(只做建议,不自动填):family 字符串与 id 互含。 */
268
+ function familySuggestions(norm, indexes, cap = 6) {
269
+ const seen = new Set();
270
+ const out = [];
271
+ for (const index of indexes) {
272
+ for (const m of index.all) {
273
+ if (!m.family || out.length >= cap)
274
+ break;
275
+ if (norm.includes(m.family) || m.family.includes(norm)) {
276
+ if (!seen.has(m.rawId)) {
277
+ seen.add(m.rawId);
278
+ out.push(m.rawId);
279
+ }
280
+ }
281
+ }
282
+ if (out.length >= cap)
283
+ break;
284
+ }
285
+ return out;
286
+ }
287
+ function toResult(lang, id, model, source, matchType) {
288
+ return {
289
+ id,
290
+ status: "matched",
291
+ ...(model.contextWindow ? { contextWindow: model.contextWindow } : {}),
292
+ ...(model.maxTokens ? { maxTokens: model.maxTokens } : {}),
293
+ ...(model.vision ? { input: ["text", "image"] } : {}),
294
+ ...(model.reasoning ? { reasoning: true } : {}),
295
+ ...(model.name && model.name.toLowerCase() !== id.toLowerCase() ? { name: model.name } : {}),
296
+ source: `${source}${matchType === "exact" || matchType === "hint" ? "" : t(lang, "(别名)", " (alias)")}`,
297
+ matchType,
298
+ };
299
+ }
300
+ function isUrl(s) {
301
+ return /^https?:\/\//i.test(s.trim());
302
+ }
303
+ /** 批量补参数:hint(目录 id / URL)优先,否则 OpenRouter → models.dev 自动匹配。 */
304
+ export async function enrichBatch(ids, hints, opts = {}) {
305
+ const lang = opts.lang ?? "en";
306
+ const fetchFn = opts.fetchFn ?? fetch;
307
+ const uniq = [...new Set(ids.map((s) => (s ?? "").trim()).filter(Boolean))].slice(0, 100);
308
+ const results = [];
309
+ const throwAbort = () => {
310
+ const err = new Error("aborted");
311
+ err.partialResults = results;
312
+ throw err;
313
+ };
314
+ if (opts.signal?.aborted)
315
+ throwAbort();
316
+ let orModels = null;
317
+ let mdModels = null;
318
+ let orIndex = null;
319
+ let mdIndex = null;
320
+ if (opts.catalogs) {
321
+ orModels = opts.catalogs.openrouter;
322
+ mdModels = opts.catalogs.modelsdev;
323
+ orIndex = buildIndex(orModels);
324
+ mdIndex = buildIndex(mdModels);
325
+ }
326
+ else {
327
+ opts.onProgress?.({
328
+ phase: "catalog",
329
+ message: t(lang, "正在获取 OpenRouter 模型目录…", "Fetching OpenRouter catalog…"),
330
+ });
331
+ try {
332
+ orModels = await getOpenRouterCatalog(fetchFn, opts.signal);
333
+ }
334
+ catch (err) {
335
+ if (opts.signal?.aborted || err.message === "aborted")
336
+ throwAbort();
337
+ orModels = [];
338
+ }
339
+ orIndex = buildIndex(orModels);
340
+ }
341
+ const ensureMd = async () => {
342
+ if (opts.signal?.aborted)
343
+ throwAbort();
344
+ if (!mdIndex) {
345
+ if (!opts.catalogs) {
346
+ opts.onProgress?.({
347
+ phase: "catalog",
348
+ message: t(lang, "正在拉取 models.dev 目录(约 25MB)…", "Fetching models.dev catalog (~25MB)…"),
349
+ });
350
+ }
351
+ try {
352
+ mdModels = opts.catalogs ? opts.catalogs.modelsdev : await getModelsDevCatalog(fetchFn, opts.signal);
353
+ }
354
+ catch (err) {
355
+ if (opts.signal?.aborted || err.message === "aborted")
356
+ throwAbort();
357
+ mdModels = [];
358
+ }
359
+ mdIndex = buildIndex(mdModels ?? []);
360
+ }
361
+ return mdIndex;
362
+ };
363
+ const indexes = () => (mdIndex ? [orIndex, mdIndex] : [orIndex]);
364
+ if (!opts.catalogs && orModels.length === 0) {
365
+ // 主源不可达时仍试一次辅源;都空才报错。
366
+ await ensureMd();
367
+ if ((mdModels ?? []).length === 0) {
368
+ throw new Error(t(lang, "参考源不可达(OpenRouter / models.dev),请检查网络后重试", "Reference catalogs unreachable (OpenRouter / models.dev); check your network and retry"));
369
+ }
370
+ }
371
+ const total = uniq.length;
372
+ for (let i = 0; i < total; i++) {
373
+ if (opts.signal?.aborted)
374
+ throwAbort();
375
+ const id = uniq[i];
376
+ opts.onProgress?.({
377
+ phase: "matching",
378
+ current: i + 1,
379
+ total,
380
+ message: t(lang, `正在匹配参数 (${i + 1}/${total}):${id}`, `Matching params (${i + 1}/${total}): ${id}`),
381
+ });
382
+ const norm = normalizeModelId(id);
383
+ const hint = (hints[id] ?? hints[norm] ?? "").trim();
384
+ // -- 依据分支 ---------------------------------------------------------
385
+ if (hint) {
386
+ if (isUrl(hint)) {
387
+ opts.onProgress?.({
388
+ phase: "page",
389
+ current: i + 1,
390
+ total,
391
+ message: t(lang, `正在抓取依据网页 (${i + 1}/${total}):${hint}`, `Fetching evidence page (${i + 1}/${total}): ${hint}`),
392
+ });
393
+ try {
394
+ const html = opts.pageFetch ? await opts.pageFetch(hint) : await fetchPageText(fetchFn, hint, opts.signal);
395
+ const ext = extractParamsFromHtml(html);
396
+ if (ext.contextWindow === undefined && ext.maxTokens === undefined && ext.vision === undefined) {
397
+ results.push({
398
+ id,
399
+ status: "unmatched",
400
+ note: t(lang, "网页未提取到可用参数", "No usable params extracted from the page"),
401
+ });
402
+ continue;
403
+ }
404
+ results.push({
405
+ id,
406
+ status: "matched",
407
+ ...(ext.contextWindow ? { contextWindow: ext.contextWindow } : {}),
408
+ ...(ext.maxTokens ? { maxTokens: ext.maxTokens } : {}),
409
+ ...(ext.vision ? { input: ["text", "image"] } : {}),
410
+ source: t(lang, "网页", "Web page"),
411
+ matchType: "hint-url",
412
+ });
413
+ }
414
+ catch (err) {
415
+ if (opts.signal?.aborted || err.message === "aborted")
416
+ throwAbort();
417
+ results.push({
418
+ id,
419
+ status: "unmatched",
420
+ note: t(lang, "依据网页抓取失败", "Failed to fetch the evidence page"),
421
+ });
422
+ }
423
+ continue;
424
+ }
425
+ const hNorm = normalizeModelId(hint);
426
+ const hStripped = stripTierSuffix(hNorm);
427
+ const hit = orIndex.byId.get(hNorm)?.[0] ??
428
+ orIndex.byId.get(hStripped)?.[0] ??
429
+ orIndex.byName.get(hNorm)?.[0];
430
+ if (hit) {
431
+ results.push(toResult(lang, id, hit, t(lang, "依据", "Evidence"), "hint"));
432
+ continue;
433
+ }
434
+ const md = await ensureMd();
435
+ const mdHit = md.byId.get(hNorm)?.[0] ?? md.byId.get(hStripped)?.[0] ?? md.byName.get(hNorm)?.[0];
436
+ if (mdHit) {
437
+ results.push(toResult(lang, id, mdHit, t(lang, "依据", "Evidence"), "hint"));
438
+ continue;
439
+ }
440
+ results.push({
441
+ id,
442
+ status: "unmatched",
443
+ note: t(lang, `依据「${hint}」在目录中未命中`, `Evidence "${hint}" not found in catalogs`),
444
+ });
445
+ continue;
446
+ }
447
+ // -- 自动匹配分支 -----------------------------------------------------
448
+ const orHit = lookupIndex(norm, orIndex);
449
+ if (orHit) {
450
+ const r = toResult(lang, id, orHit.model, "OpenRouter", orHit.matchType);
451
+ // OpenRouter 的推理信号弱;models.dev 有明确布尔值时补上。
452
+ if (!orHit.model.reasoning) {
453
+ const md = await ensureMd();
454
+ const mdHit = md.byId.get(normalizeModelId(orHit.model.id))?.[0];
455
+ if (mdHit?.reasoning)
456
+ r.reasoning = true;
457
+ }
458
+ results.push(r);
459
+ continue;
460
+ }
461
+ const md = await ensureMd();
462
+ const mdHit = lookupIndex(norm, md);
463
+ if (mdHit) {
464
+ results.push(toResult(lang, id, mdHit.model, "models.dev", mdHit.matchType));
465
+ continue;
466
+ }
467
+ const suggestions = familySuggestions(norm, indexes());
468
+ results.push({
469
+ id,
470
+ status: suggestions.length ? "suggested" : "unmatched",
471
+ ...(suggestions.length ? { suggestions, matchType: "family" } : {}),
472
+ note: suggestions.length
473
+ ? t(lang, "家族相近,可用其 id 作依据重试", "Close family match — retry with its id as evidence")
474
+ : t(lang, "目录无此模型,可填官方文档 URL 作依据重试", "Not in catalogs — retry with an official docs URL as evidence"),
475
+ });
476
+ }
477
+ return results;
478
+ }
@@ -136,7 +136,15 @@ export function parseCronSpec(spec) {
136
136
  function isFull(values, min, max) {
137
137
  return values.length === max - min + 1;
138
138
  }
139
- /** 下一次触发毫秒时间戳(从 fromMs 的下一分钟开始扫,上限约一年)。纯函数,单测覆盖。 */
139
+ /**
140
+ * 下一次触发毫秒时间戳(从 fromMs 的下一分钟开始扫,上限约一年);**一年内没有下一次回 null**
141
+ * (例如 `0 0 31 2 *` 这种永远不存在的日子)。
142
+ *
143
+ * 为什么不是回一个「一年后的哨兵值」:调用方 `armCron` 拿它做 `setTimeout(next - now)`,
144
+ * 而 Node 的 setTimeout 延迟超过 2^31-1ms(≈24.8 天)会**溢出成 1ms** —— 哨兵值配上溢出
145
+ * 就是「1ms 后触发 → 再排下一次」的死循环(每次触发还伴随插件回调与写盘)。回 null 之后,
146
+ * 调用方能明确区分「还有很久」与「永远不会发生」。纯函数,单测覆盖。
147
+ */
140
148
  export function nextCronFire(parts, fromMs) {
141
149
  const minuteSet = new Set(parts.minute);
142
150
  const hourSet = new Set(parts.hour);
@@ -163,7 +171,24 @@ export function nextCronFire(parts, fromMs) {
163
171
  continue;
164
172
  return t;
165
173
  }
166
- return limit;
174
+ return null;
175
+ }
176
+ /**
177
+ * setTimeout 的安全延迟上限(毫秒):Node 与浏览器都按 32 位有符号整数存延迟,
178
+ * 超过 2^31-1 会被截断(Node 会**静默变成 1ms** 并打一条 TimeoutOverflowWarning)。
179
+ */
180
+ export const MAX_TIMEOUT_MS = 2_147_483_647;
181
+ /**
182
+ * 把「距离下次触发的毫秒数」切成一段安全的 setTimeout 延迟(纯函数,单测覆盖)。
183
+ *
184
+ * 超过上限就只等一个分片(默认 6 小时)再重新计算 —— 重新计算这一步是关键:
185
+ * 过期声明、时钟回拨、系统休眠回来都能自然纠正,比一次性排一个超长定时器稳。
186
+ */
187
+ export function armDelay(nextAtMs, nowMs, maxChunkMs = 6 * 60 * 60 * 1000) {
188
+ const raw = Math.max(0, Number(nextAtMs) - Number(nowMs));
189
+ if (!Number.isFinite(raw))
190
+ return 0;
191
+ return Math.min(raw, Math.max(1, Math.min(maxChunkMs, MAX_TIMEOUT_MS)));
167
192
  }
168
193
  export function scheduleFile(pluginDir) {
169
194
  return join(pluginDir, "schedules.json");