pi-web-ui 0.91.0 → 0.93.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 (64) hide show
  1. package/CHANGELOG.md +90 -1
  2. package/README.md +69 -14
  3. package/README.zh-CN.md +36 -10
  4. package/bin/pi-web-ui.mjs +39 -8
  5. package/dist/server/agent-service.js +938 -163
  6. package/dist/server/auth-cookie.js +26 -0
  7. package/dist/server/claim-files-tool.js +115 -0
  8. package/dist/server/claim-store.js +301 -0
  9. package/dist/server/client-state.js +114 -6
  10. package/dist/server/conversation-read-tool.js +371 -28
  11. package/dist/server/conversation-touches.js +357 -0
  12. package/dist/server/delegate-task.js +10 -3
  13. package/dist/server/dsh/dsh-agent-service.js +50 -1
  14. package/dist/server/files-service.js +78 -26
  15. package/dist/server/http-proxy.js +72 -0
  16. package/dist/server/index.js +94 -11
  17. package/dist/server/model-admin.js +124 -2
  18. package/dist/server/model-enrich.js +478 -0
  19. package/dist/server/patch-node-pty.js +11 -0
  20. package/dist/server/plugin-schedule.js +27 -2
  21. package/dist/server/plugins.js +66 -10
  22. package/dist/server/present-files-tool.js +303 -0
  23. package/dist/server/protocol-version.js +1 -1
  24. package/dist/server/read-tool.js +125 -0
  25. package/dist/server/resolve-global-sdk.js +72 -0
  26. package/dist/server/sdk-origin.js +64 -0
  27. package/dist/server/serialize.js +80 -22
  28. package/dist/server/settings-service.js +19 -1
  29. package/dist/server/skill-tool.js +2 -1
  30. package/dist/server/subagents.js +30 -13
  31. package/dist/server/terminals.js +38 -18
  32. package/dist/server/text-sniff.js +15 -0
  33. package/dist/server/themes.js +32 -2
  34. package/dist/server/tool-info.js +89 -0
  35. package/dist/server/tool-manager.js +110 -11
  36. package/package.json +2 -2
  37. package/plugin-sdk/README.md +1 -0
  38. package/plugins/catalog.json +20 -0
  39. package/themes/ayu-light.css +136 -0
  40. package/themes/catppuccin-latte.css +140 -0
  41. package/themes/catppuccin.css +144 -0
  42. package/themes/codex.css +136 -0
  43. package/themes/cyberpunk.css +4 -0
  44. package/themes/dazzle.css +4 -0
  45. package/themes/everforest-light.css +136 -0
  46. package/themes/geist.css +136 -0
  47. package/themes/gruvbox-light.css +136 -0
  48. package/themes/kanagawa-lotus.css +136 -0
  49. package/themes/md-preview.css +4 -0
  50. package/themes/mist.css +4 -0
  51. package/themes/nord.css +134 -0
  52. package/themes/one-dark.css +135 -0
  53. package/themes/paper.css +16 -12
  54. package/themes/rose-pine-dawn.css +136 -0
  55. package/themes/sakura.css +4 -0
  56. package/themes/solarized-light.css +135 -0
  57. package/themes/tokyo-night.css +144 -0
  58. package/themes/white.css +4 -0
  59. package/web/dist/assets/{TerminalPanel-GtDQ1h85.js → TerminalPanel-C1A23aI-.js} +1 -1
  60. package/web/dist/assets/index-B48QWf0I.js +364 -0
  61. package/web/dist/assets/index-DGL5OInG.css +1 -0
  62. package/web/dist/index.html +2 -2
  63. package/web/dist/assets/index-CdsKltOS.css +0 -1
  64. package/web/dist/assets/index-OoULNF70.js +0 -363
@@ -13,6 +13,7 @@
13
13
  */
14
14
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
15
15
  import { dirname, join } from "node:path";
16
+ import { enrichBatch } from "./model-enrich.js";
16
17
  import { pick } from "./i18n.js";
17
18
  import { ProviderOAuthFlowManager } from "./provider-oauth-flow.js";
