pi-web-ui 0.62.0 → 0.63.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 (56) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +504 -497
  3. package/README.zh-CN.md +427 -422
  4. package/bin/pi-web-ui.mjs +1809 -1881
  5. package/deploy/com.xingshuyin.pi-web-ui.plist +48 -48
  6. package/deploy/nginx-subpath.conf +88 -88
  7. package/deploy/pi-web-ui-task.xml +54 -54
  8. package/deploy/pi-web-ui.service +31 -31
  9. package/dist/server/agent-service.js +318 -88
  10. package/dist/server/attachments.js +37 -28
  11. package/dist/server/bg-servers.js +6 -1
  12. package/dist/server/client-state.js +15 -7
  13. package/dist/server/control-socket.js +1 -5
  14. package/dist/server/dsh/dsh-agent-service.js +454 -118
  15. package/dist/server/dsh/dsh-client.js +1 -3
  16. package/dist/server/dsh/dsh-sessions.js +4 -14
  17. package/dist/server/dsh/runtime/cordis.yml +1 -1
  18. package/dist/server/dsh/runtime/goal-rpc.mjs +645 -662
  19. package/dist/server/dsh/runtime/launcher.mjs +8 -18
  20. package/dist/server/dsh/runtime/override.patch.yml +9 -9
  21. package/dist/server/dsh/runtime/runtime-root.mjs +7 -11
  22. package/dist/server/ensure-bash.js +2 -5
  23. package/dist/server/files-service.js +42 -36
  24. package/dist/server/goal-service.js +59 -30
  25. package/dist/server/index.js +17 -10
  26. package/dist/server/mcp-bridge.js +11 -5
  27. package/dist/server/model-admin.js +78 -72
  28. package/dist/server/plugin-facilities.js +2 -4
  29. package/dist/server/plugin-updater.js +1 -1
  30. package/dist/server/plugins.js +26 -8
  31. package/dist/server/process-utils.js +4 -4
  32. package/dist/server/protocol-version.js +1 -1
  33. package/dist/server/scm.js +27 -21
  34. package/dist/server/serialize.js +1 -3
  35. package/dist/server/settings-service.js +63 -21
  36. package/dist/server/slash-commands.js +29 -3
  37. package/dist/server/subagent-templates.js +249 -0
  38. package/dist/server/subagents.js +150 -0
  39. package/dist/server/terminals.js +101 -40
  40. package/dist/server/text-sniff.js +4 -24
  41. package/dist/server/update-check.js +2 -4
  42. package/dist/server/uploads.js +1 -3
  43. package/dist/server/vision-bridge.js +2 -7
  44. package/dist/server/wait-subscription-scan.js +102 -21
  45. package/dist/server/webui-context.js +1 -1
  46. package/extensions/webui.ts +190 -192
  47. package/package.json +109 -103
  48. package/web/dist/assets/TerminalPanel-B9vcI7-_.js +2 -0
  49. package/web/dist/assets/index-B8Wv29p5.css +10 -0
  50. package/web/dist/assets/index-CWUWD0rq.js +323 -0
  51. package/web/dist/favicon.svg +8 -8
  52. package/web/dist/index.html +15 -15
  53. package/web/public/favicon.svg +8 -8
  54. package/web/dist/assets/TerminalPanel-BkCWDsIG.js +0 -2
  55. package/web/dist/assets/index-BRVKed7W.js +0 -321
  56. package/web/dist/assets/index-ORTpR6ZQ.css +0 -10
@@ -1,6 +1,6 @@
1
- import { countLines, decodeText, looksLikeText, sniffImageMime, } from "./text-sniff.js";
1
+ import { countLines, decodeText, looksLikeText, sniffImageMime } from "./text-sniff.js";
2
2
  import { saveUpload, uploadsRoot } from "./uploads.js";
3
- import { buildVisionBridgePrompt, findVisionModels, transcribeImages, } from "./vision-bridge.js";
3
+ import { buildVisionBridgePrompt, findVisionModels, transcribeImages } from "./vision-bridge.js";
4
4
  /** 跨快照的视觉转写缓存:批次 hash(名称 + base64 头 + 提示词)→ 转写文本。
5
5
  * 编辑重问重发相同图片不再重复耗视觉 token。进程级共享即可。 */
