pi-web-ui 0.36.0 → 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 +99 -1
- package/dist/server/attachments.js +91 -40
- package/dist/server/bg-servers.js +5 -2
- package/dist/server/index.js +40 -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 +448 -3
- package/dist/server/slash-commands.js +17 -1
- package/package.json +1 -1
- 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). */
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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,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();
|
|
@@ -708,6 +730,16 @@ wss.on("connection", (ws) => {
|
|
|
708
730
|
case "plugin_message":
|
|
709
731
|
pluginMgr.handleMessage(msg.pluginId, msg.payload, clientId ?? undefined);
|
|
710
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
|
+
}
|
|
711
743
|
case "plugins_reload":
|
|
712
744
|
void pluginMgr.reload().then(() => pluginMgr.pushToAll());
|
|
713
745
|
break;
|
|
@@ -756,6 +788,9 @@ wss.on("connection", (ws) => {
|
|
|
756
788
|
// 让各插件向新接入的客户端推送自身初始状态(onAttach 钩子)——
|
|
757
789
|
// 插件不要依赖客户端挂载后自己拉(见 plugins.ts onAttach 注释)。
|
|
758
790
|
pluginMgr.notifyAttach(cid);
|
|
791
|
+
// 插件命令可能在本客户端 attach 过程中才注册(首载竞态)——
|
|
792
|
+
// 重推一次目录,保证选择器完整。
|
|
793
|
+
service.applyPluginCommandCatalog();
|
|
759
794
|
})
|
|
760
795
|
.catch(() => { });
|
|
761
796
|
// Replay anything that arrived while the session was starting.
|
|
@@ -848,6 +883,7 @@ async function shutdown() {
|
|
|
848
883
|
clearInterval(heartbeatTimer);
|
|
849
884
|
stopControl();
|
|
850
885
|
pluginMgr.dispose();
|
|
886
|
+
mcpBridge.dispose();
|
|
851
887
|
await service.disposeAll();
|
|
852
888
|
wss.close();
|
|
853
889
|
httpServer.close();
|