pi-web-ui 0.36.0 → 0.44.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/bin/pi-web-ui.mjs CHANGED
@@ -27,6 +27,12 @@
27
27
  import { spawnSync } from "node:child_process";
28
28
  import { createConnection } from "node:net";
29
29
  import { get as httpGet } from "node:http";
30
+ import {
31
+ ensureBackup as ensurePluginBackup,
32
+ restoreBackup as restorePluginBackup,
33
+ checkPluginUpdates,
34
+ resolveRemoteSha,
35
+ } from "../dist/server/plugin-updater.js";
30
36
  import {
31
37
  chmodSync,
32
38
  copyFileSync,
@@ -140,6 +146,8 @@ function parseFlags(argv) {
140
146
  print: false,
141
147
  noBrowser: false,
142
148
  force: false,
149
+ checkUpdates: false,
150
+ rollback: undefined,
143
151
  help: false,
144
152
  };
145
153
  const positionals = [];
@@ -178,6 +186,12 @@ function parseFlags(argv) {
178
186
  case "--force":
179
187
  opts.force = true;
180
188
  break;
189
+ case "--check-updates":
190
+ opts.checkUpdates = true;
191
+ break;
192
+ case "--rollback":
193
+ opts.rollback = take("--rollback");
194
+ break;
181
195
  case "--help":
182
196
  case "-h":
183
197
  opts.help = true;
@@ -1354,7 +1368,11 @@ const PLUGIN_HELP = `用法:
1354
1368
  install 选项:
1355
1369
  --name <id> 插件目录名/id(默认取仓库名或 manifest.id,仅限字母数字-_)
1356
1370
  --data-dir <dir> 数据目录(默认 ~/.pi-web 或 $PI_WEB_DATA_DIR)
1357
- --force 目标目录已存在时覆盖
1371
+ --force 目标目录已存在时覆盖(覆盖前自动备份旧版本)
1372
+
1373
+ plugins 选项:
1374
+ --check-updates 逐个对比最近安装版本与远端 HEAD,列出可更新插件
1375
+ --rollback <id> 回滚到最近一份更新前备份(<dataDir>/plugin-backups/)
1358
1376
  `;
1359
1377
 
1360
1378
  function pluginDataDir(opts) {
@@ -1498,6 +1516,7 @@ async function pluginInstallCmd(argv) {
1498
1516
  const isLocal = existsSync(localCandidate);
1499
1517
  const src = isLocal ? null : parsePluginSource(rawSpec);
1500
1518
  const tmp = mkdtempSync(join(tmpdir(), "pi-web-ui-plugin-"));
1519
+ let backupTs = null;
1501
1520
  try {
1502
1521
  let checkout;
1503
1522
  try {
@@ -1532,6 +1551,9 @@ async function pluginInstallCmd(argv) {
1532
1551
  if (existsSync(target)) {
1533
1552
  if (!opts.force)
1534
1553
  fail(`插件目录已存在:${target}\n 加 --force 覆盖,或用 --name <id> 换个名字。`);
1554
+ // 更新前备份旧版本(<dataDir>/plugin-backups/<id>-<ts>/,保留最近 3 份),
1555
+ // 失败时自动回滚。备份与安装同 filter:不带 .git/node_modules。
1556
+ backupTs = ensurePluginBackup(pluginDataDir(opts), id, { source: rawSpec });
1535
1557
  // 插件凭据/配置不因升级丢失:先取出旧 config.json,拷完新文件后原样放回
1536
1558
  try {
1537
1559
  prevConfig = readFileSync(join(target, CONFIG_NAME), "utf8");
@@ -1541,10 +1563,18 @@ async function pluginInstallCmd(argv) {
1541
1563
  rmSync(target, { recursive: true, force: true });
1542
1564
  }
1543
1565
  mkdirSync(target, { recursive: true });
1544
- cpSync(pluginRoot, target, {
1545
- recursive: true,
1546
- filter: (s) => !/(^|[\\/])(\.git|node_modules)([\\/]|$)/.test(s),
1547
- });
1566
+ try {
1567
+ cpSync(pluginRoot, target, {
1568
+ recursive: true,
1569
+ filter: (s) => !/(^|[\\/])(\.git|node_modules)([\\/]|$)/.test(s),
1570
+ });
1571
+ } catch (err) {
1572
+ // 拷贝失败 → 有备份则自动回滚,保持旧版本可用
1573
+ if (backupTs && restorePluginBackup(pluginDataDir(opts), id)) {
1574
+ fail(`插件更新失败:${err?.message ?? err}\n 已自动回滚到更新前版本。`);
1575
+ }
1576
+ fail(`插件更新失败:${err?.message ?? err}\n (无可用备份,请重新 install --force)`);
1577
+ }
1548
1578
  if (prevConfig !== null && !existsSync(join(target, CONFIG_NAME))) {
1549
1579
  writeFileSync(join(target, CONFIG_NAME), prevConfig);
1550
1580
  }
@@ -1557,6 +1587,14 @@ async function pluginInstallCmd(argv) {
1557
1587
  } catch {
1558
1588
  /* 尽力而为:没有来源信息只是不显示更新按钮 */
1559
1589
  }
1590
+ // 记录本次安装的远端 sha(git ls-remote HEAD,离线也支持本地 git 源):
1591
+ // 供 `pi-web-ui plugins --check-updates` 对比更新。失败静默(无 sha = 保守可更新)。
1592
+ try {
1593
+ const sha = await resolveRemoteSha(rawSpec);
1594
+ if (sha) writeFileSync(join(target, ".pi-git-sha"), sha + "\n");
1595
+ } catch {
1596
+ /* 尽力而为 */
1597
+ }
1560
1598
  console.log(
1561
1599
  `✔ 已安装插件 ${id}${manifest.name && manifest.name !== id ? `(${manifest.name})` : ""}${manifest.version ? ` v${manifest.version}` : ""}`,
1562
1600
  );
@@ -1584,12 +1622,28 @@ function pluginUninstallCmd(argv) {
1584
1622
  }
1585
1623
 
1586
1624
  function pluginListCmd(argv) {
1587
- const { opts } = parseFlags(argv);
1625
+ const { opts, positionals } = parseFlags(argv);
1588
1626
  if (opts.help) {
1589
1627
  console.log(PLUGIN_HELP);
1590
1628
  return;
1591
1629
  }
1592
- const pluginsDir = join(pluginDataDir(opts), "plugins");
1630
+ const dataDir = pluginDataDir(opts);
1631
+ // --rollback <id>:回滚到最近一份更新前备份
1632
+ if (opts.rollback) {
1633
+ const id = String(opts.rollback);
1634
+ if (!PLUGIN_ID_RE.test(id)) fail(`非法插件 id: ${id}`);
1635
+ const target = join(dataDir, "plugins", id);
1636
+ if (!existsSync(target)) fail(`未安装插件 "${id}"(pi-web-ui plugins 查看已装列表)`);
1637
+ const ts = restorePluginBackup(dataDir, id);
1638
+ if (!ts) fail(`插件 "${id}" 没有更新备份(从未覆盖安装 / 备份已用完)`);
1639
+ console.log(`✔ 已回滚插件 ${id} 到 ${ts} 的快照 —— 运行中的服务刷新浏览器后生效。`);
1640
+ return;
1641
+ }
1642
+ // --check-updates:对比各插件记录的最后安装 sha 与远端 HEAD(git ls-remote)
1643
+ if (opts.checkUpdates) {
1644
+ return checkUpdatesCmd(dataDir).then(() => {});
1645
+ }
1646
+ const pluginsDir = join(dataDir, "plugins");
1593
1647
  const rows = [];
1594
1648
  let names = [];
1595
1649
  try {
@@ -1613,6 +1667,34 @@ function pluginListCmd(argv) {
1613
1667
  console.log(`已安装的界面插件(${pluginsDir}):\n${rows.join("\n")}`);
1614
1668
  }
1615
1669
 
1670
+ async function checkUpdatesCmd(dataDir) {
1671
+ console.log("检查界面插件更新(git ls-remote 对比最近安装版本)…\n");
1672
+ let rows;
1673
+ try {
1674
+ rows = await checkPluginUpdates(dataDir);
1675
+ } catch (err) {
1676
+ fail(`更新检查失败:${err?.message ?? err}`);
1677
+ }
1678
+ if (rows.length === 0) {
1679
+ console.log(`尚未安装任何带来源记录的界面插件(目录: ${join(dataDir, "plugins")})`);
1680
+ return;
1681
+ }
1682
+ let any = false;
1683
+ for (const r of rows) {
1684
+ const label = r.name && r.name !== r.id ? `${r.id}(${r.name})` : r.id;
1685
+ if (r.updatable) {
1686
+ console.log(` 🔄 ${label}${r.version ? ` v${r.version}` : ""} 可更新(已装 ${r.localSha ?? "未知"} → 远端 ${r.remoteSha})`);
1687
+ console.log(` 更新: pi-web-ui install ${r.source} --name ${r.id} --force`);
1688
+ any = true;
1689
+ } else if (r.remoteSha) {
1690
+ console.log(` ✓ ${label}${r.version ? ` v${r.version}` : ""} 已是最新(${r.remoteSha})`);
1691
+ } else {
1692
+ console.log(` ? ${label} ${r.error ?? "无法检查"}(来源: ${r.source})`);
1693
+ }
1694
+ }
1695
+ if (!any) console.log("\n全部插件均为最新版本。");
1696
+ }
1697
+
1616
1698
  async function serverCmd(argv) {
1617
1699
  const { opts, positionals } = parseFlags(argv);
1618
1700
  if (opts.help) {
@@ -314,12 +314,20 @@ export class ClientSession {
314
314
  emit: (msg) => this.emit(msg),
315
315
  flushSnapshot: () => this.flushSnapshot(),
316
316
  isDisposed: () => this.disposed,
317
+ // 插件注册的常驻任务(host.registerBackgroundTask)并入同一「后台任务」面板。
318
+ pluginTasks: () => this.pluginBgTasksProvider?.() ?? [],
317
319
  });
318
320
  /** index.ts 注入(经 AgentService 拷贝到每个新会话):把 SDK 工具执行事件转发给
319
321
  * 插件(PluginManager.emitToolEvent)。未设置时不做任何事。 */
320
322
  onToolEvent = undefined;
321
323
  /** index.ts 注入:读取插件当前注册的 AI 工具(attach 时拷贝到每个新会话)。 */
322
324
  pluginToolsProvider = undefined;
325
+ /** index.ts 注入:读取插件当前注册的斜杠命令(目录展示 + prompt 拦截执行)。 */
326
+ pluginCommandsProvider = undefined;
327
+ /** index.ts 注入:读取插件注册的常驻后台任务(并入 bg_servers 面板)。 */
328
+ pluginBgTasksProvider = undefined;
329
+ /** index.ts 注入:停止插件任务(kill_background_server with taskId)。 */
330
+ pluginStopBgTask = undefined;
323
331
  /** 上一轮注入会话的插件工具名集合(用于检测注销/移除)。 */
324
332
  appliedPluginToolNames = new Set();
325
333
  /** The active conversation (all session operations target it). */
@@ -1514,6 +1522,27 @@ export class ClientSession {
1514
1522
  setThinking: (level) => this.setThinking(level),
1515
1523
  refreshSessions: () => this.refreshSessions(),
1516
1524
  afterReload: () => this.applyTerminalToolGating(this.session),
1525
+ pluginCommands: () => this.pluginCommandsProvider?.() ?? [],
1526
+ execPluginCommand: async (name, args) => {
1527
+ const def = this.pluginCommandsProvider?.().find((c) => c.name === name);
1528
+ if (!def)
1529
+ return false;
1530
+ try {
1531
+ const result = await def.run(args, { clientId: this.clientId });
1532
+ // 字符串返回值 → 通知条回显给发起人;富展示用 broadcast/sendTo。
1533
+ if (typeof result === "string" && result.trim()) {
1534
+ this.emit({ type: "notice", level: "info", text: result });
1535
+ }
1536
+ }
1537
+ catch (err) {
1538
+ this.emit({
1539
+ type: "notice",
1540
+ level: "error",
1541
+ text: `插件命令 /${name} 执行失败:${err.message}`,
1542
+ });
1543
+ }
1544
+ return true;
1545
+ },
1517
1546
  onQuit: () => this.onQuit?.() ?? false,
1518
1547
  });
1519
1548
  /** Catalog push — index.ts get_commands / attach / cwd 切换等都会调用。 */
@@ -1779,8 +1808,33 @@ export class ClientSession {
1779
1808
  async listBgServers() {
1780
1809
  await this.bg.listAndPush();
1781
1810
  }
1811
+ /** 插件任务集合变化时由宿主调用:重推一次 bg_servers(含插件任务)。 */
1812
+ refreshBgTasks() {
1813
+ this.bg.push();
1814
+ }
1815
+ /** 插件设置保存结果等需要从 index.ts 发 notice 时用(emit 是私有的)。 */
1816
+ emitNotice(level, text) {
1817
+ this.emit({ type: "notice", level, text });
1818
+ }
1782
1819
  /** Kill ONE background server (by port); returns whether anything was killed. */
1783
- async killBackgroundServer(port) {
1820
+ /** Kill ONE background server (by port) OR a plugin task (by taskId). */
1821
+ async killBackgroundServer(port, taskId) {
1822
+ if (taskId) {
1823
+ // 插件任务:交给插件管理器 stop 回调(不杀进程树——任务在宿主进程内)。
1824
+ const ok = this.pluginStopBgTask?.(taskId) ?? false;
1825
+ if (!ok) {
1826
+ this.emit({
1827
+ type: "notice",
1828
+ level: "info",
1829
+ text: `后台任务「${taskId}」不存在或已结束`,
1830
+ });
1831
+ }
1832
+ this.bg.push();
1833
+ this.flushSnapshot();
1834
+ return ok;
1835
+ }
1836
+ if (typeof port !== "number")
1837
+ return false;
1784
1838
  return this.bg.killOne(port);
1785
1839
  }
1786
1840
  /** Kill every background server the agent started; returns the freed ports. */
@@ -1944,6 +1998,9 @@ export class ClientSession {
1944
1998
  // lifecycle. Removal is deferred until the new chat exists so the active
1945
1999
  // conversation stays valid during the (async) runtime creation.
1946
2000
  const displaced = this.displaceActive();
2001
+ // Carry the model chosen in the active chat over to the new chat so it
2002
+ // doesn't silently revert to the ModelRuntime default model.
2003
+ const prevModel = this.conv.session.agent.state.model ?? null;
1947
2004
  try {
1948
2005
  const conversationId = this.nextConversationId();
1949
2006
  const terminals = this.makeTerminalManager(conversationId, this.cwd);
@@ -1958,6 +2015,16 @@ export class ClientSession {
1958
2015
  if (displaced)
1959
2016
  this.removeConversation(displaced.id);
1960
2017
  await this.bindSession();
2018
+ // New session seeds with the ModelRuntime default model — restore the
2019
+ // model the user had selected in the previous chat.
2020
+ if (prevModel && this.sharedModelRuntime) {
2021
+ try {
2022
+ await this.session.setModel(prevModel);
2023
+ }
2024
+ catch {
2025
+ // model no longer resolvable — keep the default
2026
+ }
2027
+ }
1961
2028
  this.emitConversations();
1962
2029
  this.goalSvc.emitGoalStatus();
1963
2030
  this.pushTerminals();
@@ -2247,6 +2314,9 @@ export class ClientSession {
2247
2314
  return;
2248
2315
  }
2249
2316
  try {
2317
+ // Preserve the model the user had selected — fork() seeds a new
2318
+ // branch with the ModelRuntime default model otherwise.
2319
+ const prevModel = this.session.agent.state.model ?? null;
2250
2320
  const result = await this.runtime.fork(entryId);
2251
2321
  if (result.cancelled) {
2252
2322
  this.emit({
@@ -2258,6 +2328,15 @@ export class ClientSession {
2258
2328
  return;
2259
2329
  }
2260
2330
  await this.bindSession();
2331
+ // Restore the previously-selected model on the forked branch.
2332
+ if (prevModel && this.sharedModelRuntime) {
2333
+ try {
2334
+ await this.session.setModel(prevModel);
2335
+ }
2336
+ catch {
2337
+ // model no longer resolvable — keep the default
2338
+ }
2339
+ }
2261
2340
  await this.prompt(trimmed, attachments);
2262
2341
  this.emit({
2263
2342
  type: "notice",
@@ -2608,6 +2687,12 @@ export class AgentService {
2608
2687
  onToolEvent = undefined;
2609
2688
  /** index.ts 注入:读取插件当前注册的 AI 工具(attach 时拷贝到每个新会话)。 */
2610
2689
  pluginToolsProvider = undefined;
2690
+ /** index.ts 注入:读取插件当前注册的斜杠命令(attach 时拷贝到每个新会话)。 */
2691
+ pluginCommandsProvider = undefined;
2692
+ /** index.ts 注入:读取插件注册的常驻后台任务(并入 bg_servers 面板)。 */
2693
+ pluginBgTasksProvider = undefined;
2694
+ /** index.ts 注入:停止插件任务(kill_background_server with taskId)。 */
2695
+ pluginStopBgTask = undefined;
2611
2696
  clients = new Map();
2612
2697
  /** Quiesce (draining) state — the service refuses NEW work (prompts, forks,
2613
2698
  * session resumes, new clients) so a deploy/upgrade/backup can stop cleanly
@@ -2739,6 +2824,9 @@ export class AgentService {
2739
2824
  cs.onQuit = this.onQuit;
2740
2825
  cs.onToolEvent = this.onToolEvent;
2741
2826
  cs.pluginToolsProvider = this.pluginToolsProvider;
2827
+ cs.pluginCommandsProvider = this.pluginCommandsProvider;
2828
+ cs.pluginBgTasksProvider = this.pluginBgTasksProvider;
2829
+ cs.pluginStopBgTask = this.pluginStopBgTask;
2742
2830
  cs.isQuiesced = () => this.quiesced;
2743
2831
  // 插件宿主工作区跟随:初次接入也同步一次(恢复的 lastCwd 可能≠服务启动目录),
2744
2832
  // notifyCwd 幂等去重;此后 set_cwd 成功时由 cs.onCwdChanged 继续驱动。
@@ -2751,6 +2839,16 @@ export class AgentService {
2751
2839
  for (const cs of this.clients.values())
2752
2840
  cs.refreshPluginTools();
2753
2841
  }
2842
+ /** 插件斜杠命令集合变化时由 index.ts 触发:重推各客户端的命令目录。 */
2843
+ applyPluginCommandCatalog() {
2844
+ for (const cs of this.clients.values())
2845
+ void cs.pushSlashCommands();
2846
+ }
2847
+ /** 插件常驻后台任务变化时由 index.ts 触发:重推各客户端的 bg_servers。 */
2848
+ refreshBackgroundServers() {
2849
+ for (const cs of this.clients.values())
2850
+ cs.refreshBgTasks();
2851
+ }
2754
2852
  /** Remove a socket from a client's broadcast set (called on socket close). */
2755
2853
  detach(clientId, send) {
2756
2854
  this.clients.get(clientId)?.detachSink(send);
@@ -1,5 +1,5 @@
1
1
  import { countLines, decodeText, looksLikeText, sniffImageMime, } from "./text-sniff.js";
2
- import { saveUpload } from "./uploads.js";
2
+ import { saveUpload, uploadsRoot } from "./uploads.js";
3
3
  import { buildVisionBridgePrompt, findVisionModels, transcribeImages, } from "./vision-bridge.js";
4
4
  /** 跨快照的视觉转写缓存:批次 hash(名称 + base64 头 + 提示词)→ 转写文本。
5
5
  * 编辑重问重发相同图片不再重复耗视觉 token。进程级共享即可。 */
@@ -17,7 +17,7 @@ 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 } = await import("node:path");
20
+ const { resolve, sep, relative, extname, join, 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
@@ -42,6 +42,57 @@ export async function buildAttachmentMessages(ctx, attachments) {
42
42
  ".svg": "image/svg+xml",
43
43
  };
44
44
  const out = [];
45
+ /** Push the aside for a raw uploaded file (fresh fileData or a restored
46
+ * uploadPath re-read from disk). Small text files are inlined so the
47
+ * model sees them immediately; everything else becomes a path reference.
48
+ * `upload: true` marks the card as a restorable upload — the browser
49
+ * re-sends it by path when editing & re-asking a question. */
50
+ const pushUploadAside = (name, wirePath, buf) => {
51
+ if (buf.length <= MAX_INLINE_BYTES && looksLikeText(buf)) {
52
+ const lines = countLines(buf);
53
+ out.push({
54
+ message: {
55
+ customType: "file",
56
+ content: [
57
+ {
58
+ type: "text",
59
+ text: `\n<file path="${wirePath}">\n\`\`\`\n${decodeText(buf)}\n\`\`\`\n</file>`,
60
+ },
61
+ ],
62
+ display: true,
63
+ details: {
64
+ name,
65
+ path: wirePath,
66
+ mode: "inline",
67
+ size: buf.length,
68
+ lines,
69
+ upload: true,
70
+ },
71
+ },
72
+ });
73
+ }
74
+ else {
75
+ out.push({
76
+ message: {
77
+ customType: "file",
78
+ content: [
79
+ {
80
+ type: "text",
81
+ text: `<file path="${wirePath}" size="${buf.length}" />`,
82
+ },
83
+ ],
84
+ display: true,
85
+ details: {
86
+ name,
87
+ path: wirePath,
88
+ mode: "reference",
89
+ size: buf.length,
90
+ upload: true,
91
+ },
92
+ },
93
+ });
94
+ }
95
+ };
45
96
  // -- Vision bridge ------------------------------------------------------
46
97
  // When the active model can't accept images (DeepSeek, GLM, …), pasted
47
98
  // images are transcribed by a configured vision model first and the
@@ -288,48 +339,48 @@ export async function buildAttachmentMessages(ctx, attachments) {
288
339
  // Wire format: forward-slash absolute path (the read tool accepts
289
340
  // absolute paths; Windows uses "C:/..." — safe inside the XML-ish tag).
290
341
  const wirePath = abs.split(sep).join("/");
291
- if (buf.length <= MAX_INLINE_BYTES && looksLikeText(buf)) {
292
- const lines = countLines(buf);
293
- out.push({
294
- message: {
295
- customType: "file",
296
- content: [
297
- {
298
- type: "text",
299
- text: `\n<file path="${wirePath}">\n\`\`\`\n${decodeText(buf)}\n\`\`\`\n</file>`,
300
- },
301
- ],
302
- display: true,
303
- details: {
304
- name: safeName,
305
- path: wirePath,
306
- mode: "inline",
307
- size: buf.length,
308
- lines,
309
- },
310
- },
342
+ pushUploadAside(safeName, wirePath, buf);
343
+ continue;
344
+ }
345
+ // Restored upload from edit-and-re-ask: the browser re-sends the
346
+ // server-generated absolute path of a previously uploaded (fileData)
347
+ // file instead of the original base64 (the fork drops the original
348
+ // aside card, so the bytes must be re-read from the uploads dir).
349
+ // Validate the path stays inside THIS client's uploads/ folder, then
350
+ // re-read the persisted bytes and attach by the same path — no
351
+ // re-save (the file already exists; retention sweeping governs its
352
+ // lifetime, same as the original card).
353
+ if (att.uploadPath) {
354
+ const rootDir = uploadsRoot();
355
+ const abs = resolve(rootDir, att.uploadPath);
356
+ const relToRoot = relative(rootDir, abs);
357
+ const inClientDir = !relToRoot.startsWith("..") &&
358
+ !relToRoot.includes(`${sep}..`) &&
359
+ (relToRoot === ctx.clientId ||
360
+ relToRoot.startsWith(`${ctx.clientId}${sep}`));
361
+ if (!inClientDir) {
362
+ ctx.emit({
363
+ type: "notice",
364
+ level: "warning",
365
+ text: `无法恢复已上传文件(路径不在本客户端上传目录):${att.name ?? att.uploadPath}`,
311
366
  });
367
+ continue;
312
368
  }
313
- else {
314
- out.push({
315
- message: {
316
- customType: "file",
317
- content: [
318
- {
319
- type: "text",
320
- text: `<file path="${wirePath}" size="${buf.length}" />`,
321
- },
322
- ],
323
- display: true,
324
- details: {
325
- name: safeName,
326
- path: wirePath,
327
- mode: "reference",
328
- size: buf.length,
329
- },
330
- },
369
+ let buf;
370
+ try {
371
+ buf = await fs.readFile(abs);
372
+ }
373
+ catch {
374
+ ctx.emit({
375
+ type: "notice",
376
+ level: "warning",
377
+ text: `无法恢复已上传文件(已被清理或不可读):${att.name ?? att.uploadPath}`,
331
378
  });
379
+ continue;
332
380
  }
381
+ if (buf.length === 0)
382
+ continue;
383
+ pushUploadAside(att.name ?? basename(abs), abs.split(sep).join("/"), buf);
333
384
  continue;
334
385
  }
335
386
  const abs = resolve(root, att.path);
@@ -69,9 +69,9 @@ export class BgServerTracker {
69
69
  if (added)
70
70
  this.push();
71
71
  }
72
- /** The current background-server list, oldest first. */
72
+ /** The current background-server list, oldest first. 合并插件任务。 */
73
73
  list() {
74
- return [...this.servers.entries()]
74
+ const out = [...this.servers.entries()]
75
75
  .map(([port, v]) => ({
76
76
  port,
77
77
  pid: v.pid,
@@ -80,6 +80,9 @@ export class BgServerTracker {
80
80
  ...(v.command ? { command: v.command } : {}),
81
81
  }))
82
82
  .sort((a, b) => a.since - b.since);
83
+ for (const t of this.opts.pluginTasks?.() ?? [])
84
+ out.push(t);
85
+ return out;
83
86
  }
84
87
  /** Push the current background-task list to every connected socket. */
85
88
  push() {
@@ -177,6 +177,7 @@ export class ClientStateStore {
177
177
  terminalToolsEnabled: s?.settings?.terminalToolsEnabled ?? true,
178
178
  terminalBash: s?.settings?.terminalBash ?? false,
179
179
  terminalBashIdleMs: s?.settings?.terminalBashIdleMs ?? 15_000,
180
+ thinkingWrap: s?.settings?.thinkingWrap ?? true,
180
181
  visionBridgeEnabled: s?.settings?.visionBridgeEnabled ?? true,
181
182
  visionBridgeModel: s?.settings?.visionBridgeModel ?? null,
182
183
  visionBridgePromptMode: s?.settings?.visionBridgePromptMode === "replace" ? "replace" : "append",
@@ -199,6 +200,7 @@ export class ClientStateStore {
199
200
  terminalToolsEnabled: settings.terminalToolsEnabled ?? cur.terminalToolsEnabled ?? true,
200
201
  terminalBash: settings.terminalBash ?? cur.terminalBash ?? false,
201
202
  terminalBashIdleMs: settings.terminalBashIdleMs ?? cur.terminalBashIdleMs ?? 15_000,
203
+ thinkingWrap: settings.thinkingWrap ?? cur.thinkingWrap ?? true,
202
204
  visionBridgeEnabled: settings.visionBridgeEnabled ?? cur.visionBridgeEnabled ?? true,
203
205
  visionBridgeModel: settings.visionBridgeModel ?? cur.visionBridgeModel ?? null,
204
206
  visionBridgePromptMode: settings.visionBridgePromptMode ??
@@ -36,6 +36,7 @@ import { scheduleUploadCleanup } from "./uploads.js";
36
36
  import { ensureWindowsBash, windowsBashDir } from "./ensure-bash.js";
37
37
  import { listThemes, resolveThemeFile } from "./themes.js";
38
38
  import { PluginManager, resolvePluginClientFile } from "./plugins.js";
39
+ import { McpBridge } from "./mcp-bridge.js";
39
40
  const PORT = Number(process.env.PORT ?? 8787);
40
41
  const CWD = resolve(process.env.PI_WEB_CWD ?? process.cwd());
41
42
  const DATA_DIR = resolve(process.env.PI_WEB_DATA_DIR ?? join(homedir(), ".pi-web"));
@@ -216,6 +217,13 @@ app.get("/themes/:id.css", (req, res) => {
216
217
  // (which may hold credentials) never leave the machine. Registered BEFORE the
217
218
  // SPA catch-all below.
218
219
  const PLUGINS_DIR = join(DATA_DIR, "plugins");
220
+ // 插件 HTTP 路由挂载点:host.route("GET", "/inbox") 实际暴露为
221
+ // /plugins-api/<id>/inbox。PI_WEB_TOKEN 鉴权(上方 app.use)自动覆盖;
222
+ // 响应已在前面过了 express.json。注意不要在此 catch-all 里消费 body。
223
+ app.all(["/plugins-api/:id/*", "/plugins-api/:id"], (req, res) => {
224
+ const rest = String(req.params[0] ?? "");
225
+ pluginMgr.handleHttp(String(req.params.id ?? ""), req.method, rest, req, res);
226
+ });
219
227
  app.get("/plugins/:id/client/*", (req, res) => {
220
228
  // express 4 的通配参数在运行时落在 params[0],但类型声明里没有 —— 显式取
221
229
  const rest = String(req.params[0] ?? "");
@@ -368,12 +376,26 @@ join(DATA_DIR, "client-state.json"));
368
376
  // Optional UI plugins (<dataDir>/plugins/<id>/): scanned on every client
369
377
  // attach so freshly dropped plugins appear without a server restart.
370
378
  const pluginMgr = new PluginManager(DATA_DIR, CWD);
379
+ // MCP 工具桥:读取 <dataDir>/mcp.json 启动外部 MCP 服务器(stdio),把它们的
380
+ // 工具并入与插件工具相同的 customTools 管线;单服务器失败不炸进程。
381
+ const mcpBridge = new McpBridge(DATA_DIR, (...a) => console.log("[mcp]", ...a));
382
+ void mcpBridge.load().then(() => {
383
+ if (mcpBridge.getTools().length)
384
+ service.applyPluginAgentTools();
385
+ });
371
386
  // 插件扩展点:SDK 工具执行事件(bash/读文件等 start+end)转发给已注册的插件。
372
387
  service.onToolEvent = (ev) => pluginMgr.emitToolEvent(ev);
373
- // 插件扩展点:插件注册的 AI 工具(registerAgentTool)→ 会话创建时带上 +
374
- // 变化时动态注入/移除已有会话。
375
- service.pluginToolsProvider = () => pluginMgr.getAgentTools();
388
+ // 插件扩展点:插件注册的 AI 工具(registerAgentTool)+ MCP 桥工具 → 会话创建时
389
+ // 带上 + 变化时动态注入/移除已有会话。
390
+ service.pluginToolsProvider = () => [...pluginMgr.getAgentTools(), ...mcpBridge.getTools()];
376
391
  pluginMgr.onAgentToolsChanged = () => service.applyPluginAgentTools();
392
+ // 插件扩展点:插件斜杠命令(registerCommand)→ 命令选择器目录 + prompt 拦截执行。
393
+ pluginMgr.onCommandsChanged = () => service.applyPluginCommandCatalog();
394
+ service.pluginCommandsProvider = () => pluginMgr.listCommands();
395
+ // 插件扩展点:插件常驻后台任务(registerBackgroundTask)→ 并入「后台任务」面板。
396
+ pluginMgr.onBgTasksChanged = () => service.refreshBackgroundServers();
397
+ service.pluginBgTasksProvider = () => pluginMgr.bgTasks();
398
+ service.pluginStopBgTask = (taskId) => pluginMgr.stopPluginBgTask(taskId);
377
399
  // 插件宿主工作区实时跟随当前项目:任意客户端 set_cwd 成功后同步给
378
400
  // PluginManager,编辑器等工作区跟随型插件随即切根(详见 plugins.ts notifyCwd)。
379
401
  service.onClientCwdChanged = (cwd) => pluginMgr.notifyCwd(cwd);
@@ -512,7 +534,7 @@ wss.on("connection", (ws) => {
512
534
  void cs.abortBash();
513
535
  break;
514
536
  case "kill_background_server":
515
- void cs.killBackgroundServer(msg.port);
537
+ void cs.killBackgroundServer(msg.port, msg.taskId);
516
538
  break;
517
539
  case "kill_background_servers":
518
540
  void cs.killAllBackgroundServers();
@@ -694,6 +716,7 @@ wss.on("connection", (ws) => {
694
716
  terminalToolsEnabled: msg.terminalToolsEnabled,
695
717
  terminalBash: msg.terminalBash,
696
718
  terminalBashIdleMs: msg.terminalBashIdleMs,
719
+ thinkingWrap: msg.thinkingWrap,
697
720
  visionBridgeEnabled: msg.visionBridgeEnabled,
698
721
  visionBridgeModel: msg.visionBridgeModel,
699
722
  visionBridgePromptMode: msg.visionBridgePromptMode,
@@ -708,6 +731,16 @@ wss.on("connection", (ws) => {
708
731
  case "plugin_message":
709
732
  pluginMgr.handleMessage(msg.pluginId, msg.payload, clientId ?? undefined);
710
733
  break;
734
+ case "plugin_settings": {
735
+ const r = pluginMgr.savePluginSettings(msg.pluginId, msg.values ?? {});
736
+ if (r.error) {
737
+ cs?.emitNotice("error", `插件设置保存失败:${r.error}`);
738
+ }
739
+ else {
740
+ cs?.emitNotice("info", "插件设置已保存");
741
+ }
742
+ break;
743
+ }
711
744
  case "plugins_reload":
712
745
  void pluginMgr.reload().then(() => pluginMgr.pushToAll());
713
746
  break;
@@ -756,6 +789,9 @@ wss.on("connection", (ws) => {
756
789
  // 让各插件向新接入的客户端推送自身初始状态(onAttach 钩子)——
757
790
  // 插件不要依赖客户端挂载后自己拉(见 plugins.ts onAttach 注释)。
758
791
  pluginMgr.notifyAttach(cid);
792
+ // 插件命令可能在本客户端 attach 过程中才注册(首载竞态)——
793
+ // 重推一次目录,保证选择器完整。
794
+ service.applyPluginCommandCatalog();
759
795
  })
760
796
  .catch(() => { });
761
797
  // Replay anything that arrived while the session was starting.
@@ -848,6 +884,7 @@ async function shutdown() {
848
884
  clearInterval(heartbeatTimer);
849
885
  stopControl();
850
886
  pluginMgr.dispose();
887
+ mcpBridge.dispose();
851
888
  await service.disposeAll();
852
889
  wss.close();
853
890
  httpServer.close();