pi-web-ui 0.31.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.
@@ -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
+ }
@@ -8,4 +8,4 @@
8
8
  * its own copy in web/src/protocol-version.ts; scripts/check-protocol-sync.mjs
9
9
  * verifies the two never drift.
10
10
  */
11
- export const PROTOCOL_VERSION = 5;
11
+ export const PROTOCOL_VERSION = 7;
@@ -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),