pi-web-ui 0.35.1 → 0.43.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 +89 -7
- package/dist/server/agent-service.js +117 -1
- package/dist/server/attachments.js +91 -40
- package/dist/server/bg-servers.js +5 -2
- package/dist/server/index.js +43 -4
- package/dist/server/mcp-bridge.js +268 -0
- package/dist/server/plugin-facilities.js +299 -0
- package/dist/server/plugin-updater.js +226 -0
- package/dist/server/plugins.js +480 -6
- package/dist/server/slash-commands.js +17 -1
- package/package.json +96 -96
- package/themes/md-preview.css +47 -0
- package/themes/white.css +47 -0
- package/web/dist/assets/{TerminalPanel-BxezvWth.js → TerminalPanel-BeTRtKaL.js} +1 -1
- package/web/dist/assets/index-BByVm30o.css +10 -0
- package/web/dist/assets/index-DPc38E4m.js +19 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-BP593LGC.js +0 -19
- package/web/dist/assets/index-DduTNQNx.css +0 -10
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
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
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
|
|
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). */
|
|
@@ -1399,6 +1407,10 @@ export class ClientSession {
|
|
|
1399
1407
|
}
|
|
1400
1408
|
/** Set by index.ts: called when /pi-web-ui:quit is invoked. */
|
|
1401
1409
|
onQuit = undefined;
|
|
1410
|
+
/** 本客户端成功切换工作区(set_cwd)后触发,参数为新绝对路径。
|
|
1411
|
+
* attach 时由 AgentService 接到全局 onClientCwdChanged —— 编辑器等
|
|
1412
|
+
* 工作区跟随型插件借此把根目录切到用户当前项目。 */
|
|
1413
|
+
onCwdChanged = undefined;
|
|
1402
1414
|
/** Ask the npm registry for the latest pi-web-ui version and report it. */
|
|
1403
1415
|
async checkUpdate() {
|
|
1404
1416
|
const current = ClientSession.currentAppVersion();
|
|
@@ -1510,6 +1522,27 @@ export class ClientSession {
|
|
|
1510
1522
|
setThinking: (level) => this.setThinking(level),
|
|
1511
1523
|
refreshSessions: () => this.refreshSessions(),
|
|
1512
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
|
+
},
|
|
1513
1546
|
onQuit: () => this.onQuit?.() ?? false,
|
|
1514
1547
|
});
|
|
1515
1548
|
/** Catalog push — index.ts get_commands / attach / cwd 切换等都会调用。 */
|
|
@@ -1775,8 +1808,33 @@ export class ClientSession {
|
|
|
1775
1808
|
async listBgServers() {
|
|
1776
1809
|
await this.bg.listAndPush();
|
|
1777
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
|
+
}
|
|
1778
1819
|
/** Kill ONE background server (by port); returns whether anything was killed. */
|
|
1779
|
-
|
|
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;
|
|
1780
1838
|
return this.bg.killOne(port);
|
|
1781
1839
|
}
|
|
1782
1840
|
/** Kill every background server the agent started; returns the freed ports. */
|
|
@@ -1940,6 +1998,9 @@ export class ClientSession {
|
|
|
1940
1998
|
// lifecycle. Removal is deferred until the new chat exists so the active
|
|
1941
1999
|
// conversation stays valid during the (async) runtime creation.
|
|
1942
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;
|
|
1943
2004
|
try {
|
|
1944
2005
|
const conversationId = this.nextConversationId();
|
|
1945
2006
|
const terminals = this.makeTerminalManager(conversationId, this.cwd);
|
|
@@ -1954,6 +2015,16 @@ export class ClientSession {
|
|
|
1954
2015
|
if (displaced)
|
|
1955
2016
|
this.removeConversation(displaced.id);
|
|
1956
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
|
+
}
|
|
1957
2028
|
this.emitConversations();
|
|
1958
2029
|
this.goalSvc.emitGoalStatus();
|
|
1959
2030
|
this.pushTerminals();
|
|
@@ -2243,6 +2314,9 @@ export class ClientSession {
|
|
|
2243
2314
|
return;
|
|
2244
2315
|
}
|
|
2245
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;
|
|
2246
2320
|
const result = await this.runtime.fork(entryId);
|
|
2247
2321
|
if (result.cancelled) {
|
|
2248
2322
|
this.emit({
|
|
@@ -2254,6 +2328,15 @@ export class ClientSession {
|
|
|
2254
2328
|
return;
|
|
2255
2329
|
}
|
|
2256
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
|
+
}
|
|
2257
2340
|
await this.prompt(trimmed, attachments);
|
|
2258
2341
|
this.emit({
|
|
2259
2342
|
type: "notice",
|
|
@@ -2408,6 +2491,13 @@ export class ClientSession {
|
|
|
2408
2491
|
this.conv.promptedSinceActive = false;
|
|
2409
2492
|
this.conv.lastActiveAt = Date.now();
|
|
2410
2493
|
this.cwd = abs;
|
|
2494
|
+
// 工作区跟随型插件(编辑器文件树等)同步切根。
|
|
2495
|
+
try {
|
|
2496
|
+
this.onCwdChanged?.(abs);
|
|
2497
|
+
}
|
|
2498
|
+
catch {
|
|
2499
|
+
/* 钩子异常不影响主流程 */
|
|
2500
|
+
}
|
|
2411
2501
|
// Remember the new workspace (restore target + recent-project entry).
|
|
2412
2502
|
this.stateStore.remember(this.clientId, abs);
|
|
2413
2503
|
void this.pushProjects();
|
|
@@ -2597,6 +2687,12 @@ export class AgentService {
|
|
|
2597
2687
|
onToolEvent = undefined;
|
|
2598
2688
|
/** index.ts 注入:读取插件当前注册的 AI 工具(attach 时拷贝到每个新会话)。 */
|
|
2599
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;
|
|
2600
2696
|
clients = new Map();
|
|
2601
2697
|
/** Quiesce (draining) state — the service refuses NEW work (prompts, forks,
|
|
2602
2698
|
* session resumes, new clients) so a deploy/upgrade/backup can stop cleanly
|
|
@@ -2611,6 +2707,9 @@ export class AgentService {
|
|
|
2611
2707
|
stateStore;
|
|
2612
2708
|
/** Set by index.ts: called when /pi-web-ui:quit is invoked. */
|
|
2613
2709
|
onQuit = undefined;
|
|
2710
|
+
/** 任意客户端成功切换工作区后触发(新绝对路径)。index.ts 接到
|
|
2711
|
+
* PluginManager.notifyCwd,让插件宿主的 host.cwd 实时跟随当前项目。 */
|
|
2712
|
+
onClientCwdChanged = undefined;
|
|
2614
2713
|
constructor(cwd, stateFile) {
|
|
2615
2714
|
this.cwd = cwd;
|
|
2616
2715
|
this.stateStore = new ClientStateStore(stateFile);
|
|
@@ -2725,7 +2824,14 @@ export class AgentService {
|
|
|
2725
2824
|
cs.onQuit = this.onQuit;
|
|
2726
2825
|
cs.onToolEvent = this.onToolEvent;
|
|
2727
2826
|
cs.pluginToolsProvider = this.pluginToolsProvider;
|
|
2827
|
+
cs.pluginCommandsProvider = this.pluginCommandsProvider;
|
|
2828
|
+
cs.pluginBgTasksProvider = this.pluginBgTasksProvider;
|
|
2829
|
+
cs.pluginStopBgTask = this.pluginStopBgTask;
|
|
2728
2830
|
cs.isQuiesced = () => this.quiesced;
|
|
2831
|
+
// 插件宿主工作区跟随:初次接入也同步一次(恢复的 lastCwd 可能≠服务启动目录),
|
|
2832
|
+
// notifyCwd 幂等去重;此后 set_cwd 成功时由 cs.onCwdChanged 继续驱动。
|
|
2833
|
+
cs.onCwdChanged = (abs) => this.onClientCwdChanged?.(abs);
|
|
2834
|
+
this.onClientCwdChanged?.(cs.cwd);
|
|
2729
2835
|
return cs;
|
|
2730
2836
|
}
|
|
2731
2837
|
/** 插件 AI 工具集合变化(注册/注销)时由 index.ts 触发:推送到所有客户端的全部会话。 */
|
|
@@ -2733,6 +2839,16 @@ export class AgentService {
|
|
|
2733
2839
|
for (const cs of this.clients.values())
|
|
2734
2840
|
cs.refreshPluginTools();
|
|
2735
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
|
+
}
|
|
2736
2852
|
/** Remove a socket from a client's broadcast set (called on socket close). */
|
|
2737
2853
|
detach(clientId, send) {
|
|
2738
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
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() {
|
package/dist/server/index.js
CHANGED
|
@@ -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,29 @@ 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);
|
|
399
|
+
// 插件宿主工作区实时跟随当前项目:任意客户端 set_cwd 成功后同步给
|
|
400
|
+
// PluginManager,编辑器等工作区跟随型插件随即切根(详见 plugins.ts notifyCwd)。
|
|
401
|
+
service.onClientCwdChanged = (cwd) => pluginMgr.notifyCwd(cwd);
|
|
377
402
|
// ---------------------------------------------------------------------------
|
|
378
403
|
// Self-update
|
|
379
404
|
// ---------------------------------------------------------------------------
|
|
@@ -509,7 +534,7 @@ wss.on("connection", (ws) => {
|
|
|
509
534
|
void cs.abortBash();
|
|
510
535
|
break;
|
|
511
536
|
case "kill_background_server":
|
|
512
|
-
void cs.killBackgroundServer(msg.port);
|
|
537
|
+
void cs.killBackgroundServer(msg.port, msg.taskId);
|
|
513
538
|
break;
|
|
514
539
|
case "kill_background_servers":
|
|
515
540
|
void cs.killAllBackgroundServers();
|
|
@@ -705,6 +730,16 @@ wss.on("connection", (ws) => {
|
|
|
705
730
|
case "plugin_message":
|
|
706
731
|
pluginMgr.handleMessage(msg.pluginId, msg.payload, clientId ?? undefined);
|
|
707
732
|
break;
|
|
733
|
+
case "plugin_settings": {
|
|
734
|
+
const r = pluginMgr.savePluginSettings(msg.pluginId, msg.values ?? {});
|
|
735
|
+
if (r.error) {
|
|
736
|
+
cs?.emitNotice("error", `插件设置保存失败:${r.error}`);
|
|
737
|
+
}
|
|
738
|
+
else {
|
|
739
|
+
cs?.emitNotice("info", "插件设置已保存");
|
|
740
|
+
}
|
|
741
|
+
break;
|
|
742
|
+
}
|
|
708
743
|
case "plugins_reload":
|
|
709
744
|
void pluginMgr.reload().then(() => pluginMgr.pushToAll());
|
|
710
745
|
break;
|
|
@@ -753,6 +788,9 @@ wss.on("connection", (ws) => {
|
|
|
753
788
|
// 让各插件向新接入的客户端推送自身初始状态(onAttach 钩子)——
|
|
754
789
|
// 插件不要依赖客户端挂载后自己拉(见 plugins.ts onAttach 注释)。
|
|
755
790
|
pluginMgr.notifyAttach(cid);
|
|
791
|
+
// 插件命令可能在本客户端 attach 过程中才注册(首载竞态)——
|
|
792
|
+
// 重推一次目录,保证选择器完整。
|
|
793
|
+
service.applyPluginCommandCatalog();
|
|
756
794
|
})
|
|
757
795
|
.catch(() => { });
|
|
758
796
|
// Replay anything that arrived while the session was starting.
|
|
@@ -845,6 +883,7 @@ async function shutdown() {
|
|
|
845
883
|
clearInterval(heartbeatTimer);
|
|
846
884
|
stopControl();
|
|
847
885
|
pluginMgr.dispose();
|
|
886
|
+
mcpBridge.dispose();
|
|
848
887
|
await service.disposeAll();
|
|
849
888
|
wss.close();
|
|
850
889
|
httpServer.close();
|