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.
Files changed (54) hide show
  1. package/CHANGELOG.md +81 -1
  2. package/bin/pi-web-ui.mjs +21 -2
  3. package/dist/server/agent-service.js +1206 -106
  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 +119 -5
  7. package/dist/server/compaction-markers.js +287 -0
  8. package/dist/server/conversation-read-tool.js +369 -27
  9. package/dist/server/conversation-touches.js +357 -0
  10. package/dist/server/delegate-task.js +12 -1
  11. package/dist/server/dsh/dsh-agent-service.js +28 -1
  12. package/dist/server/file-transfer-routes.js +4 -1
  13. package/dist/server/icon-svg.js +35 -0
  14. package/dist/server/index.js +152 -34
  15. package/dist/server/model-admin.js +124 -2
  16. package/dist/server/model-enrich.js +478 -0
  17. package/dist/server/plugin-catalog.js +5 -0
  18. package/dist/server/plugin-facilities.js +42 -11
  19. package/dist/server/plugin-schedule.js +27 -2
  20. package/dist/server/plugins.js +112 -18
  21. package/dist/server/present-files-tool.js +303 -0
  22. package/dist/server/prompt-composer.js +1 -1
  23. package/dist/server/protocol-version.js +1 -1
  24. package/dist/server/read-tool.js +91 -0
  25. package/dist/server/schedule-agent-tool.js +23 -5
  26. package/dist/server/scheduler-tasks.js +60 -1
  27. package/dist/server/scm-commitmsg.js +140 -0
  28. package/dist/server/scm.js +42 -0
  29. package/dist/server/serialize.js +21 -1
  30. package/dist/server/settings-service.js +47 -0
  31. package/dist/server/skill-tool.js +120 -0
  32. package/dist/server/soft-cap.js +68 -0
  33. package/dist/server/subagent-templates.js +6 -0
  34. package/dist/server/subagents.js +157 -16
  35. package/dist/server/text-sniff.js +15 -0
  36. package/dist/server/themes.js +17 -2
  37. package/dist/server/tool-manager.js +13 -1
  38. package/package.json +2 -1
  39. package/plugin-sdk/README.md +1 -0
  40. package/plugins/catalog.json +20 -0
  41. package/themes/catppuccin-latte.css +136 -0
  42. package/themes/catppuccin.css +140 -0
  43. package/themes/nord.css +130 -0
  44. package/themes/one-dark.css +131 -0
  45. package/themes/paper.css +12 -12
  46. package/themes/solarized-light.css +131 -0
  47. package/themes/tokyo-night.css +140 -0
  48. package/web/dist/assets/{TerminalPanel-CkxM4Byl.js → TerminalPanel-RACpk6AX.js} +1 -1
  49. package/web/dist/assets/es-DIDZppIF.js +2 -0
  50. package/web/dist/assets/index-BgrnP9Jz.js +364 -0
  51. package/web/dist/assets/index-Cy8dSTZt.css +1 -0
  52. package/web/dist/index.html +2 -2
  53. package/web/dist/assets/index-DJIV4A-t.js +0 -355
  54. package/web/dist/assets/index-DLdcRBs_.css +0 -1
@@ -16,7 +16,7 @@
16
16
  * all share one conversation list per project.
17
17
  * PI_CODING_AGENT_DIR pi config dir (auth/models/skills) — passed to the SDK
18
18
  */
19
- import { existsSync, readFileSync } from "node:fs";
19
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
20
20
  import { stat } from "node:fs/promises";
21
21
  import { createServer, request as proxyRequest } from "node:http";
22
22
  import { createConnection } from "node:net";
@@ -32,7 +32,7 @@ import { PROTOCOL_VERSION } from "./protocol-version.js";
32
32
  import { AgentService, workspacePath, QuiesceRejectedError } from "./agent-service.js";
33
33
  import { WS_MAX_PAYLOAD_BYTES, isAbsoluteWirePath, wireToAbs } from "./files-service.js";
34
34
  import { registerFileTransferRoutes } from "./file-transfer-routes.js";
35
- import { previewKind } from "./text-sniff.js";
35
+ import { isAudioFile, previewKind } from "./text-sniff.js";
36
36
  import { startControlServer } from "./control-socket.js";