6
6
  const visionBridgeCache = new Map();
@@ -17,21 +17,13 @@ export async function buildAttachmentMessages(ctx, attachments) {
17
17
  if (!attachments || attachments.length === 0)
18
18
  return [];
19
19
  const fs = await import("node:fs/promises");
20
- const { resolve, sep, relative, extname, join, basename } = await import("node:path");
20
+ const { resolve, sep, relative, extname, basename } = await import("node:path");
21
21
  const root = resolve(ctx.cwd);
22
22
  const MAX_ATTACHMENT_BYTES = 200 * 1024;
23
23
  // Files at or below this size are inlined; larger files are referenced by
24
24
  // path only (the model reads them on demand — saves tokens for small edits).
25
25
  const MAX_INLINE_BYTES = Number(process.env.PI_WEB_INLINE_FILE_MAX ?? 12 * 1024);
26
- const IMAGE_EXT = new Set([
27
- ".png",
28
- ".jpg",
29
- ".jpeg",
30
- ".gif",
31
- ".webp",
32
- ".bmp",
33
- ".svg",
34
- ]);
26
+ const IMAGE_EXT = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg"]);
35
27
  const MIME = {
36
28
  ".png": "image/png",
37
29
  ".jpg": "image/jpeg",
@@ -113,9 +105,7 @@ export async function buildAttachmentMessages(ctx, attachments) {
113
105
  for (const [idx, att] of attachments.entries()) {
114
106
  if (att.imageData) {
115
107
  const raw = att.imageData.replace(/^data:[^;]*;base64,/, "");
116
- const mimeType = att.mimeType?.startsWith("image/")
117
- ? att.mimeType
118
- : "image/png";
108
+ const mimeType = att.mimeType?.startsWith("image/") ? att.mimeType : "image/png";
119
109
  const bytes = Buffer.byteLength(raw, "base64");
120
110
  // Only images that would actually be sent (non-empty, under the cap).
121
111
  if (bytes > 0 && bytes <= 2 * 1024 * 1024) {
@@ -162,6 +152,7 @@ export async function buildAttachmentMessages(ctx, attachments) {
162
152
  type: "notice",
163
153
  level: "warning",
164
154
  text: `当前模型(${mainModel?.name ?? mainModel?.id ?? "未知"})不支持识图,且视觉桥已在设置中关闭:图片将原样发送、可能被忽略。`,
155
+ textEn: `Current model (${mainModel?.name ?? mainModel?.id ?? "unknown"}) cannot see images and the vision bridge is off in settings: images will be sent as-is and may be ignored.`,
165
156
  });
166
157
  }
167
158
  else {
@@ -188,6 +179,7 @@ export async function buildAttachmentMessages(ctx, attachments) {
188
179
  type: "notice",
189
180
  level: "warning",
190
181
  text: `当前模型(${mainModel?.name ?? mainModel?.id ?? "未知"})不支持识图,且未找到可用的视觉模型:图片将原样发送、可能被忽略。在模型配置里添加任意支持图片的模型(如 qwen-vl、GLM-4V、Gemini)即可自动启用视觉桥转写。`,
182
+ textEn: `Current model (${mainModel?.name ?? mainModel?.id ?? "unknown"}) cannot see images and no vision model is available: images will be sent as-is and may be ignored. Add any vision-capable model (e.g. qwen-vl, GLM-4V, Gemini) in model settings to enable vision-bridge transcription.`,
191
183
  });
192
184
  }
193
185
  else {
@@ -196,9 +188,7 @@ export async function buildAttachmentMessages(ctx, attachments) {
196
188
  // The active transcription prompt is part of the key: changing
197
189
  // the custom prompt must invalidate cached transcripts made with
198
190
  // the old prompt.
199
- const batchHash = bridgedImages
200
- .map((b) => `${b.att.name ?? "img"}:${b.raw.slice(0, 48)}`)
201
- .join("|") +
191
+ const batchHash = bridgedImages.map((b) => `${b.att.name ?? "img"}:${b.raw.slice(0, 48)}`).join("|") +
202
192
  "::" +
203
193
  buildVisionBridgePrompt(ctx.settings.visionBridgePromptMode, ctx.settings.visionBridgePrompt);
204
194
  let transcript = visionBridgeCache.get(batchHash);
@@ -207,6 +197,7 @@ export async function buildAttachmentMessages(ctx, attachments) {
207
197
  type: "notice",
208
198
  level: "info",
209
199
  text: `当前模型不支持识图,正在用视觉桥(${chosen.label})转写 ${bridgedImages.length} 张图片…`,
200
+ textEn: `Current model cannot see images; transcribing ${bridgedImages.length} image(s) via the vision bridge (${chosen.label})…`,
210
201
  });
211
202
  try {
212
203
  const chosenModel = ctx.session.modelRuntime.getModel(chosen.provider, chosen.id);
@@ -223,6 +214,7 @@ export async function buildAttachmentMessages(ctx, attachments) {
223
214
  type: "notice",
224
215
  level: "info",
225
216
  text: `✅ 图片已由视觉桥转写完成(${chosen.label})`,
217
+ textEn: `✅ Images transcribed by the vision bridge (${chosen.label})`,
226
218
  });
227
219
  }
228
220
  catch (err) {
@@ -231,6 +223,7 @@ export async function buildAttachmentMessages(ctx, attachments) {
231
223
  type: "notice",
232
224
  level: "error",
233
225
  text: `图片转写失败(${chosen.label}):${err.message}。图片将原样发送、可能被忽略。`,
226
+ textEn: `Image transcription failed (${chosen.label}): ${err.message}. Images will be sent as-is and may be ignored.`,
234
227
  });
235
228
  }
236
229
  }
@@ -254,6 +247,7 @@ export async function buildAttachmentMessages(ctx, attachments) {
254
247
  type: "notice",
255
248
  level: "error",
256
249
  text: `图片数据为空,已跳过`,
250
+ textEn: `Image data is empty, skipped`,
257
251
  });