18
19
  /** Drop provisional overlay rows once the official catalog lists the same id.
@@ -196,9 +197,13 @@ function parseOpenAiModel(m) {
196
197
  boolMeta(r.supports_vision) === true ||
197
198
  boolMeta(r.vision) === true ||
198
199
  strArrMeta(r.input)?.includes("image") === true;
200
+ // 名字兜底:很多端点(如 Antigravity-Manager 的 /v1/models)只给 id 不给
201
+ // 元数据;id 含 thinking 即视为思考模型(与反代自身的 is_thinking_model
202
+ // 同口径)。只增不减:取消勾选靠手填(编辑页/刷新合并都只补缺)。
199
203
  const reasoning = boolMeta(r.reasoning) === true ||
200
204
  boolMeta(r.supports_reasoning) === true ||
201
- modalities?.includes("reasoning") === true;
205
+ modalities?.includes("reasoning") === true ||
206
+ id.toLowerCase().includes("thinking");
202
207
  const contextWindow = numMeta(r.context_window) ?? numMeta(r.context_length) ?? numMeta(r.max_model_len) ?? numMeta(r.max_context_length);
203
208
  const maxTokens = numMeta(r.max_tokens) ?? numMeta(r.max_output_tokens) ?? numMeta(r.max_completion_tokens);
204
209
  return {
@@ -218,9 +223,12 @@ function parseGoogleModel(m) {
218
223
  const rawName = typeof r.name === "string" ? r.name : "";
219
224
  const id = rawName.replace(/^models\//, "");
220
225
  const displayName = typeof r.displayName === "string" ? r.displayName : undefined;
226
+ // 与 OpenAI 形状同口径的名字兜底(见 parseOpenAiModel)。
227
+ const reasoning = id.toLowerCase().includes("thinking");
221
228
  return {
222
229
  id,
223
230
  ...(displayName && displayName !== id ? { name: displayName } : {}),
231
+ ...(reasoning ? { reasoning: true } : {}),
224
232
  ...(numMeta(r.inputTokenLimit) ? { contextWindow: numMeta(r.inputTokenLimit) } : {}),
225
233
  ...(numMeta(r.outputTokenLimit) ? { maxTokens: numMeta(r.outputTokenLimit) } : {}),
226
234
  };
@@ -228,6 +236,7 @@ function parseGoogleModel(m) {
228
236
  export class ModelAdminService {
229
237
  host;
230
238
  oauthFlows;
239
+ activeEnrichAbort = new Map();
231
240
  constructor(host) {
232
241
  this.host = host;
233
242
  this.oauthFlows = new ProviderOAuthFlowManager({
@@ -251,6 +260,25 @@ export class ModelAdminService {
251
260
  }
252
261
  dispose() {
253
262
  this.oauthFlows.dispose();
263
+ for (const ac of this.activeEnrichAbort.values()) {
264
+ ac.abort();
265
+ }
266
+ this.activeEnrichAbort.clear();
267
+ }
268
+ abortEnrichModels(reqId) {
269
+ if (reqId !== undefined) {
270
+ const ac = this.activeEnrichAbort.get(reqId);
271
+ if (ac) {
272
+ ac.abort();
273
+ this.activeEnrichAbort.delete(reqId);
274
+ }
275
+ }
276
+ else {
277
+ for (const ac of this.activeEnrichAbort.values()) {
278
+ ac.abort();
279
+ }
280
+ this.activeEnrichAbort.clear();
281
+ }
254
282
  }
255
283
  async logoutProviderOAuth(provider) {
256
284
  const providerId = provider.trim();
@@ -880,6 +908,94 @@ export class ModelAdminService {
880
908
  }
881
909
  this.host.flushSnapshot();
882
910
  }
911
+ /**
912
+ * Enrich custom-provider DRAFT rows with public catalog params
913
+ * (OpenRouter primary, models.dev secondary) — enrich_models_result.
914
+ * Nothing is saved here; the UI fills blanks from the result.
915
+ */
916
+ async enrichModels(reqId, ids, hints, lang) {
917
+ const l = lang?.() ?? "en";
918
+ const ac = new AbortController();
919
+ this.activeEnrichAbort.set(reqId, ac);
920
+ try {
921
+ const cleanIds = [...new Set((ids ?? []).map((s) => (s ?? "").trim()).filter(Boolean))].slice(0, 100);
922
+ if (cleanIds.length === 0) {
923
+ throw new Error(pick(l, "没有可补的模型 id", "No model ids to enrich", "models.enrich.empty"));
924
+ }
925
+ const cleanHints = {};
926
+ for (const [k, v] of Object.entries(hints ?? {})) {
927
+ if (k.trim() && (v ?? "").trim())
928
+ cleanHints[k.trim()] = (v ?? "").trim();
929
+ }
930
+ const results = await enrichBatch(cleanIds, cleanHints, {
931
+ lang: (l === "zh" ? "zh" : "en"),
932
+ signal: ac.signal,
933
+ onProgress: (p) => {
934
+ this.host.emit({
935
+ type: "enrich_models_progress",
936
+ reqId,
937
+ phase: p.phase,
938
+ current: p.current,
939
+ total: p.total,
940
+ message: p.message,
941
+ });
942
+ },
943
+ });
944
+ const matched = results.filter((r) => r.status === "matched").length;
945
+ const suggested = results.filter((r) => r.status === "suggested").length;
946
+ this.host.emit({ type: "enrich_models_result", reqId, ok: true, results });
947
+ this.host.emit({
948
+ type: "notice",
949
+ level: "info",
950
+ text: `🔍 已补 ${matched} 个${suggested ? `,另有 ${suggested} 个只找到相近家族(见建议)` : ""},共 ${results.length} 个`,
951
+ textEn: `🔍 Enriched ${matched}${suggested ? `, ${suggested} with family suggestions` : ""} of ${results.length}`,
952
+ });
953
+ }
954
+ catch (err) {
955
+ const isAbort = err.message === "aborted" || ac.signal.aborted;
956
+ const partial = err.partialResults;
957
+ if (isAbort) {
958
+ const hasPartial = Array.isArray(partial) && partial.length > 0;
959
+ if (hasPartial) {
960
+ const matched = partial.filter((r) => r.status === "matched").length;
961
+ this.host.emit({ type: "enrich_models_result", reqId, ok: true, results: partial });
962
+ this.host.emit({
963
+ type: "notice",
964
+ level: "warning",
965
+ text: `⏹ 已中断补参数(已保留中断前匹配的 ${matched} 个模型)`,
966
+ textEn: `⏹ Model enrichment aborted (kept ${matched} models matched before abort)`,
967
+ });
968
+ }
969
+ else {
970
+ this.host.emit({
971
+ type: "enrich_models_result",
972
+ reqId,
973
+ ok: false,
974
+ error: pick(l, "已取消补参数", "Model enrichment cancelled", "models.enrich.cancelled"),
975
+ });
976
+ this.host.emit({
977
+ type: "notice",
978
+ level: "warning",
979
+ text: pick(l, "已取消补参数", "Model enrichment cancelled", "models.enrich.cancelled"),
980
+ });
981
+ }
982
+ }
983
+ else {
984
+ const error = err.message;
985
+ this.host.emit({ type: "enrich_models_result", reqId, ok: false, error });
986
+ this.host.emit({
987
+ type: "notice",
988
+ level: "error",
989
+ text: `补参数失败:${error}`,
990
+ textEn: `Enrich failed: ${error}`,
991
+ });
992
+ }
993
+ }
994
+ finally {
995
+ this.activeEnrichAbort.delete(reqId);
996
+ }
997
+ this.host.flushSnapshot();
998
+ }
883
999
  /** Enumerate pi's built-in providers with auth capabilities and status. */