37
37
  import { scheduleUploadCleanup } from "./uploads.js";
38
38
  import { ensureWindowsBash, windowsBashDir } from "./ensure-bash.js";
@@ -81,6 +81,18 @@ function cliFlag(name) {
81
81
  const PORT = Number(cliFlag("--port") ?? process.env.PI_WEB_PORT ?? 8787);
82
82
  const CWD = resolve(cliFlag("--cwd") ?? process.env.PI_WEB_CWD ?? process.cwd());
83
83
  const DATA_DIR = resolve(cliFlag("--data-dir") ?? process.env.PI_WEB_DATA_DIR ?? join(homedir(), ".pi-web"));
84
+ // The data dir is where the control socket, client state, plugins, themes and
85
+ // uploads live, but nothing guarantees it exists on a first run (a fresh
86
+ // `server install` never creates it). The control socket binds at startup —
87
+ // before any store gets a chance to mkdir its own subdirectory — and bind()
88
+ // into a missing directory fails with EACCES, silently disabling the whole
89
+ // control channel (status/quiesce) for that process. Create it up front.
90
+ try {
91
+ mkdirSync(DATA_DIR, { recursive: true });
92
+ }
93
+ catch (err) {
94
+ console.warn(`[data] 无法创建数据目录 ${DATA_DIR}: ${err.message}`);
95
+ }
84
96
  // Dev-no-cache setting read without a ClientStateStore instance (the index.html
85
97
  // route runs before any client attaches). Reads the same global settings blob.
86
98
  function readDevNoCacheSetting() {
@@ -212,6 +224,16 @@ function cookieToken(req) {
212
224
  }
213
225
  return "";
214
226
  }
227
+ /** Express 5 命名通配 `*splat` 的取值:单段是字符串,多段是字符串数组
228
+ * (issue #225:直接 String() 会把多段用逗号拼成 "a,b.mjs",插件 vendor 子
229
+ * 目录、嵌套文件预览、插件子路径 API 全 404)。统一拼回 "/" 即得 Express 4
230
+ * 语义;下游既有的越界/包含校验(workspacePath / resolvePluginClientFile)不变。 */
231
+ function splatParam(req) {
232
+ const v = req.params.splat;
233
+ if (Array.isArray(v))
234
+ return v.join("/");
235
+ return String(v ?? "");
236
+ }
215
237
  if (AUTH_TOKEN) {
216
238
  // /api/health 保持开放:无敏感信息,容器/监控探针需要它。
217
239
  // 但绝不能因命中 /api/health 就反射下发真实 token cookie(安全漏洞:issue #45)。
@@ -269,7 +291,9 @@ app.get("/api/health", (_req, res) => {
269
291
  *
270
292
  * Media preview (no download param): only image/video kinds are served —
271
293
  * text goes over the WebSocket, and exe/jar/etc. are never exposed here.
272
- * express's sendFile handles Range requests, so video seeking works.
294
+ * Audio files (isAudioFile: browser-playable containers only) are allowed too
295
+ * so present_files cards and the preview dialog can inline-play them.
296
+ * express's sendFile handles Range requests, so video/audio seeking works.
273
297
  *
274
298
  * Download (?download=1): any file kind is served with
275
299
  * Content-Disposition: attachment so the browser saves it instead of
@@ -306,7 +330,8 @@ app.get("/api/file", async (req, res) => {
306
330
  // allowlist them explicitly here.
307
331
  const lower = name.toLowerCase();
308
332
  const isHtmlPreview = lower.endsWith(".html") || lower.endsWith(".htm") || lower.endsWith(".xhtml");
309
- if (!isDownload && kind !== "image" && kind !== "video" && !isHtmlPreview) {
333
+ const isAudioPreview = isAudioFile(name);
334
+ if (!isDownload && kind !== "image" && kind !== "video" && !isHtmlPreview && !isAudioPreview) {
310
335
  res.status(400).end("not a previewable media file");
311
336
  return;
312
337
  }
@@ -318,7 +343,10 @@ app.get("/api/file", async (req, res) => {
318
343
  if (isDownload) {
319
344
  // res.download sets Content-Disposition: attachment and RFC 5987
320
345
  // filename* encoding for non-ASCII names.
321
- res.download(abs, name);
346
+ // dotfiles: allow — issue #223:Express 5 的 send 默认 dotfiles=ignore,
347
+ // 工作区/数据目录常位于隐藏目录下(如 ~/.pi-web),绝对路径含点号段会被判 404。
348
+ // 路径已由上方的 workspacePath/isAbsoluteWirePath 做工作区 containment 校验,放行安全。
349
+ res.download(abs, name, { dotfiles: "allow" });
322
350
  }
323
351
  else {
324
352
  if (isHtmlPreview) {
@@ -335,7 +363,7 @@ app.get("/api/file", async (req, res) => {
335
363
  res.setHeader("Content-Security-Policy", allowJs ? "sandbox allow-scripts" : "sandbox");
336
364
  res.setHeader("X-Content-Type-Options", "nosniff");
337
365
  }
338
- res.sendFile(abs);
366
+ res.sendFile(abs, { dotfiles: "allow" });
339
367
  }
340
368
  }
341
369
  catch {
@@ -360,8 +388,8 @@ app.get("/api/file", async (req, res) => {
360
388
  */
361
389
  app.get("/api/preview/*splat", async (req, res) => {
362
390
  try {
363
- // SAFETY: Express 5 命名通配 *splat 落在 req.params.splat(Express 4 是 params[0])。
364
- const captured = String(req.params.splat ?? "");
391
+ // 多段路径的 splat 是数组(见 splatParam),拼回 "/" 后才是线形路径。
392
+ const captured = splatParam(req);
365
393
  const ABS_MARKER = "__abs__/";
366
394
  const cid = typeof req.query.clientId === "string" ? req.query.clientId : "";
367
395
  const cs = cid ? service.get(cid) : undefined;
@@ -399,7 +427,7 @@ app.get("/api/preview/*splat", async (req, res) => {
399
427
  const allowJs = req.query.allowJs === "1";
400
428
  res.setHeader("Content-Security-Policy", allowJs ? "sandbox allow-scripts" : "sandbox");
401
429
  }
402
- res.sendFile(abs);
430
+ res.sendFile(abs, { dotfiles: "allow" });
403
431
  }
404
432
  catch {
405
433
  res.status(404).end("not found");
@@ -498,7 +526,10 @@ app.get("/themes/:id.css", (req, res) => {
498
526
  }
499
527
  res.setHeader("Content-Type", "text/css; charset=utf-8");
500
528
  res.setHeader("Cache-Control", "no-cache");
501
- res.sendFile(file);
529
+ // dotfiles: allow — issue #223:Express 5 的 send 默认 dotfiles=ignore,主题文件位于
530
+ // 隐藏目录下(如 ~/.pi-web/themes、npm 全局目录 ~/.local/…)时会被判 404。路径已由
531
+ // resolveThemeFile 校验(id 白名单 + 仅已知目录 + isFile),放行安全。
532
+ res.sendFile(file, { dotfiles: "allow" });
502
533
  });
503
534
  // Plugin client bundles: <dataDir>/plugins/<id>/client/* served at
504
535
  // /plugins/<id>/client/* so the frontend can import() plugin views. Only the
@@ -510,8 +541,8 @@ const PLUGINS_DIR = join(DATA_DIR, "plugins");
510
541
  // /plugins-api/<id>/inbox。PI_WEB_TOKEN 鉴权(上方 app.use)自动覆盖;
511
542
  // 响应已在前面过了 express.json。注意不要在此 catch-all 里消费 body。
512
543
  app.all(["/plugins-api/:id/*splat", "/plugins-api/:id"], (req, res) => {
513
- // SAFETY: Express 5 命名通配 *splat 落在 req.params.splat(Express 4 是 params[0])。
514
- const rest = String(req.params.splat ?? "");
544
+ // 多段子路径的 splat 是数组(见 splatParam),拼回 "/" 后再交插件路由。
545
+ const rest = splatParam(req);
515
546
  pluginMgr.handleHttp(String(req.params.id ?? ""), req.method, rest, req, res);
516
547
  });
517
548
  function proxyForwardPath(hit, url) {
@@ -584,8 +615,8 @@ app.use((req, res, next) => {
584
615
  proxyHttp(hit, req, res);
585
616
  });
586
617
  app.get("/plugins/:id/client/*splat", (req, res) => {
587
- // SAFETY: Express 5 命名通配 *splat 落在 req.params.splat(Express 4 是 params[0])。
588
- const rest = String(req.params.splat ?? "");
618
+ // 多段子路径(插件 vendor/分包)的 splat 是数组(见 splatParam),拼回 "/"。
619
+ const rest = splatParam(req);
589
620
  // 特权 DOM 门禁:声明了 dom 能力的插件,其 bundle 需用户逐个授权后才下发
590
621
  // (同源 bundle 技术上拦不住 DOM 访问,门只能放在这里;见 server/plugin-dom.ts)。
591
622
  if (pluginMgr.isDomBundleBlocked(String(req.params.id ?? ""))) {
@@ -602,7 +633,8 @@ app.get("/plugins/:id/client/*splat", (req, res) => {
602
633
  res.setHeader("Content-Type", "text/javascript; charset=utf-8");
603
634
  }
604
635
  res.setHeader("Cache-Control", "no-cache"); // 开发期改文件即生效
605
- res.sendFile(abs, (err) => {
636
+ // dotfiles: allow — issue #223:插件目录默认在 ~/.pi-web/plugins(隐藏目录段),同上需放行。
637
+ res.sendFile(abs, { dotfiles: "allow" }, (err) => {
606
638
  if (err && !res.headersSent)
607
639
  res
608
640
  .status(err.statusCode === 404 ? 404 : 500)
@@ -659,7 +691,8 @@ if (existsSync(webDist)) {
659
691
  const stored = readDevNoCacheSetting();
660
692
  const noStore = stored ?? envDefault;
661
693
  res.setHeader("Cache-Control", noStore ? "no-store" : "public, max-age=0");
662
- res.sendFile(join(webDist, "index.html"), (err) => {
694
+ // dotfiles: allow — issue #223:nvm 等安装路径本身在隐藏目录下(如 ~/.nvm/…),同上需放行。
695
+ res.sendFile(join(webDist, "index.html"), { dotfiles: "allow" }, (err) => {
663
696
  if (err && !res.headersSent) {
664
697
  res.status(503).send("正在更新 pi-web-ui,请稍后刷新…");
665
698
  }
@@ -967,7 +1000,8 @@ pluginMgr.permissionRequester = (pluginId, req) => new Promise((resolve) => {
967
1000
  pluginMgr.onPermGrantsChanged = () => pushPluginPermissions();
968
1001
  // 内置定时任务(issue #184):全局 <dataDir>/scheduler-tasks.json,TTL 与
969
1002
  // client-state 同级;Agent 工具建的任务优先唤醒发起对话(issue #193:
970
- // wakeConversation steer 投递,不切用户当前对话),对话不在了再回落无头伪
1003
+ // wakeConversation steer 投递,不切用户当前对话),原对话不在先回落同项目
1004
+ // 活跃对话(issue #231:视口兜底+自动重绑定+明确降级提示),都没有才无头伪
971
1005
  // 客户端(chatFromScheduler);单次任务触发后自动删除。DSH 引擎无这俩方法时
972
1006
  // executor 回 not-supported(历史里记失败,不炸进程)。
973
1007
  const scheduler = new SchedulerStore(DATA_DIR, {
@@ -979,31 +1013,83 @@ const scheduler = new SchedulerStore(DATA_DIR, {
979
1013
  result = { ok: false, error: "当前引擎不支持定时任务(仅标准 pi 引擎)" };
980
1014
  }
981
1015
  else {
982
- // 发起对话还在 → steer 唤醒它(报告直接落原对话);不在了 → 无头执行。
1016
+ const runHeadless = () => svc.chatFromScheduler({
1017
+ id: task.id,
1018
+ cwd: task.cwd,
1019
+ prompt: task.prompt,
1020
+ model: task.model,
1021
+ thinkingLevel: task.thinkingLevel,
1022
+ });
983
1023
  const target = String(task.conversationId ?? "").trim();
984
- if (target && typeof svc.wakeConversation === "function") {
985
- const w = await svc.wakeConversation(target, `[定时任务 ${task.name}] ${task.prompt}`);
1024
+ const taskFile = String(task.sessionFile ?? "").trim();
1025
+ const text = `[定时任务 ${task.name}] ${task.prompt}`;
1026
+ if (!target && !taskFile) {
1027
+ // 面板建的任务:创建时就没绑对话,保持无头语义(不抢占用户视口)。
1028
+ result = await runHeadless();
1029
+ }
1030
+ else if (typeof svc.wakeConversation === "function") {
1031
+ // 原绑定对话还在(含压缩/重启后按会话文件重认)→ steer 唤醒,报告落原对话。
1032
+ let w;
1033
+ try {
1034
+ w = await svc.wakeConversation(target, text, { sessionFile: taskFile, cwd: task.cwd });
1035
+ }
1036
+ catch (err) {
1037
+ w = { ok: false, error: err.message };
1038
+ }
986
1039
  if (w.ok) {
1040
+ // 会话继承(issue #231):压缩/重启后同文件对话换了新 id → 任务跟过去,
1041
+ // 下次触发直达,不再误判 closed/gone。单次任务随后自删,免一次写盘。
1042
+ if (!task.oneShot) {
1043
+ try {
1044
+ if (w.conversationId && w.conversationId !== target)
1045
+ scheduler.rebind(task.id, { conversationId: w.conversationId });
1046
+ if (w.sessionFile && w.sessionFile !== taskFile)
1047
+ scheduler.rebind(task.id, { sessionFile: w.sessionFile });
1048
+ }
1049
+ catch {
1050
+ // 重绑失败不影响本次已投递的唤醒
1051
+ }
1052
+ }
987
1053
  result = { ok: true, conversationId: w.conversationId };
988
1054
  }
1055
+ else if (typeof svc.wakeViewportInCwd === "function") {
1056
+ // 活跃视口兜底(issue #231):原句柄断开(切走释放/过户改名/重启),
1057
+ // 只要同项目还有用户正看着的对话,报告落那里 —— 不静默吞结果。
1058
+ const fallbackText = `[定时任务 ${task.name}|原对话不在,已转到本窗口继续] ${task.prompt}`;
1059
+ let f;
1060
+ try {
1061
+ f = await svc.wakeViewportInCwd(task.cwd, fallbackText);
1062
+ }
1063
+ catch (err) {
1064
+ f = { ok: false, error: err.message };
1065
+ }
1066
+ if (f.ok) {
1067
+ if (!task.oneShot) {
1068
+ try {
1069
+ scheduler.rebind(task.id, {
1070
+ conversationId: f.conversationId ?? "",
1071
+ sessionFile: f.sessionFile ?? "",
1072
+ });
1073
+ }
1074
+ catch {
1075
+ // 重绑失败不影响本次已投递的唤醒
1076
+ }
1077
+ }
1078
+ pushNoticeToAll("info", `定时任务「${task.name}」原对话不在,已转到同项目的活跃对话继续(原绑定 ${target || "(未知)"})。报告直接落在当前对话。`, `Scheduled task "${task.name}" moved to the project's active conversation (was ${target || "unknown"}). The report lands in the current chat.`);
1079
+ result = { ok: true, conversationId: f.conversationId };
1080
+ }
1081
+ else {
1082
+ // 降级可见性(issue #231):必须无头时明确广播去向,不静默。
1083
+ pushNoticeToAll("warning", `定时任务「${task.name}」原对话不在、同项目也无存活对话,已转后台执行(无头)。报告在后台任务面板的调度历史中查看。`, `Scheduled task "${task.name}" found no live conversation and runs headless; see its report in the background-tasks panel history.`);
1084
+ result = await runHeadless();
1085
+ }
1086
+ }
989
1087
  else {
990
- result = await svc.chatFromScheduler({
991
- id: task.id,
992
- cwd: task.cwd,
993
- prompt: task.prompt,
994
- model: task.model,
995
- thinkingLevel: task.thinkingLevel,
996
- });
1088
+ result = await runHeadless();
997
1089
  }
998
1090
  }
999
1091
  else {
1000
- result = await svc.chatFromScheduler({
1001
- id: task.id,
1002
- cwd: task.cwd,
1003
- prompt: task.prompt,
1004
- model: task.model,
1005
- thinkingLevel: task.thinkingLevel,
1006
- });
1092
+ result = await runHeadless();
1007
1093
  }
1008
1094
  }
1009
1095
  }
@@ -1489,6 +1575,22 @@ wss.on("connection", (ws) => {
1489
1575
  case "scm_commit":
1490
1576
  void cs.scmQuery("commit", msg.reqId, { hash: msg.hash });
1491
1577
  break;
1578
+ case "scm_commitmsg":
1579
+ if (typeof cs.scmGenCommitMessage === "function") {
1580
+ void cs.scmGenCommitMessage(msg.reqId);
1581
+ }
1582
+ else {
1583
+ // DSH 等引擎没有 pi 的 ModelRuntime——照样应答一次(ok:false),
1584
+ // 前端按钮不能因引擎差异卡在转圈。
1585
+ send({
1586
+ type: "scm_data",
1587
+ reqId: msg.reqId,
1588
+ kind: "commitmsg",
1589
+ ok: false,
1590
+ error: "当前引擎不支持 AI 生成提交信息(请用 pi 引擎)/ AI commit messages need the pi engine",
1591
+ });
1592
+ }
1593
+ break;
1492
1594
  case "read_file":
1493
1595
  void cs.readFile(msg.path);
1494
1596
  break;
@@ -1522,6 +1624,12 @@ wss.on("connection", (ws) => {
1522
1624
  case "set_model":
1523
1625
  void cs.setModel(msg.modelId);
1524
1626
  break;
1627
+ case "set_default_model":
1628
+ void cs.setDefaultModel?.(msg.modelId);
1629
+ break;
1630
+ case "clear_default_model":
1631
+ cs.clearDefaultModel?.();
1632
+ break;
1525
1633
  case "set_thinking":
1526
1634
  cs.setThinking(msg.level);
1527
1635
  break;
@@ -1640,6 +1748,12 @@ wss.on("connection", (ws) => {
1640
1748
  case "clone_provider":
1641
1749
  void cs.cloneProvider(msg.provider, msg.reqId);
1642
1750
  break;
1751
+ case "enrich_models":
1752
+ void cs.enrichModels(msg.reqId, msg.ids, msg.hints);
1753
+ break;
1754
+ case "abort_enrich_models":
1755
+ cs.abortEnrichModels(msg.reqId);
1756
+ break;
1643
1757
  case "list_provider_keys":
1644
1758
  cs.listProviderKeys();
1645
1759
  break;
@@ -1727,8 +1841,10 @@ wss.on("connection", (ws) => {
1727
1841
  terminalToolsEnabled: msg.terminalToolsEnabled,
1728
1842
  terminalBash: msg.terminalBash,
1729
1843
  terminalBashIdleMs: msg.terminalBashIdleMs,
1844
+ readDirEnabled: msg.readDirEnabled,
1730
1845
  editSoftEnabled: msg.editSoftEnabled,
1731
1846
  questionnaireEnabled: msg.questionnaireEnabled,
1847
+ parallelReminderEnabled: msg.parallelReminderEnabled,
1732
1848
  goalModeEnabled: msg.goalModeEnabled,
1733
1849
  thinkingWrap: msg.thinkingWrap,
1734
1850
  toolsWrap: msg.toolsWrap,
@@ -1741,6 +1857,8 @@ wss.on("connection", (ws) => {
1741
1857
  visionBridgePrompt: msg.visionBridgePrompt,
1742
1858
  subagentDefaultModel: msg.subagentDefaultModel,
1743
1859
  retryMaxAttempts: msg.retryMaxAttempts,
1860
+ softCapTokens: msg.softCapTokens,
1861
+ softCapByModel: msg.softCapByModel,
1744
1862
  reviewPrompt: msg.reviewPrompt,
1745
1863
  reviewDisabledSkills: msg.reviewDisabledSkills,
1746
1864
  markersEnabled: msg.markersEnabled,
@@ -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
  };