258
252
  continue;
259
253
  }
@@ -262,6 +256,7 @@ export async function buildAttachmentMessages(ctx, attachments) {
262
256
  type: "notice",
263
257
  level: "warning",
264
258
  text: `图片过大已跳过(>2MB):${att.name ?? "粘贴图片"}`,
259
+ textEn: `Image too large, skipped (>2MB): ${att.name ?? "pasted image"}`,
265
260
  });
266
261
  continue;
267
262
  }
@@ -321,6 +316,7 @@ export async function buildAttachmentMessages(ctx, attachments) {
321
316
  type: "notice",
322
317
  level: "error",
323
318
  text: `文件数据为空,已跳过`,
319
+ textEn: `File data is empty, skipped`,
324
320
  });
325
321
  continue;
326
322
  }
@@ -329,6 +325,7 @@ export async function buildAttachmentMessages(ctx, attachments) {
329
325
  type: "notice",
330
326
  level: "warning",
331
327
  text: `文件过大已跳过(>20MB):${att.name ?? "上传文件"}`,
328
+ textEn: `File too large, skipped (>20MB): ${att.name ?? "uploaded file"}`,
332
329
  });
333
330
  continue;
334
331
  }
@@ -356,13 +353,13 @@ export async function buildAttachmentMessages(ctx, attachments) {
356
353
  const relToRoot = relative(rootDir, abs);
357
354
  const inClientDir = !relToRoot.startsWith("..") &&
358
355
  !relToRoot.includes(`${sep}..`) &&
359
- (relToRoot === ctx.clientId ||
360
- relToRoot.startsWith(`${ctx.clientId}${sep}`));
356
+ (relToRoot === ctx.clientId || relToRoot.startsWith(`${ctx.clientId}${sep}`));
361
357
  if (!inClientDir) {
362
358
  ctx.emit({
363
359
  type: "notice",
364
360
  level: "warning",
365
361
  text: `无法恢复已上传文件(路径不在本客户端上传目录):${att.name ?? att.uploadPath}`,
362
+ textEn: `Cannot restore uploaded file (outside this client upload dir): ${att.name ?? att.uploadPath}`,
366
363
  });
367
364
  continue;
368
365
  }
@@ -375,6 +372,7 @@ export async function buildAttachmentMessages(ctx, attachments) {
375
372
  type: "notice",
376
373
  level: "warning",
377
374
  text: `无法恢复已上传文件(已被清理或不可读):${att.name ?? att.uploadPath}`,
375
+ textEn: `Cannot restore uploaded file (cleaned up or unreadable): ${att.name ?? att.uploadPath}`,
378
376
  });