884
1000
  async listProviders() {
885
1001
  const mr = this.host.modelRuntime();
@@ -1074,9 +1190,12 @@ export class ModelAdminService {
1074
1190
  boolMeta(r.supports_vision) === true ||
1075
1191
  boolMeta(r.vision) === true ||
1076
1192
  strArrMeta(r.input)?.includes("image") === true;
1193
+ // 名字兜底(同模块级 parseOpenAiModel):只给 id 的端点靠 id 含 thinking
1194
+ // 视为思考模型,只增不减,手填合并只补缺。
1077
1195
  const reasoning = boolMeta(r.reasoning) === true ||
1078
1196
  boolMeta(r.supports_reasoning) === true ||
1079
- modalities?.includes("reasoning") === true;
1197
+ modalities?.includes("reasoning") === true ||
1198
+ id.toLowerCase().includes("thinking");
1080
1199
  const contextWindow = numMeta(r.context_window) ??
1081
1200
  numMeta(r.context_length) ??
1082
1201
  numMeta(r.max_model_len) ??
@@ -1099,9 +1218,12 @@ export class ModelAdminService {
1099
1218
  const rawName = typeof r.name === "string" ? r.name : "";
1100
1219
  const id = rawName.replace(/^models\//, "");
1101
1220
  const displayName = typeof r.displayName === "string" ? r.displayName : undefined;
1221
+ // 与 OpenAI 形状同口径的名字兜底(见模块级 parseOpenAiModel)。
1222
+ const reasoning = id.toLowerCase().includes("thinking");
1102
1223
  return {
1103
1224
  id,
1104
1225
  ...(displayName && displayName !== id ? { name: displayName } : {}),
1226
+ ...(reasoning ? { reasoning: true } : {}),
1105
1227
  ...(numMeta(r.inputTokenLimit) ? { contextWindow: numMeta(r.inputTokenLimit) } : {}),
1106
1228
  ...(numMeta(r.outputTokenLimit) ? { maxTokens: numMeta(r.outputTokenLimit) } : {}),
1107
1229
  };
@@ -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
+ }
@@ -76,5 +76,16 @@ function applyPatches() {
76
76
  " }",
77
77
  " });",
78
78
  ].join("\n"));
79
+ // 3. conpty_console_list_agent: tolerate dead shell PID (AttachConsole failed)
80
+ // when process already exited or was killed — return [] instead of crashing.
81
+ patchFile(pkgDir, "lib/conpty_console_list_agent.js", "var consoleProcessList = getConsoleProcessList(shellPid);\nprocess.send({ consoleProcessList: consoleProcessList });\nprocess.exit(0);", [
82
+ "try {",
83
+ " var consoleProcessList = getConsoleProcessList(shellPid);",
84
+ " process.send({ consoleProcessList: consoleProcessList });",
85
+ "} catch (e) {",
86
+ " process.send({ consoleProcessList: [] });",
87
+ "}",
88
+ "process.exit(0);",
89
+ ].join("\n"));
79
90
  }
80
91
  applyPatches();