pi-web-ui 0.90.1 → 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.
- package/CHANGELOG.md +81 -1
- package/bin/pi-web-ui.mjs +21 -2
- package/dist/server/agent-service.js +1206 -106
- package/dist/server/claim-files-tool.js +116 -0
- package/dist/server/claim-store.js +301 -0
- package/dist/server/client-state.js +119 -5
- package/dist/server/compaction-markers.js +287 -0
- package/dist/server/conversation-read-tool.js +369 -27
- package/dist/server/conversation-touches.js +357 -0
- package/dist/server/delegate-task.js +12 -1
- package/dist/server/dsh/dsh-agent-service.js +28 -1
- package/dist/server/file-transfer-routes.js +4 -1
- package/dist/server/icon-svg.js +35 -0
- package/dist/server/index.js +152 -34
- package/dist/server/model-admin.js +124 -2
- package/dist/server/model-enrich.js +478 -0
- package/dist/server/plugin-catalog.js +5 -0
- package/dist/server/plugin-facilities.js +42 -11
- package/dist/server/plugin-schedule.js +27 -2
- package/dist/server/plugins.js +112 -18
- package/dist/server/present-files-tool.js +303 -0
- package/dist/server/prompt-composer.js +1 -1
- package/dist/server/protocol-version.js +1 -1
- package/dist/server/read-tool.js +91 -0
- package/dist/server/schedule-agent-tool.js +23 -5
- package/dist/server/scheduler-tasks.js +60 -1
- package/dist/server/scm-commitmsg.js +140 -0
- package/dist/server/scm.js +42 -0
- package/dist/server/serialize.js +21 -1
- package/dist/server/settings-service.js +47 -0
- package/dist/server/skill-tool.js +120 -0
- package/dist/server/soft-cap.js +68 -0
- package/dist/server/subagent-templates.js +6 -0
- package/dist/server/subagents.js +157 -16
- package/dist/server/text-sniff.js +15 -0
- package/dist/server/themes.js +17 -2
- package/dist/server/tool-manager.js +13 -1
- package/package.json +2 -1
- package/plugin-sdk/README.md +1 -0
- package/plugins/catalog.json +20 -0
- package/themes/catppuccin-latte.css +136 -0
- package/themes/catppuccin.css +140 -0
- package/themes/nord.css +130 -0
- package/themes/one-dark.css +131 -0
- package/themes/paper.css +12 -12
- package/themes/solarized-light.css +131 -0
- package/themes/tokyo-night.css +140 -0
- package/web/dist/assets/{TerminalPanel-CkxM4Byl.js → TerminalPanel-RACpk6AX.js} +1 -1
- package/web/dist/assets/es-DIDZppIF.js +2 -0
- package/web/dist/assets/index-BgrnP9Jz.js +364 -0
- package/web/dist/assets/index-Cy8dSTZt.css +1 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-DJIV4A-t.js +0 -355
- package/web/dist/assets/index-DLdcRBs_.css +0 -1
|
@@ -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
|
+
}
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
18
18
|
import { dirname } from "node:path";
|
|
19
|
+
import { normalizeIconSvg } from "./icon-svg.js";
|
|
19
20
|
import { pick } from "./i18n.js";
|
|
20
21
|
/** 合法插件 id(与 server/plugins.ts 的 ID_RE 一致,防路径穿越)。 */
|
|
21
22
|
const ID_RE = /^[A-Za-z0-9_-]+$/;
|
|
@@ -77,6 +78,7 @@ function toEntry(raw, builtin) {
|
|
|
77
78
|
const description = typeof raw.description === "string" && raw.description.trim() ? raw.description.trim() : undefined;
|
|
78
79
|
const descriptionEn = typeof raw.descriptionEn === "string" && raw.descriptionEn.trim() ? raw.descriptionEn.trim() : undefined;
|
|
79
80
|
const icon = typeof raw.icon === "string" && raw.icon.trim() ? raw.icon.trim() : undefined;
|
|
81
|
+
const iconSvg = normalizeIconSvg(raw.iconSvg);
|
|
80
82
|
const homepage = typeof raw.homepage === "string" && raw.homepage.trim() ? raw.homepage.trim() : undefined;
|
|
81
83
|
return {
|
|
82
84
|
id,
|
|
@@ -86,6 +88,7 @@ function toEntry(raw, builtin) {
|
|
|
86
88
|
...(description ? { description } : {}),
|
|
87
89
|
...(descriptionEn ? { descriptionEn } : {}),
|
|
88
90
|
...(icon ? { icon } : {}),
|
|
91
|
+
...(iconSvg ? { iconSvg } : {}),
|
|
89
92
|
...(homepage ? { homepage } : {}),
|
|
90
93
|
};
|
|
91
94
|
}
|
|
@@ -146,6 +149,7 @@ lang) {
|
|
|
146
149
|
? { description: input.description.trim() }
|
|
147
150
|
: {}),
|
|
148
151
|
...(typeof input?.icon === "string" && input.icon.trim() ? { icon: input.icon.trim() } : {}),
|
|
152
|
+
...(normalizeIconSvg(input?.iconSvg) ? { iconSvg: normalizeIconSvg(input?.iconSvg) } : {}),
|
|
149
153
|
});
|
|
150
154
|
atomicWrite(customPath, { entries: next });
|
|
151
155
|
return toEntry(next[next.length - 1], false);
|
|
@@ -223,6 +227,7 @@ export function writeCustomCatalog(customPath, incoming, replace) {
|
|
|
223
227
|
...(e.description ? { description: e.description } : {}),
|
|
224
228
|
...(e.descriptionEn ? { descriptionEn: e.descriptionEn } : {}),
|
|
225
229
|
...(e.icon ? { icon: e.icon } : {}),
|
|
230
|
+
...(e.iconSvg ? { iconSvg: e.iconSvg } : {}),
|
|
226
231
|
...(e.homepage ? { homepage: e.homepage } : {}),
|
|
227
232
|
}));
|
|
228
233
|
atomicWrite(customPath, { entries });
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* 不能防同一用户账号下的完整进程妥协——本地个人工具的合理折衷。
|
|
17
17
|
*/
|
|
18
18
|
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
|
19
|
-
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
19
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
20
20
|
import { readFile as fspReadFile, readdir as fspReaddir, rm as fspRm, mkdir as fspMkdir, writeFile as fspWriteFile, appendFile as fspAppendFile, stat as fspStat, } from "node:fs/promises";
|
|
21
21
|
import { dirname, join, relative, resolve } from "node:path";
|
|
22
22
|
import { createRequire } from "node:module";
|
|
@@ -31,24 +31,52 @@ function atomicWrite(file, data) {
|
|
|
31
31
|
// ---------------------------------------------------------------------------
|
|
32
32
|
// storage
|
|
33
33
|
// ---------------------------------------------------------------------------
|
|
34
|
-
/** 每插件的 JSON 文件 KV。所有方法同步(数据量小,避免并发写乱序)。
|
|
34
|
+
/** 每插件的 JSON 文件 KV。所有方法同步(数据量小,避免并发写乱序)。
|
|
35
|
+
*
|
|
36
|
+
* 缓存按文件 mtimeMs 失效(而非「首次读入后永不失效」):storage.json 有两个
|
|
37
|
+
* 写入者 —— 本类,以及宿主 settings 面板的 saveSettingsValues(直写磁盘的
|
|
38
|
+
* settings 键,不经过本缓存)。插件长轮询里每隔几秒就 store.set("cursor", …)
|
|
39
|
+
* 一次,若缓存永不失效,set() 会把整份旧快照回写,把面板刚保存的 settings
|
|
40
|
+
* 抹掉 —— 表现为「设置重启即丢」。mtime 失效让下一次 load() 重读,两个写入者
|
|
41
|
+
* 各自保留自己的键。 */
|
|
35
42
|
export class PluginStorage {
|
|
36
43
|
file;
|
|
37
44
|
cache;
|
|
45
|
+
/** 缓存对应的文件 mtimeMs(undefined = 文件尚不存在或不可 stat)。 */
|
|
46
|
+
cacheMtimeMs;
|
|
38
47
|
constructor(file) {
|
|
39
48
|
this.file = file;
|
|
40
49
|
}
|
|
41
|
-
|
|
42
|
-
if (this.cache)
|
|
43
|
-
return this.cache;
|
|
50
|
+
mtime() {
|
|
44
51
|
try {
|
|
45
|
-
|
|
46
|
-
this.cache = parsed && typeof parsed === "object" ? parsed : {};
|
|
52
|
+
return statSync(this.file).mtimeMs;
|
|
47
53
|
}
|
|
48
54
|
catch {
|
|
49
|
-
|
|
55
|
+
return undefined; // 不存在 / 不可 stat
|
|
50
56
|
}
|
|
51
|
-
|
|
57
|
+
}
|
|
58
|
+
/** 从磁盘重读(不存在 / 损坏 = 空表)并同步缓存与 mtime。 */
|
|
59
|
+
readFromDisk(mtimeMs = this.mtime()) {
|
|
60
|
+
let parsed = {};
|
|
61
|
+
if (mtimeMs !== undefined) {
|
|
62
|
+
try {
|
|
63
|
+
const raw = JSON.parse(readFileSync(this.file, "utf8"));
|
|
64
|
+
if (raw && typeof raw === "object")
|
|
65
|
+
parsed = raw;
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
parsed = {}; // 损坏 = 空表(不致命,重新积累)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
this.cache = parsed;
|
|
72
|
+
this.cacheMtimeMs = mtimeMs;
|
|
73
|
+
return parsed;
|
|
74
|
+
}
|
|
75
|
+
load() {
|
|
76
|
+
const mtimeMs = this.mtime();
|
|
77
|
+
if (this.cache && this.cacheMtimeMs === mtimeMs)
|
|
78
|
+
return this.cache;
|
|
79
|
+
return this.readFromDisk(mtimeMs);
|
|
52
80
|
}
|
|
53
81
|
get(key, fallback) {
|
|
54
82
|
const v = this.load()[key];
|
|
@@ -60,7 +88,10 @@ export class PluginStorage {
|
|
|
60
88
|
set(key, value) {
|
|
61
89
|
if (!key)
|
|
62
90
|
throw new Error("storage.set: key 不能为空");
|
|
63
|
-
|
|
91
|
+
// 写前必重读磁盘:宿主 settings 面板的 saveSettingsValues 直写同一文件的
|
|
92
|
+
// settings 键(不经过本缓存),拿旧快照整份回写会把它抹掉。写是低频路径,
|
|
93
|
+
// 重读的代价可忽略;mtime 粒度即使同毫秒也抹不掉(这里是无条件重读)。
|
|
94
|
+
const store = this.readFromDisk();
|
|
64
95
|
store[key] = value;
|
|
65
96
|
try {
|
|
66
97
|
atomicWrite(this.file, JSON.stringify(store));
|
|
@@ -70,7 +101,7 @@ export class PluginStorage {
|
|
|
70
101
|
}
|
|
71
102
|
}
|
|
72
103
|
delete(key) {
|
|
73
|
-
const store = this.
|
|
104
|
+
const store = this.readFromDisk(); // 同 set:写前重读,不拿旧快照回写
|
|
74
105
|
if (!(key in store))
|
|
75
106
|
return;
|
|
76
107
|
delete store[key];
|
|
@@ -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
|
-
/**
|
|
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
|
|
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");
|