379
377
  continue;
380
378
  }
@@ -390,6 +388,7 @@ export async function buildAttachmentMessages(ctx, attachments) {
390
388
  type: "notice",
391
389
  level: "warning",
392
390
  text: `附件路径超出工作区:${att.path}`,
391
+ textEn: `Attachment path is outside the workspace: ${att.path}`,
393
392
  });
394
393
  continue;
395
394
  }
@@ -405,6 +404,7 @@ export async function buildAttachmentMessages(ctx, attachments) {
405
404
  type: "notice",
406
405
  level: "error",
407
406
  text: `附件不存在:${att.path}`,
407
+ textEn: `Attachment does not exist: ${att.path}`,
408
408
  });
409
409
  continue;
410
410
  }
@@ -432,6 +432,7 @@ export async function buildAttachmentMessages(ctx, attachments) {
432
432
  type: "notice",
433
433
  level: "warning",
434
434
  text: `跳过非文件附件:${att.path}`,
435
+ textEn: `Skipped non-file attachment: ${att.path}`,
435
436
  });
436
437
  continue;
437
438
  }
@@ -448,17 +449,19 @@ export async function buildAttachmentMessages(ctx, attachments) {
448
449
  content: [
449
450
  {
450
451
  type: "text",
451
- text: `
452
- <vision-bridge>
453
- ${transcript}
452
+ text: `
453
+ <vision-bridge>
454
+ ${transcript}
454
455
  </vision-bridge>`,
455
456
  },
456
457
  ...(pathImg
457
- ? ([{
458
+ ? [
459
+ {
458
460
  type: "image",
459
461
  data: pathImg.raw,
460
462
  mimeType: pathImg.mimeType,
461
- }])
463
+ },
464
+ ]
462
465
  : []),
463
466
  ],
464
467
  display: true,
@@ -498,6 +501,7 @@ ${transcript}
498
501
  type: "notice",
499
502
  level: "warning",
500
503
  text: `图片附件过大已跳过(>200KB):${att.path}`,
504
+ textEn: `Image attachment too large, skipped (>200KB): ${att.path}`,
501
505
  });
502
506
  continue;
503
507
  }
@@ -505,9 +509,7 @@ ${transcript}
505
509
  out.push({
506
510
  message: {
507
511
  customType: "file",
508
- content: [
509
- { type: "image", data, mimeType: MIME[ext] ?? "image/png" },
510
- ],
512
+ content: [{ type: "image", data, mimeType: MIME[ext] ?? "image/png" }],
511
513
  display: true,
512
514
  details: { name, path: rel, mode: "image", size: stat.size },
513
515
  },
@@ -564,6 +566,7 @@ ${transcript}
564
566
  type: "notice",
565
567
  level: "warning",
566
568
  text: `行范围无效,已改为仅引用:${att.path}`,
569
+ textEn: `Invalid line range, switched to reference-only: ${att.path}`,
567
570
  });
568
571
  out.push(makeReference());
569
572
  continue;
@@ -573,6 +576,7 @@ ${transcript}
573
576
  type: "notice",
574
577
  level: "warning",
575
578
  text: `文件过大,已改为仅引用:${att.path}`,
579
+ textEn: `File too large, switched to reference-only: ${att.path}`,
576
580
  });
577
581
  out.push(makeReference());
578
582
  continue;
@@ -583,6 +587,7 @@ ${transcript}
583
587
  type: "notice",
584
588
  level: "warning",
585
589
  text: `二进制文件已改为仅引用:${att.path}`,
590
+ textEn: `Binary file, switched to reference-only: ${att.path}`,
586
591
  });
587
592
  out.push(makeReference());
588
593
  continue;
@@ -599,6 +604,7 @@ ${transcript}
599
604
  type: "notice",
600
605
  level: "warning",
601
606
  text: `选中行超出文件范围,已改为仅引用:${att.path}`,
607
+ textEn: `Selected lines out of range, switched to reference-only: ${att.path}`,
602
608
  });
603
609
  out.push(makeReference());
604
610
  continue;
@@ -634,6 +640,7 @@ ${transcript}
634
640
  type: "notice",
635
641
  level: "warning",
636
642
  text: `文件过大,已改为仅引用:${att.path}`,
643
+ textEn: `File too large, switched to reference-only: ${att.path}`,
637
644
  });
638
645
  out.push(makeReference());
639
646
  continue;
@@ -644,6 +651,7 @@ ${transcript}
644
651
  type: "notice",
645
652
  level: "warning",
646
653
  text: `二进制文件已改为仅引用:${att.path}`,
654
+ textEn: `Binary file, switched to reference-only: ${att.path}`,
647
655
  });
648
656
  out.push(makeReference());
649
657
  continue;
@@ -662,6 +670,7 @@ ${transcript}
662
670
  type: "notice",
663
671
  level: "warning",
664
672
  text: `二进制文件已跳过(仅引用路径):${att.path}`,
673
+ textEn: `Binary file skipped (path referenced only): ${att.path}`,
665
674
  });
666
675
  out.push(makeReference());
667
676
  continue;
@@ -1,4 +1,4 @@
1
- import { killPidTree, lookupProcessName, lookupProcessCommandLine, snapshotListeningPorts, } from "./process-utils.js";
1
+ import { killPidTree, lookupProcessName, lookupProcessCommandLine, snapshotListeningPorts } from "./process-utils.js";
2
2
  const BG_REFRESH_INTERVAL_MS = 30_000;
3
3
  /** bash 结束后等这么久再拍「后」快照——给后台服务绑定端口的时间。 */
4
4
  const BG_BIND_WAIT_MS = 1500;
@@ -63,6 +63,7 @@ export class BgServerTracker {
63
63
  type: "notice",
64
64
  level: "info",
65
65
  text: `检测到 AI 启动的后台服务:端口 ${port}(pid ${pid})——可在顶栏「后台任务」里单独停止或全部关闭`,
66
+ textEn: `Detected an AI-started background service: port ${port} (pid ${pid}) — stop it individually or all at once under Background tasks in the top bar`,
66
67
  });
67
68
  }
68
69
  }
@@ -97,6 +98,7 @@ export class BgServerTracker {
97
98
  return;
98
99
  const now = await snapshotListeningPorts();
99
100
  let changed = false;
101
+ // eslint-disable-next-line unicorn/no-useless-spread -- snapshot: handlers may unsubscribe mid-emit
100
102
  for (const [port, v] of [...this.servers]) {
101
103
  if (now.get(port) !== v.pid) {
102
104
  this.servers.delete(port);
@@ -119,6 +121,7 @@ export class BgServerTracker {
119
121
  type: "notice",
120
122
  level: "info",
121
123
  text: `端口 ${port} 不在后台任务列表中`,
124
+ textEn: `Port ${port} is not in the background task list`,
122
125
  });
123
126
  this.opts.flushSnapshot();
124
127
  return false;
@@ -130,6 +133,7 @@ export class BgServerTracker {
130
133
  type: "notice",
131
134
  level: "info",
132
135
  text: `已停止后台任务:端口 ${port}(pid ${entry.pid})`,
136
+ textEn: `Stopped background task: port ${port} (pid ${entry.pid})`,
133
137
  });
134
138
  this.opts.flushSnapshot();
135
139
  return true;
@@ -139,6 +143,7 @@ export class BgServerTracker {
139
143
  if (this.servers.size === 0)
140
144
  return [];
141
145
  const killed = [];
146
+ // eslint-disable-next-line unicorn/no-useless-spread -- snapshot: handlers may unsubscribe mid-emit
142
147
  for (const [port, { pid }] of [...this.servers]) {
143
148
  killPidTree(pid);
144
149
  killed.push(String(port));
@@ -46,6 +46,15 @@ export function isExtensionDisabled(e, disabled) {
46
46
  const keys = extensionKeyCandidates(e);
47
47
  return disabled.some((d) => keys.includes(d));
48
48
  }
49
+ /** Whether an extension is covered by an ENABLED whitelist (any identity
50
+ * match). Empty whitelist = not whitelisting = everything allowed. Used by
51
+ * subagent templates(白名单语义:模板勾选 = 子代理只加载这些扩展)。 */
52
+ export function isExtensionEnabled(e, enabled) {
53
+ if (enabled.length === 0)
54
+ return true;
55
+ const keys = extensionKeyCandidates(e);
56
+ return enabled.some((d) => keys.includes(d));
57
+ }
49
58
  /**
50
59
  * Persists which workspace each browser client last used + which workspaces it
51
60
  * has opened, so a server restart / page reload restores the same project and
@@ -58,6 +67,10 @@ export class ClientStateStore {
58
67
  constructor(filePath) {
59
68
  this.filePath = filePath;
60
69
  }
70
+ /** <dataDir>(client-state.json 的上一级)——共享配置(子代理模板库等)落在这里。 */
71
+ get dataDir() {
72
+ return dirname(this.filePath);
73
+ }
61
74
  load() {
62
75
  if (this.cache)
63
76
  return this.cache;
@@ -93,10 +106,7 @@ export class ClientStateStore {
93
106
  const state = (all[clientId] ??= { projects: [] });
94
107
  state.lastCwd = cwd;
95
108
  const now = Date.now();
96
- state.projects = [
97
- { path: cwd, lastUsed: now },
98
- ...state.projects.filter((p) => p.path !== cwd),
99
- ].slice(0, 30);
109
+ state.projects = [{ path: cwd, lastUsed: now }, ...state.projects.filter((p) => p.path !== cwd)].slice(0, 30);
100
110
  // Opening the workspace again clears its removal tombstone.
101
111
  if (state.removedProjects?.length) {
102
112
  state.removedProjects = state.removedProjects.filter((p) => p !== cwd);
@@ -205,9 +215,7 @@ export class ClientStateStore {
205
215
  toolsWrap: settings.toolsWrap ?? cur.toolsWrap ?? true,
206
216
  visionBridgeEnabled: settings.visionBridgeEnabled ?? cur.visionBridgeEnabled ?? true,
207
217
  visionBridgeModel: settings.visionBridgeModel ?? cur.visionBridgeModel ?? null,
208
- visionBridgePromptMode: settings.visionBridgePromptMode ??
209
- cur.visionBridgePromptMode ??
210
- "append",
218
+ visionBridgePromptMode: settings.visionBridgePromptMode ?? cur.visionBridgePromptMode ?? "append",
211
219
  visionBridgePrompt: settings.visionBridgePrompt ?? cur.visionBridgePrompt ?? "",
212
220
  reviewPrompt: settings.reviewPrompt ?? cur.reviewPrompt ?? "",
213
221
  reviewDisabledSkills: settings.reviewDisabledSkills ?? cur.reviewDisabledSkills ?? [],
@@ -25,16 +25,13 @@ const CONTROL_IDLE_TIMEOUT_MS = 5_000;
25
25
  const CONTROL_CLIENT_TIMEOUT_MS = 3_000;
26
26
  /** Socket path (POSIX) or pipe name (Windows). */
27
27
  export function controlPath(dataDir, port) {
28
- return process.platform === "win32"
29
- ? `\\\\.\\pipe\\pi-web-ui-${port}`
30
- : join(dataDir, "pi-web-ui.sock");
28
+ return process.platform === "win32" ? `\\\\.\\pipe\\pi-web-ui-${port}` : join(dataDir, "pi-web-ui.sock");
31
29
  }
32
30
  /** Start the control socket; returns a stop function. */
33
31
  export function startControlServer(opts) {
34
32
  const { service, dataDir, port } = opts;
35
33
  const path = controlPath(dataDir, port);
36
34
  let server;
37
- let stop = false;
38
35
  if (process.platform === "win32") {
39
36
  server = createServer(handleConnection);
40
37
  }
@@ -126,7 +123,6 @@ export function startControlServer(opts) {
126
123
  });
127
124
  }
128
125
  return () => {
129
- stop = true;
130
126
  server.close();
131
127
  try {
132
128
  if (process.platform !== "win32" && existsSync(path))