pi-web-ui 0.32.0 → 0.34.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/LICENSE +21 -21
- package/README.md +295 -295
- package/README.zh-CN.md +279 -279
- package/bin/pi-web-ui.mjs +291 -2
- package/deploy/com.xingshuyin.pi-web-ui.plist +48 -48
- package/deploy/nginx-subpath.conf +88 -88
- package/deploy/pi-web-ui-task.xml +71 -71
- package/deploy/pi-web-ui.service +31 -31
- package/dist/server/agent-service.js +247 -15
- package/dist/server/attachments.js +3 -3
- package/dist/server/client-state.js +29 -0
- package/dist/server/index.js +61 -0
- package/dist/server/plugins.js +368 -0
- package/dist/server/protocol-version.js +1 -1
- package/dist/server/settings-service.js +19 -0
- package/dist/server/terminals.js +487 -8
- package/extensions/webui.ts +192 -192
- package/package.json +96 -96
- package/themes/md-preview.css +7026 -6911
- package/themes/white.css +7087 -6972
- package/web/dist/assets/{TerminalPanel-D9N7NOQl.js → TerminalPanel-DXxlbXLf.js} +1 -1
- package/web/dist/assets/index-8y0w43os.css +10 -0
- package/web/dist/assets/index-CNrY4Azz.js +19 -0
- package/web/dist/favicon.svg +8 -8
- package/web/dist/index.html +15 -15
- package/web/public/favicon.svg +8 -8
- package/web/dist/assets/index-C9LigvHH.css +0 -10
- package/web/dist/assets/index-vcrKbPaa.js +0 -19
|
@@ -97,8 +97,31 @@ export class ClientStateStore {
|
|
|
97
97
|
{ path: cwd, lastUsed: now },
|
|
98
98
|
...state.projects.filter((p) => p.path !== cwd),
|
|
99
99
|
].slice(0, 30);
|
|
100
|
+
// Opening the workspace again clears its removal tombstone.
|
|
101
|
+
if (state.removedProjects?.length) {
|
|
102
|
+
state.removedProjects = state.removedProjects.filter((p) => p !== cwd);
|
|
103
|
+
}
|
|
104
|
+
this.save();
|
|
105
|
+
}
|
|
106
|
+
/** Drop one workspace from the recent-project list (user-requested removal).
|
|
107
|
+
* Records a tombstone too: pushProjects() re-discovers cwds from session
|
|
108
|
+
* files on every listing, so without it the entry would instantly reappear. */
|
|
109
|
+
removeProject(clientId, cwd) {
|
|
110
|
+
const all = this.load();
|
|
111
|
+
const state = (all[clientId] ??= { projects: [] });
|
|
112
|
+
state.projects = state.projects.filter((p) => p.path !== cwd);
|
|
113
|
+
if (state.lastCwd === cwd)
|
|
114
|
+
delete state.lastCwd;
|
|
115
|
+
const removed = new Set(state.removedProjects ?? []);
|
|
116
|
+
removed.add(cwd);
|
|
117
|
+
state.removedProjects = [...removed];
|
|
100
118
|
this.save();
|
|
101
119
|
}
|
|
120
|
+
/** Tombstoned projects (explicitly removed by the user) for filtering the
|
|
121
|
+
* merged recent-project list. */
|
|
122
|
+
getRemovedProjects(clientId) {
|
|
123
|
+
return this.load()[clientId]?.removedProjects ?? [];
|
|
124
|
+
}
|
|
102
125
|
/** Last-used goal/review prefs for a client, or undefined if never set. */
|
|
103
126
|
getGoalPrefs(clientId) {
|
|
104
127
|
const s = this.load()[clientId];
|
|
@@ -152,12 +175,15 @@ export class ClientStateStore {
|
|
|
152
175
|
disabledSkills: s?.settings?.disabledSkills ?? [],
|
|
153
176
|
disabledExtensions: s?.settings?.disabledExtensions ?? [],
|
|
154
177
|
terminalToolsEnabled: s?.settings?.terminalToolsEnabled ?? true,
|
|
178
|
+
terminalBash: s?.settings?.terminalBash ?? false,
|
|
179
|
+
terminalBashIdleMs: s?.settings?.terminalBashIdleMs ?? 15_000,
|
|
155
180
|
visionBridgeEnabled: s?.settings?.visionBridgeEnabled ?? true,
|
|
156
181
|
visionBridgeModel: s?.settings?.visionBridgeModel ?? null,
|
|
157
182
|
visionBridgePromptMode: s?.settings?.visionBridgePromptMode === "replace" ? "replace" : "append",
|
|
158
183
|
visionBridgePrompt: s?.settings?.visionBridgePrompt ?? "",
|
|
159
184
|
reviewPrompt: s?.settings?.reviewPrompt ?? "",
|
|
160
185
|
reviewDisabledSkills: s?.settings?.reviewDisabledSkills ?? [],
|
|
186
|
+
disabledPlugins: s?.settings?.disabledPlugins ?? [],
|
|
161
187
|
};
|
|
162
188
|
}
|
|
163
189
|
/** Persist the client's settings-panel state (partial merge). */
|
|
@@ -171,6 +197,8 @@ export class ClientStateStore {
|
|
|
171
197
|
disabledSkills: settings.disabledSkills ?? cur.disabledSkills ?? [],
|
|
172
198
|
disabledExtensions: settings.disabledExtensions ?? cur.disabledExtensions ?? [],
|
|
173
199
|
terminalToolsEnabled: settings.terminalToolsEnabled ?? cur.terminalToolsEnabled ?? true,
|
|
200
|
+
terminalBash: settings.terminalBash ?? cur.terminalBash ?? false,
|
|
201
|
+
terminalBashIdleMs: settings.terminalBashIdleMs ?? cur.terminalBashIdleMs ?? 15_000,
|
|
174
202
|
visionBridgeEnabled: settings.visionBridgeEnabled ?? cur.visionBridgeEnabled ?? true,
|
|
175
203
|
visionBridgeModel: settings.visionBridgeModel ?? cur.visionBridgeModel ?? null,
|
|
176
204
|
visionBridgePromptMode: settings.visionBridgePromptMode ??
|
|
@@ -179,6 +207,7 @@ export class ClientStateStore {
|
|
|
179
207
|
visionBridgePrompt: settings.visionBridgePrompt ?? cur.visionBridgePrompt ?? "",
|
|
180
208
|
reviewPrompt: settings.reviewPrompt ?? cur.reviewPrompt ?? "",
|
|
181
209
|
reviewDisabledSkills: settings.reviewDisabledSkills ?? cur.reviewDisabledSkills ?? [],
|
|
210
|
+
disabledPlugins: settings.disabledPlugins ?? cur.disabledPlugins ?? [],
|
|
182
211
|
};
|
|
183
212
|
this.save();
|
|
184
213
|
}
|
package/dist/server/index.js
CHANGED
|
@@ -35,6 +35,7 @@ import { startControlServer } from "./control-socket.js";
|
|
|
35
35
|
import { scheduleUploadCleanup } from "./uploads.js";
|
|
36
36
|
import { ensureWindowsBash, windowsBashDir } from "./ensure-bash.js";
|
|
37
37
|
import { listThemes, resolveThemeFile } from "./themes.js";
|
|
38
|
+
import { PluginManager, resolvePluginClientFile } from "./plugins.js";
|
|
38
39
|
const PORT = Number(process.env.PORT ?? 8787);
|
|
39
40
|
const CWD = resolve(process.env.PI_WEB_CWD ?? process.cwd());
|
|
40
41
|
const DATA_DIR = resolve(process.env.PI_WEB_DATA_DIR ?? join(homedir(), ".pi-web"));
|
|
@@ -209,6 +210,30 @@ app.get("/themes/:id.css", (req, res) => {
|
|
|
209
210
|
res.setHeader("Cache-Control", "no-cache");
|
|
210
211
|
res.sendFile(file);
|
|
211
212
|
});
|
|
213
|
+
// Plugin client bundles: <dataDir>/plugins/<id>/client/* served at
|
|
214
|
+
// /plugins/<id>/client/* so the frontend can import() plugin views. Only the
|
|
215
|
+
// client/ subtree is exposed — manifest.json and the server-side index.mjs
|
|
216
|
+
// (which may hold credentials) never leave the machine. Registered BEFORE the
|
|
217
|
+
// SPA catch-all below.
|
|
218
|
+
const PLUGINS_DIR = join(DATA_DIR, "plugins");
|
|
219
|
+
app.get("/plugins/:id/client/*", (req, res) => {
|
|
220
|
+
// express 4 的通配参数在运行时落在 params[0],但类型声明里没有 —— 显式取
|
|
221
|
+
const rest = String(req.params[0] ?? "");
|
|
222
|
+
const abs = resolvePluginClientFile(PLUGINS_DIR, req.params.id, rest);
|
|
223
|
+
if (!abs) {
|
|
224
|
+
res.status(404).end("plugin not found");
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
// .mjs 常不在老 mime 表里,手动定 Content-Type 保证 import() 可用
|
|
228
|
+
if (/\.(mjs|js)$/.test(abs)) {
|
|
229
|
+
res.setHeader("Content-Type", "text/javascript; charset=utf-8");
|
|
230
|
+
}
|
|
231
|
+
res.setHeader("Cache-Control", "no-cache"); // 开发期改文件即生效
|
|
232
|
+
res.sendFile(abs, (err) => {
|
|
233
|
+
if (err && !res.headersSent)
|
|
234
|
+
res.status(err.statusCode === 404 ? 404 : 500).end("not found");
|
|
235
|
+
});
|
|
236
|
+
});
|
|
212
237
|
/** Set in the env of the replacement child spawned by a self-update restart. */
|
|
213
238
|
const RESTART_CHILD_ENV = "PI_WEB_RESTART_CHILD";
|
|
214
239
|
const webDist = join(pkgRoot, "web", "dist");
|
|
@@ -340,6 +365,15 @@ const heartbeatTimer = setInterval(() => {
|
|
|
340
365
|
const service = new AgentService(CWD,
|
|
341
366
|
// Per-client persisted UI state: last-used workspace + recent projects.
|
|
342
367
|
join(DATA_DIR, "client-state.json"));
|
|
368
|
+
// Optional UI plugins (<dataDir>/plugins/<id>/): scanned on every client
|
|
369
|
+
// attach so freshly dropped plugins appear without a server restart.
|
|
370
|
+
const pluginMgr = new PluginManager(DATA_DIR, CWD);
|
|
371
|
+
// 插件扩展点:SDK 工具执行事件(bash/读文件等 start+end)转发给已注册的插件。
|
|
372
|
+
service.onToolEvent = (ev) => pluginMgr.emitToolEvent(ev);
|
|
373
|
+
// 插件扩展点:插件注册的 AI 工具(registerAgentTool)→ 会话创建时带上 +
|
|
374
|
+
// 变化时动态注入/移除已有会话。
|
|
375
|
+
service.pluginToolsProvider = () => pluginMgr.getAgentTools();
|
|
376
|
+
pluginMgr.onAgentToolsChanged = () => service.applyPluginAgentTools();
|
|
343
377
|
// ---------------------------------------------------------------------------
|
|
344
378
|
// Self-update
|
|
345
379
|
// ---------------------------------------------------------------------------
|
|
@@ -449,6 +483,10 @@ wss.on("connection", (ws) => {
|
|
|
449
483
|
lastSnapshotBytes = wire.length * 2;
|
|
450
484
|
ws.send(wire);
|
|
451
485
|
};
|
|
486
|
+
// Plugins broadcast to every open socket; unregister on close below.
|
|
487
|
+
// Plugins broadcast to every open socket; unregister on close below. The
|
|
488
|
+
// cid getter lets plugins target THIS socket via host.sendTo(clientId).
|
|
489
|
+
const removePluginSender = pluginMgr.addSender(send, () => clientId);
|
|
452
490
|
const dispatch = (msg) => {
|
|
453
491
|
if (!clientId) {
|
|
454
492
|
pending.push(msg);
|
|
@@ -505,6 +543,12 @@ wss.on("connection", (ws) => {
|
|
|
505
543
|
case "list_projects":
|
|
506
544
|
void cs.pushProjects();
|
|
507
545
|
break;
|
|
546
|
+
case "remove_project":
|
|
547
|
+
void cs.removeProject(msg.path);
|
|
548
|
+
break;
|
|
549
|
+
case "delete_session":
|
|
550
|
+
void cs.deleteSession(msg.path);
|
|
551
|
+
break;
|
|
508
552
|
case "switch_session":
|
|
509
553
|
void cs.switchSession(msg.path);
|
|
510
554
|
break;
|
|
@@ -643,7 +687,10 @@ wss.on("connection", (ws) => {
|
|
|
643
687
|
customSystemPrompt: msg.customSystemPrompt,
|
|
644
688
|
disabledSkills: msg.disabledSkills,
|
|
645
689
|
disabledExtensions: msg.disabledExtensions,
|
|
690
|
+
disabledPlugins: msg.disabledPlugins,
|
|
646
691
|
terminalToolsEnabled: msg.terminalToolsEnabled,
|
|
692
|
+
terminalBash: msg.terminalBash,
|
|
693
|
+
terminalBashIdleMs: msg.terminalBashIdleMs,
|
|
647
694
|
visionBridgeEnabled: msg.visionBridgeEnabled,
|
|
648
695
|
visionBridgeModel: msg.visionBridgeModel,
|
|
649
696
|
visionBridgePromptMode: msg.visionBridgePromptMode,
|
|
@@ -655,6 +702,12 @@ wss.on("connection", (ws) => {
|
|
|
655
702
|
case "extensions_reload":
|
|
656
703
|
void cs.reloadExtensions();
|
|
657
704
|
break;
|
|
705
|
+
case "plugin_message":
|
|
706
|
+
pluginMgr.handleMessage(msg.pluginId, msg.payload, clientId ?? undefined);
|
|
707
|
+
break;
|
|
708
|
+
case "plugins_reload":
|
|
709
|
+
void pluginMgr.reload().then(() => pluginMgr.pushToAll());
|
|
710
|
+
break;
|
|
658
711
|
case "save_preset":
|
|
659
712
|
void cs.savePreset(msg.name);
|
|
660
713
|
break;
|
|
@@ -691,6 +744,12 @@ wss.on("connection", (ws) => {
|
|
|
691
744
|
protocolVersion: PROTOCOL_VERSION,
|
|
692
745
|
});
|
|
693
746
|
cs.flushSnapshot();
|
|
747
|
+
// Plugin catalog: re-scan + activate new dirs on every attach so
|
|
748
|
+
// freshly dropped plugins show up without a server restart.
|
|
749
|
+
pluginMgr
|
|
750
|
+
.ensureLoaded()
|
|
751
|
+
.then((plugins) => send({ type: "plugins", plugins, epoch: pluginMgr.epoch }))
|
|
752
|
+
.catch(() => { });
|
|
694
753
|
// Replay anything that arrived while the session was starting.
|
|
695
754
|
const queued = pending;
|
|
696
755
|
pending = [];
|
|
@@ -725,6 +784,7 @@ wss.on("connection", (ws) => {
|
|
|
725
784
|
service.noteSocketClose();
|
|
726
785
|
closed = true;
|
|
727
786
|
pending = [];
|
|
787
|
+
removePluginSender();
|
|
728
788
|
if (snapshotRetryTimer) {
|
|
729
789
|
clearTimeout(snapshotRetryTimer);
|
|
730
790
|
snapshotRetryTimer = null;
|
|
@@ -779,6 +839,7 @@ async function shutdown() {
|
|
|
779
839
|
console.log("\nshutting down…");
|
|
780
840
|
clearInterval(heartbeatTimer);
|
|
781
841
|
stopControl();
|
|
842
|
+
pluginMgr.dispose();
|
|
782
843
|
await service.disposeAll();
|
|
783
844
|
wss.close();
|
|
784
845
|
httpServer.close();
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-web-ui 插件管理器 —— 可选界面组件的加载与桥接。
|
|
3
|
+
*
|
|
4
|
+
* 一个插件 = <dataDir>/plugins/<id>/ 目录:
|
|
5
|
+
* manifest.json 元数据 { id?, name, version?, description? }(id 缺省取目录名)
|
|
6
|
+
* index.mjs 服务端入口(可选):export default { activate(host) → deactivate? }
|
|
7
|
+
* client/ 前端资源(可选),经 /plugins/<id>/client/* 以静态文件暴露;
|
|
8
|
+
* entry.mjs 视图入口:export default { mount(el, ctx) → cleanup? }
|
|
9
|
+
*
|
|
10
|
+
* 设计要点:
|
|
11
|
+
* - 不装即不存在:目录不在就没有任何协议/UI 痕迹;每次客户端 attach 时重扫目录,
|
|
12
|
+
* 新丢进来的插件无需重启服务即可出现在顶栏(import 只做一次并缓存)。
|
|
13
|
+
* - id 必须匹配 ID_RE,防路径穿越;client 静态服务同样逐段校验。
|
|
14
|
+
* - host 窄接口:broadcast(pluginId, payload) 广播 plugin_data、onMessage 注册
|
|
15
|
+
* 客户端上行处理、dataDir/cwd/log 环境。发送通道由 index.ts 注入(每个 socket
|
|
16
|
+
* 的 send 函数),插件本身不接触 ws。
|
|
17
|
+
* - activate 抛错只标记 error 字段并记日志,绝不影响主进程。
|
|
18
|
+
*/
|
|
19
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
20
|
+
import { existsSync } from "node:fs";
|
|
21
|
+
import { join, resolve, sep } from "node:path";
|
|
22
|
+
import { pathToFileURL } from "node:url";
|
|
23
|
+
/** 合法插件 id:字母/数字/下划线/连字符,防路径穿越(同 themes.ts 的做法)。 */
|
|
24
|
+
const ID_RE = /^[A-Za-z0-9_-]+$/;
|
|
25
|
+
export class PluginManager {
|
|
26
|
+
dataDir;
|
|
27
|
+
cwd;
|
|
28
|
+
loaded = new Map();
|
|
29
|
+
/** 已 import 过但无入口/失败的目录——避免重复 import 与重复报错。 */
|
|
30
|
+
attempted = new Set();
|
|
31
|
+
senders = new Set();
|
|
32
|
+
messageHandlers = new Map();
|
|
33
|
+
/** 插件注册的 AI 工具:pluginId → (name → 定义)。宿主经 agentTools() 读取。 */
|
|
34
|
+
agentTools = new Map();
|
|
35
|
+
/** AI 工具集合变化回调(index.ts 接到 AgentService,把新工具推入活跃会话)。 */
|
|
36
|
+
onAgentToolsChanged = undefined;
|
|
37
|
+
/** 服务端重载纪元:每次 reload() +1,前端用作 import 缓存击穿参数。 */
|
|
38
|
+
epochCounter = 0;
|
|
39
|
+
constructor(dataDir, cwd) {
|
|
40
|
+
this.dataDir = dataDir;
|
|
41
|
+
this.cwd = cwd;
|
|
42
|
+
}
|
|
43
|
+
get pluginsDir() {
|
|
44
|
+
return join(this.dataDir, "plugins");
|
|
45
|
+
}
|
|
46
|
+
/** 当前重载纪元(随 plugins 消息下发)。 */
|
|
47
|
+
get epoch() {
|
|
48
|
+
return this.epochCounter;
|
|
49
|
+
}
|
|
50
|
+
addSender(send, cid) {
|
|
51
|
+
const s = { cid, send };
|
|
52
|
+
this.senders.add(s);
|
|
53
|
+
return () => this.senders.delete(s);
|
|
54
|
+
}
|
|
55
|
+
/** 客户端上行:路由给对应插件的处理器;未知/未激活的插件静默丢弃。 */
|
|
56
|
+
handleMessage(pluginId, payload, from) {
|
|
57
|
+
if (!ID_RE.test(pluginId))
|
|
58
|
+
return;
|
|
59
|
+
const handlers = this.messageHandlers.get(pluginId);
|
|
60
|
+
if (!handlers)
|
|
61
|
+
return;
|
|
62
|
+
for (const h of handlers) {
|
|
63
|
+
try {
|
|
64
|
+
h(payload, from);
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
console.error(`[plugin:${pluginId}] message handler failed:`, err);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
broadcast(pluginId, payload) {
|
|
72
|
+
this.deliverAll({ type: "plugin_data", pluginId, payload });
|
|
73
|
+
}
|
|
74
|
+
/** 系统通知:发给所有 socket(复用 notice 消息,前端 toast 展示)。 */
|
|
75
|
+
notifyAll(level, text) {
|
|
76
|
+
this.deliverAll({ type: "notice", level, text });
|
|
77
|
+
}
|
|
78
|
+
/** 给指定客户端定向发一条插件消息;找不到该 socket 时静默忽略。 */
|
|
79
|
+
sendTo(clientId, pluginId, payload) {
|
|
80
|
+
for (const s of this.senders) {
|
|
81
|
+
if (s.cid() !== clientId)
|
|
82
|
+
continue;
|
|
83
|
+
try {
|
|
84
|
+
s.send({ type: "plugin_data", pluginId, payload });
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
/* dead socket */
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/** 目录清单 + 当前 epoch 推给所有 socket。 */
|
|
92
|
+
async pushToAll() {
|
|
93
|
+
const list = await this.scan();
|
|
94
|
+
this.deliverAll({ type: "plugins", plugins: list, epoch: this.epochCounter });
|
|
95
|
+
}
|
|
96
|
+
/** 服务端热重载:反激活全部 → 清缓存 → 重扫重激活 → epoch+1。
|
|
97
|
+
* 返回新目录清单(含激活结果)。 */
|
|
98
|
+
async reload() {
|
|
99
|
+
this.dispose();
|
|
100
|
+
this.attempted.clear();
|
|
101
|
+
this.epochCounter += 1;
|
|
102
|
+
return this.ensureLoaded();
|
|
103
|
+
}
|
|
104
|
+
/** agent-service 调:把 SDK 工具执行事件扇出给所有插件(异常隔离)。 */
|
|
105
|
+
emitToolEvent(ev) {
|
|
106
|
+
for (const p of this.loaded.values()) {
|
|
107
|
+
for (const h of p.toolHandlers) {
|
|
108
|
+
try {
|
|
109
|
+
h(ev);
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
console.error(`[plugin:${p.info.id}] tool-event handler failed:`, err);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/** 当前全部插件注册的 AI 工具(扁平化,按插件 id 稳定排序)。 */
|
|
118
|
+
getAgentTools() {
|
|
119
|
+
const out = [];
|
|
120
|
+
for (const table of [...this.agentTools.values()].sort())
|
|
121
|
+
out.push(...table.values());
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
/** 注册一个供 AI 调用的工具;重名拒绝并返回空操作注销函数。 */
|
|
125
|
+
registerAgentTool(pluginId, tool) {
|
|
126
|
+
if (!tool || typeof tool.execute !== "function" || !tool.name || !tool.description) {
|
|
127
|
+
console.error(`[plugin:${pluginId}] registerAgentTool: 缺少 name/description/execute,忽略`);
|
|
128
|
+
return () => { };
|
|
129
|
+
}
|
|
130
|
+
let table = this.agentTools.get(pluginId);
|
|
131
|
+
if (!table)
|
|
132
|
+
this.agentTools.set(pluginId, (table = new Map()));
|
|
133
|
+
if (table.has(tool.name)) {
|
|
134
|
+
console.error(`[plugin:${pluginId}] AI 工具 "${tool.name}" 重复注册,忽略`);
|
|
135
|
+
return () => { };
|
|
136
|
+
}
|
|
137
|
+
table.set(tool.name, tool);
|
|
138
|
+
console.log(`[plugin:${pluginId}] registered AI tool: ${tool.name}`);
|
|
139
|
+
try {
|
|
140
|
+
this.onAgentToolsChanged?.();
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
console.error("[plugins] onAgentToolsChanged failed:", err);
|
|
144
|
+
}
|
|
145
|
+
return () => {
|
|
146
|
+
if (table.delete(tool.name)) {
|
|
147
|
+
if (table.size === 0)
|
|
148
|
+
this.agentTools.delete(pluginId);
|
|
149
|
+
try {
|
|
150
|
+
this.onAgentToolsChanged?.();
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
/* shutting down */
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
deliverAll(msg) {
|
|
159
|
+
for (const s of this.senders) {
|
|
160
|
+
try {
|
|
161
|
+
s.send(msg);
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
/* dead socket — index.ts cleans it up */
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/** 当前目录清单(重扫 manifest,不重新 import)。 */
|
|
169
|
+
async list() {
|
|
170
|
+
return this.scan();
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* attach 时调用:重扫目录 + 激活尚未加载的新插件。
|
|
174
|
+
* 返回给浏览器的目录(含激活失败的条目,前端显示为不可用)。
|
|
175
|
+
*/
|
|
176
|
+
async ensureLoaded() {
|
|
177
|
+
const found = await this.scan();
|
|
178
|
+
for (const info of found) {
|
|
179
|
+
if (this.loaded.has(info.id) || this.attempted.has(info.id))
|
|
180
|
+
continue;
|
|
181
|
+
if (!existsSync(join(this.pluginsDir, info.id, "index.mjs")))
|
|
182
|
+
continue; // 纯前端插件
|
|
183
|
+
await this.activate(info);
|
|
184
|
+
}
|
|
185
|
+
// 已被删除的插件:调用 deactivate 并移出缓存
|
|
186
|
+
for (const [id, p] of [...this.loaded]) {
|
|
187
|
+
if (!found.some((f) => f.id === id)) {
|
|
188
|
+
this.deactivateEntry(id, p);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return found.map((f) => this.loaded.get(f.id)?.info ?? f);
|
|
192
|
+
}
|
|
193
|
+
/** 反激活单个插件:deactivate + 注销 AI 工具 + 清缓存。 */
|
|
194
|
+
deactivateEntry(id, p) {
|
|
195
|
+
try {
|
|
196
|
+
p.deactivate?.();
|
|
197
|
+
}
|
|
198
|
+
catch (err) {
|
|
199
|
+
console.error(`[plugin:${id}] deactivate failed:`, err);
|
|
200
|
+
}
|
|
201
|
+
for (const off of [...(p.agentToolUnsubscribers ?? [])]) {
|
|
202
|
+
try {
|
|
203
|
+
off();
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
/* already gone */
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
this.loaded.delete(id);
|
|
210
|
+
this.messageHandlers.delete(id);
|
|
211
|
+
console.log(`[plugin:${id}] removed`);
|
|
212
|
+
}
|
|
213
|
+
/** 关机时反激活全部插件。 */
|
|
214
|
+
dispose() {
|
|
215
|
+
for (const [id, p] of this.loaded) {
|
|
216
|
+
try {
|
|
217
|
+
p.deactivate?.();
|
|
218
|
+
}
|
|
219
|
+
catch (err) {
|
|
220
|
+
console.error(`[plugin:${id}] deactivate failed:`, err);
|
|
221
|
+
}
|
|
222
|
+
for (const off of [...(p.agentToolUnsubscribers ?? [])]) {
|
|
223
|
+
try {
|
|
224
|
+
off();
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
/* shutting down */
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
this.loaded.clear();
|
|
232
|
+
this.messageHandlers.clear();
|
|
233
|
+
}
|
|
234
|
+
/** 读 manifest 清单;坏目录(无 manifest/id 非法)直接跳过。 */
|
|
235
|
+
async scan() {
|
|
236
|
+
let names;
|
|
237
|
+
try {
|
|
238
|
+
names = await readdir(this.pluginsDir);
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
return []; // 目录不存在 = 没装任何插件
|
|
242
|
+
}
|
|
243
|
+
const out = [];
|
|
244
|
+
for (const name of names.sort()) {
|
|
245
|
+
if (!ID_RE.test(name))
|
|
246
|
+
continue;
|
|
247
|
+
const dir = join(this.pluginsDir, name);
|
|
248
|
+
try {
|
|
249
|
+
if (!(await stat(dir)).isDirectory())
|
|
250
|
+
continue;
|
|
251
|
+
const raw = await readFile(join(dir, "manifest.json"), "utf8");
|
|
252
|
+
const m = JSON.parse(raw);
|
|
253
|
+
out.push({
|
|
254
|
+
id: name,
|
|
255
|
+
name: typeof m.name === "string" && m.name ? m.name : name,
|
|
256
|
+
version: typeof m.version === "string" ? m.version : undefined,
|
|
257
|
+
description: typeof m.description === "string" ? m.description : undefined,
|
|
258
|
+
icon: typeof m.icon === "string" && m.icon.trim() ? m.icon.trim() : undefined,
|
|
259
|
+
hasClient: existsSync(join(dir, "client", "entry.mjs")),
|
|
260
|
+
error: this.loaded.get(name)?.info.error,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
continue; // 无 manifest / JSON 坏 —— 不是插件
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return out;
|
|
268
|
+
}
|
|
269
|
+
async activate(info) {
|
|
270
|
+
this.attempted.add(info.id);
|
|
271
|
+
const dir = join(this.pluginsDir, info.id);
|
|
272
|
+
const handlers = new Set();
|
|
273
|
+
this.messageHandlers.set(info.id, handlers);
|
|
274
|
+
const toolHandlers = new Set();
|
|
275
|
+
const unregisterTools = [];
|
|
276
|
+
const host = {
|
|
277
|
+
broadcast: (payload) => this.broadcast(info.id, payload),
|
|
278
|
+
notify: (level, text) => this.notifyAll(level, text),
|
|
279
|
+
sendTo: (clientId, payload) => this.sendTo(clientId, info.id, payload),
|
|
280
|
+
onMessage: (h) => {
|
|
281
|
+
handlers.add(h);
|
|
282
|
+
return () => handlers.delete(h);
|
|
283
|
+
},
|
|
284
|
+
onToolEvent: (h) => {
|
|
285
|
+
toolHandlers.add(h);
|
|
286
|
+
return () => toolHandlers.delete(h);
|
|
287
|
+
},
|
|
288
|
+
// 包一层:插件反激活时自动注销它注册的全部 AI 工具,不留悬挂项。
|
|
289
|
+
registerAgentTool: (tool) => {
|
|
290
|
+
const off = this.registerAgentTool(info.id, tool);
|
|
291
|
+
unregisterTools.push(off);
|
|
292
|
+
return () => {
|
|
293
|
+
const i = unregisterTools.indexOf(off);
|
|
294
|
+
if (i >= 0)
|
|
295
|
+
unregisterTools.splice(i, 1);
|
|
296
|
+
off();
|
|
297
|
+
};
|
|
298
|
+
},
|
|
299
|
+
dir,
|
|
300
|
+
dataDir: this.dataDir,
|
|
301
|
+
cwd: this.cwd,
|
|
302
|
+
log: (...args) => console.log(`[plugin:${info.id}]`, ...args),
|
|
303
|
+
};
|
|
304
|
+
try {
|
|
305
|
+
const mod = (await import(pathToFileURL(join(dir, "index.mjs")).href));
|
|
306
|
+
const ret = await mod.default?.activate?.(host);
|
|
307
|
+
this.loaded.set(info.id, {
|
|
308
|
+
info: { ...info },
|
|
309
|
+
deactivate: typeof ret === "function" ? ret : undefined,
|
|
310
|
+
toolHandlers,
|
|
311
|
+
agentToolUnsubscribers: unregisterTools,
|
|
312
|
+
});
|
|
313
|
+
console.log(`[plugin:${info.id}] activated (v${info.version ?? "?"})`);
|
|
314
|
+
}
|
|
315
|
+
catch (err) {
|
|
316
|
+
this.loaded.set(info.id, {
|
|
317
|
+
info: { ...info, error: err.message },
|
|
318
|
+
toolHandlers,
|
|
319
|
+
});
|
|
320
|
+
console.error(`[plugin:${info.id}] activate failed:`, err);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* 把 /plugins/:id/client/<rest> 安全映射到 <pluginsDir>/<id>/client/<rest>。
|
|
326
|
+
* 返回绝对路径;任何越界/非法 id 返回 null(调用方回 404)。
|
|
327
|
+
*/
|
|
328
|
+
export function resolvePluginClientFile(pluginsDir, id, rest) {
|
|
329
|
+
if (!ID_RE.test(id))
|
|
330
|
+
return null;
|
|
331
|
+
const root = resolve(join(pluginsDir, id, "client"));
|
|
332
|
+
// rest 由 express 路由保证不带 "..",但双保险:resolve 后必须仍在 root 内
|
|
333
|
+
const abs = resolve(root, rest);
|
|
334
|
+
if (abs !== root && !abs.startsWith(root + sep))
|
|
335
|
+
return null;
|
|
336
|
+
return abs;
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* 把插件 AI 工具定义同步进一个「会话状对象」(SDK AgentSession 的结构子集:
|
|
340
|
+
* 内部 _customTools 数组 + _refreshToolRegistry()——refresh 会重读数组,且新
|
|
341
|
+
* 工具名自动加入活跃集)。新增/更新/移除三向 diff;对象不兼容(SDK 改名)返回
|
|
342
|
+
* null 由调用方静默降级。返回新的已注入名单。
|
|
343
|
+
*
|
|
344
|
+
* 纯函数、不 import SDK —— vitest 直接测(tests/unit/plugin-tools.test.ts)。
|
|
345
|
+
*/
|
|
346
|
+
export function syncPluginToolsIntoSession(session, defs, prevNames) {
|
|
347
|
+
if (!Array.isArray(session._customTools) || typeof session._refreshToolRegistry !== "function")
|
|
348
|
+
return null;
|
|
349
|
+
const byName = new Map(session._customTools.map((d) => [d.name, d]));
|
|
350
|
+
let changed = false;
|
|
351
|
+
for (const d of defs) {
|
|
352
|
+
if (byName.get(d.name) !== d) {
|
|
353
|
+
byName.set(d.name, d);
|
|
354
|
+
changed = true;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
for (const name of prevNames) {
|
|
358
|
+
if (!defs.some((d) => d.name === name) && byName.has(name)) {
|
|
359
|
+
byName.delete(name);
|
|
360
|
+
changed = true;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
if (!changed)
|
|
364
|
+
return new Set(defs.map((d) => d.name));
|
|
365
|
+
session._customTools = [...byName.values()];
|
|
366
|
+
session._refreshToolRegistry();
|
|
367
|
+
return new Set(defs.map((d) => d.name));
|
|
368
|
+
}
|
|
@@ -100,16 +100,20 @@ export class SettingsService {
|
|
|
100
100
|
promptMode: this.settings.promptMode,
|
|
101
101
|
customSystemPrompt: this.settings.customSystemPrompt,
|
|
102
102
|
terminalToolsEnabled: this.settings.terminalToolsEnabled,
|
|
103
|
+
terminalBash: this.settings.terminalBash,
|
|
104
|
+
terminalBashIdleMs: this.settings.terminalBashIdleMs,
|
|
103
105
|
visionBridgeEnabled: this.settings.visionBridgeEnabled,
|
|
104
106
|
visionBridgeModel: this.settings.visionBridgeModel,
|
|
105
107
|
visionBridgePromptMode: this.settings.visionBridgePromptMode,
|
|
106
108
|
visionBridgePrompt: this.settings.visionBridgePrompt,
|
|
107
109
|
reviewPrompt: this.settings.reviewPrompt,
|
|
108
110
|
reviewDisabledSkills: [...this.settings.reviewDisabledSkills],
|
|
111
|
+
disabledPlugins: [...(this.settings.disabledPlugins ?? [])],
|
|
109
112
|
// The built-in prompts, so the replace-mode editors can prefill the
|
|
110
113
|
// text they would otherwise replace (empty until the resource-loader
|
|
111
114
|
// has run once for the system prompt).
|
|
112
115
|
defaultSystemPrompt: this.host.effectiveDefaultSystemPrompt(),
|
|
116
|
+
effectiveSystemPrompt: this.host.effectiveSystemPrompt(),
|
|
113
117
|
visionBridgeDefaultPrompt: SYSTEM_PROMPT,
|
|
114
118
|
visionModels: this.collectVisionModels(),
|
|
115
119
|
disabledSkills: [...this.settings.disabledSkills],
|
|
@@ -153,9 +157,19 @@ export class SettingsService {
|
|
|
153
157
|
if (partial.disabledExtensions !== undefined) {
|
|
154
158
|
this.settings.disabledExtensions = partial.disabledExtensions;
|
|
155
159
|
}
|
|
160
|
+
// 插件开关是纯 UI 隐藏(不进 needsReload——运行时无需重载)。
|
|
161
|
+
if (partial.disabledPlugins !== undefined) {
|
|
162
|
+
this.settings.disabledPlugins = partial.disabledPlugins;
|
|
163
|
+
}
|
|
156
164
|
if (partial.terminalToolsEnabled !== undefined) {
|
|
157
165
|
this.settings.terminalToolsEnabled = partial.terminalToolsEnabled;
|
|
158
166
|
}
|
|
167
|
+
if (partial.terminalBash !== undefined) {
|
|
168
|
+
this.settings.terminalBash = partial.terminalBash;
|
|
169
|
+
}
|
|
170
|
+
if (partial.terminalBashIdleMs !== undefined) {
|
|
171
|
+
this.settings.terminalBashIdleMs = Math.max(0, Math.floor(partial.terminalBashIdleMs) || 0);
|
|
172
|
+
}
|
|
159
173
|
if (partial.visionBridgeEnabled !== undefined) {
|
|
160
174
|
this.settings.visionBridgeEnabled = partial.visionBridgeEnabled;
|
|
161
175
|
}
|
|
@@ -193,6 +207,8 @@ export class SettingsService {
|
|
|
193
207
|
disabledSkills: [...this.settings.disabledSkills],
|
|
194
208
|
disabledExtensions: [...this.settings.disabledExtensions],
|
|
195
209
|
terminalToolsEnabled: this.settings.terminalToolsEnabled,
|
|
210
|
+
terminalBash: this.settings.terminalBash,
|
|
211
|
+
terminalBashIdleMs: this.settings.terminalBashIdleMs,
|
|
196
212
|
reviewPrompt: this.settings.reviewPrompt,
|
|
197
213
|
reviewDisabledSkills: [...this.settings.reviewDisabledSkills],
|
|
198
214
|
};
|
|
@@ -218,6 +234,9 @@ export class SettingsService {
|
|
|
218
234
|
disabledExtensions: [...p.disabledExtensions],
|
|
219
235
|
// 旧版持久化的预设可能没有该字段——保留当前值。
|
|
220
236
|
terminalToolsEnabled: p.terminalToolsEnabled ?? this.settings.terminalToolsEnabled,
|
|
237
|
+
// 终端接管偏好随预设走;旧预设缺字段时保留当前值。
|
|
238
|
+
terminalBash: p.terminalBash ?? this.settings.terminalBash,
|
|
239
|
+
terminalBashIdleMs: p.terminalBashIdleMs ?? this.settings.terminalBashIdleMs,
|
|
221
240
|
reviewPrompt: p.reviewPrompt ?? this.settings.reviewPrompt,
|
|
222
241
|
reviewDisabledSkills: [
|
|
223
242
|
...(p.reviewDisabledSkills ?? this.settings.reviewDisabledSkills),
|