pi-web-ui 0.36.0 → 0.44.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/pi-web-ui.mjs +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/client-state.js +2 -0
- package/dist/server/index.js +41 -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/settings-service.js +6 -0
- package/dist/server/slash-commands.js +17 -1
- package/package.json +1 -1
- package/themes/md-preview.css +86 -0
- package/themes/white.css +86 -0
- package/web/dist/assets/{TerminalPanel-BxezvWth.js → TerminalPanel-BiBJ27RP.js} +1 -1
- package/web/dist/assets/index-BByVm30o.css +10 -0
- package/web/dist/assets/index-Dys5fNYx.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/dist/server/plugins.js
CHANGED
|
@@ -17,11 +17,116 @@
|
|
|
17
17
|
* - activate 抛错只标记 error 字段并记日志,绝不影响主进程。
|
|
18
18
|
*/
|
|
19
19
|
import { readdir, readFile, stat } from "node:fs/promises";
|
|
20
|
-
import { existsSync } from "node:fs";
|
|
20
|
+
import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
21
21
|
import { join, resolve, sep } from "node:path";
|
|
22
22
|
import { pathToFileURL } from "node:url";
|
|
23
|
+
import { PluginStorage, PluginSecrets, ensurePluginDeps, WorkspaceFS } from "./plugin-facilities.js";
|
|
24
|
+
import { createHash } from "node:crypto";
|
|
23
25
|
/** 合法插件 id:字母/数字/下划线/连字符,防路径穿越(同 themes.ts 的做法)。 */
|
|
24
26
|
const ID_RE = /^[A-Za-z0-9_-]+$/;
|
|
27
|
+
/** 宿主提供的插件设施版本——manifest 声明的 apiVersion 高于此值则拒绝激活,
|
|
28
|
+
* 插件能拿到明确的「请升级 pi-web-ui」而不是在新接口上莫名 undefined。 */
|
|
29
|
+
export const PLUGIN_API_VERSION = 1;
|
|
30
|
+
/** 消息处理器超时:仅作为不再等待的日志阈值(响应由 handler 自己发出)。 */
|
|
31
|
+
const MESSAGE_TIMEOUT_MS = 30_000;
|
|
32
|
+
/** host.fs 被能力门控拒绝时的共享 rejected promise(类型对齐用)。 */
|
|
33
|
+
const NO_FS_PROMISE = Promise.reject(new Error('插件未声明能力 "fs"(manifest.permissions)——请求被拒'));
|
|
34
|
+
NO_FS_PROMISE.catch(() => { }); // 避免未处理 rejection 噪音;调用方 await 时拿到错误
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// 声明式设置 schema(manifest "settings")
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
const SETTING_TYPES = new Set(["text", "password", "number", "boolean", "select"]);
|
|
39
|
+
/** 解析 manifest.settings → 合法 schema(坏字段跳过,最多 32 个)。 */
|
|
40
|
+
function parseSettingsSchema(raw) {
|
|
41
|
+
if (!Array.isArray(raw))
|
|
42
|
+
return [];
|
|
43
|
+
const out = [];
|
|
44
|
+
for (const f of raw) {
|
|
45
|
+
if (!f || typeof f !== "object")
|
|
46
|
+
continue;
|
|
47
|
+
const o = f;
|
|
48
|
+
const key = typeof o.key === "string" ? o.key.trim() : "";
|
|
49
|
+
const type = typeof o.type === "string" ? o.type : "";
|
|
50
|
+
if (!key || !SETTING_TYPES.has(type) || out.some((x) => x.key === key))
|
|
51
|
+
continue;
|
|
52
|
+
const field = {
|
|
53
|
+
key,
|
|
54
|
+
type: type,
|
|
55
|
+
label: typeof o.label === "string" && o.label ? o.label : key,
|
|
56
|
+
...(o.default !== undefined ? { default: o.default } : {}),
|
|
57
|
+
...(typeof o.min === "number" ? { min: o.min } : {}),
|
|
58
|
+
...(typeof o.max === "number" ? { max: o.max } : {}),
|
|
59
|
+
...(Array.isArray(o.options)
|
|
60
|
+
? { options: o.options.filter((x) => typeof x === "string") }
|
|
61
|
+
: {}),
|
|
62
|
+
...(typeof o.hint === "string" ? { hint: o.hint } : {}),
|
|
63
|
+
};
|
|
64
|
+
out.push(field);
|
|
65
|
+
if (out.length >= 32)
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
/** 从 <pluginDir>/storage.json 读 settings 存值,按 schema 并默认值。 */
|
|
71
|
+
function storedSettingsValues(dir, schema) {
|
|
72
|
+
const out = {};
|
|
73
|
+
let stored = {};
|
|
74
|
+
try {
|
|
75
|
+
const parsed = JSON.parse(readFileSync(join(dir, "storage.json"), "utf8"));
|
|
76
|
+
if (parsed && typeof parsed === "object" && parsed.settings && typeof parsed.settings === "object") {
|
|
77
|
+
stored = parsed.settings;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
/* 无存储文件 = 全默认 */
|
|
82
|
+
}
|
|
83
|
+
for (const f of schema)
|
|
84
|
+
out[f.key] = stored[f.key] ?? f.default;
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
/** 校验并写回 settings(storage.json 的 settings 键,原子写);返回错误信息或 null。 */
|
|
88
|
+
function saveSettingsValues(dir, schema, values) {
|
|
89
|
+
const clean = {};
|
|
90
|
+
for (const f of schema) {
|
|
91
|
+
const v = values?.[f.key];
|
|
92
|
+
if (f.type === "number") {
|
|
93
|
+
const n = v === undefined ? Number(f.default ?? 0) : Number(v);
|
|
94
|
+
if (!Number.isFinite(n) || (f.min !== undefined && n < f.min) || (f.max !== undefined && n > f.max)) {
|
|
95
|
+
return { error: `${f.label} 超出范围`, clean };
|
|
96
|
+
}
|
|
97
|
+
clean[f.key] = n;
|
|
98
|
+
}
|
|
99
|
+
else if (f.type === "boolean") {
|
|
100
|
+
clean[f.key] = v === undefined ? Boolean(f.default) : Boolean(v);
|
|
101
|
+
}
|
|
102
|
+
else if (f.type === "select") {
|
|
103
|
+
if (v !== undefined && !f.options?.includes(String(v)))
|
|
104
|
+
return { error: `${f.label} 值非法`, clean };
|
|
105
|
+
clean[f.key] = v === undefined ? f.default : String(v);
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
clean[f.key] = v === undefined ? (f.default ?? "") : String(v);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
// 保留 storage.json 里其它键(插件自己的数据),只动 settings。
|
|
113
|
+
const file = join(dir, "storage.json");
|
|
114
|
+
let existing = {};
|
|
115
|
+
try {
|
|
116
|
+
existing = JSON.parse(readFileSync(file, "utf8"));
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
/* 首次 */
|
|
120
|
+
}
|
|
121
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
122
|
+
writeFileSync(tmp, JSON.stringify({ ...existing, settings: clean }));
|
|
123
|
+
renameSync(tmp, file);
|
|
124
|
+
}
|
|
125
|
+
catch (err) {
|
|
126
|
+
console.error(`[plugins] settings persist failed (${dir}):`, err);
|
|
127
|
+
}
|
|
128
|
+
return { clean };
|
|
129
|
+
}
|
|
25
130
|
export class PluginManager {
|
|
26
131
|
dataDir;
|
|
27
132
|
loaded = new Map();
|
|
@@ -33,6 +138,14 @@ export class PluginManager {
|
|
|
33
138
|
agentTools = new Map();
|
|
34
139
|
/** AI 工具集合变化回调(index.ts 接到 AgentService,把新工具推入活跃会话)。 */
|
|
35
140
|
onAgentToolsChanged = undefined;
|
|
141
|
+
/** 插件斜杠命令注册表:pluginId → (name → 定义)。宿主经 listCommands() 读取。 */
|
|
142
|
+
pluginCommands = new Map();
|
|
143
|
+
/** 命令集合变化回调(index.ts 接到 AgentService,刷新各客户端命令目录)。 */
|
|
144
|
+
onCommandsChanged = undefined;
|
|
145
|
+
/** 插件常驻任务:pluginId → Map<taskId, PluginBgTask>。宿主经 bgTasks() 读取。 */
|
|
146
|
+
pluginBgTasks = new Map();
|
|
147
|
+
/** 任务集合变化回调(index.ts 接到 AgentService,重推 bg_servers)。 */
|
|
148
|
+
onBgTasksChanged = undefined;
|
|
36
149
|
/** 服务端重载纪元:每次 reload() +1,前端用作 import 缓存击穿参数。 */
|
|
37
150
|
epochCounter = 0;
|
|
38
151
|
/** 当前全局工作区(host.cwd 的背后存储)——随 notifyCwd 更新。 */
|
|
@@ -62,6 +175,88 @@ export class PluginManager {
|
|
|
62
175
|
get pluginsDir() {
|
|
63
176
|
return join(this.dataDir, "plugins");
|
|
64
177
|
}
|
|
178
|
+
/** 全部插件注册的斜杠命令(按插件 id 稳定排序)。 */
|
|
179
|
+
listCommands() {
|
|
180
|
+
const out = [];
|
|
181
|
+
for (const id of [...this.pluginCommands.keys()].sort()) {
|
|
182
|
+
out.push(...this.pluginCommands.get(id).values());
|
|
183
|
+
}
|
|
184
|
+
return out;
|
|
185
|
+
}
|
|
186
|
+
/** 按名查找命令(供 prompt() 拦截执行;找不到返回 null)。 */
|
|
187
|
+
findCommand(name) {
|
|
188
|
+
for (const [pluginId, table] of this.pluginCommands) {
|
|
189
|
+
if (table.has(name))
|
|
190
|
+
return { def: table.get(name), pluginId };
|
|
191
|
+
}
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
/** 全部插件注册的常驻后台任务(扁平化为 BgServer 形状)。 */
|
|
195
|
+
bgTasks() {
|
|
196
|
+
const out = [];
|
|
197
|
+
for (const [pluginId, table] of this.pluginBgTasks) {
|
|
198
|
+
for (const t of table.values()) {
|
|
199
|
+
out.push({
|
|
200
|
+
taskId: t.id,
|
|
201
|
+
plugin: pluginId,
|
|
202
|
+
since: t.since,
|
|
203
|
+
name: t.label,
|
|
204
|
+
...(t.status ? { status: t.status } : {}),
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return out;
|
|
209
|
+
}
|
|
210
|
+
/** 停止一个插件任务(kill_background_server with taskId);返回是否命中。 */
|
|
211
|
+
stopPluginBgTask(taskId) {
|
|
212
|
+
for (const [pluginId, table] of this.pluginBgTasks) {
|
|
213
|
+
const t = table.get(taskId);
|
|
214
|
+
if (!t)
|
|
215
|
+
continue;
|
|
216
|
+
try {
|
|
217
|
+
t.stop?.();
|
|
218
|
+
}
|
|
219
|
+
catch (err) {
|
|
220
|
+
console.error(`[plugin:${pluginId}] background task ${taskId} stop failed:`, err);
|
|
221
|
+
}
|
|
222
|
+
table.delete(taskId);
|
|
223
|
+
if (table.size === 0)
|
|
224
|
+
this.pluginBgTasks.delete(pluginId);
|
|
225
|
+
try {
|
|
226
|
+
this.onBgTasksChanged?.();
|
|
227
|
+
}
|
|
228
|
+
catch { }
|
|
229
|
+
return true;
|
|
230
|
+
}
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
/** 保存某插件的声明式设置(⚙ 面板 → plugin_settings 消息):按 schema 校验、
|
|
234
|
+
* 原子写 storage.json 的 settings 键、通知插件 onSettingsChanged、重推清单
|
|
235
|
+
* 让前端回显。返回错误信息或 null(成功)。 */
|
|
236
|
+
savePluginSettings(pluginId, values) {
|
|
237
|
+
if (!ID_RE.test(pluginId))
|
|
238
|
+
return { error: "非法的插件 id" };
|
|
239
|
+
const dir = join(this.pluginsDir, pluginId);
|
|
240
|
+
const info = this.loaded.get(pluginId)?.info;
|
|
241
|
+
const schema = info?.settingsSchema ?? [];
|
|
242
|
+
if (!schema.length)
|
|
243
|
+
return { error: "该插件没有声明式设置(manifest 未声明 settings)" };
|
|
244
|
+
const { error, clean } = saveSettingsValues(dir, schema, values);
|
|
245
|
+
if (error)
|
|
246
|
+
return { error };
|
|
247
|
+
// 通知插件(异常隔离)
|
|
248
|
+
for (const h of this.loaded.get(pluginId)?.settingsHandlers ?? []) {
|
|
249
|
+
try {
|
|
250
|
+
h(clean);
|
|
251
|
+
}
|
|
252
|
+
catch (err) {
|
|
253
|
+
console.error(`[plugin:${pluginId}] onSettingsChanged handler failed:`, err);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
// 重推 plugins 清单(含新 settingsValues),前端回显。
|
|
257
|
+
void this.pushToAll().catch(() => { });
|
|
258
|
+
return {};
|
|
259
|
+
}
|
|
65
260
|
/** 当前重载纪元(随 plugins 消息下发)。 */
|
|
66
261
|
get epoch() {
|
|
67
262
|
return this.epochCounter;
|
|
@@ -87,6 +282,12 @@ export class PluginManager {
|
|
|
87
282
|
ret.catch((err) => {
|
|
88
283
|
console.error(`[plugin:${pluginId}] async message handler failed:`, err);
|
|
89
284
|
});
|
|
285
|
+
// 超时护栏:响应由 handler 自己 sendTo/broadcast 发出,超时只是记
|
|
286
|
+
// 日志不再等待——绝不能让单条消息把客户端 pending 管线无限拖死。
|
|
287
|
+
const timer = setTimeout(() => {
|
|
288
|
+
console.error(`[plugin:${pluginId}] message handler 超时(>${MESSAGE_TIMEOUT_MS}ms),已不再等待`);
|
|
289
|
+
}, MESSAGE_TIMEOUT_MS);
|
|
290
|
+
void ret.finally(() => clearTimeout(timer));
|
|
90
291
|
}
|
|
91
292
|
}
|
|
92
293
|
catch (err) {
|
|
@@ -94,6 +295,53 @@ export class PluginManager {
|
|
|
94
295
|
}
|
|
95
296
|
}
|
|
96
297
|
}
|
|
298
|
+
/** 首次安装/能力变更时提醒在线用户(marker 文件记录上次激活时的声明)。 */
|
|
299
|
+
async maybeConsentNotice(info, dir, perms) {
|
|
300
|
+
try {
|
|
301
|
+
const markerFile = join(dir, ".pi-approved");
|
|
302
|
+
const key = createHash("sha256").update(JSON.stringify(perms)).digest("hex").slice(0, 32);
|
|
303
|
+
let prev = "";
|
|
304
|
+
try {
|
|
305
|
+
prev = JSON.parse(readFileSync(markerFile, "utf8"))?.key ?? "";
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
/* 无 marker = 首次安装 */
|
|
309
|
+
}
|
|
310
|
+
if (prev === key)
|
|
311
|
+
return; // 同版本能力清单,不再打扰
|
|
312
|
+
const list = perms.length ? perms.join(", ") : "无";
|
|
313
|
+
this.notifyAll(perms.length ? "warning" : "info", `插件「${info.name}」已激活(${prev ? "能力清单变更" : "首次安装"};声明能力:${list})——请确认来源可信`);
|
|
314
|
+
writeFileSync(markerFile, JSON.stringify({ v: 1, key, perms }), "utf8");
|
|
315
|
+
}
|
|
316
|
+
catch (err) {
|
|
317
|
+
console.error(`[plugin:${info.id}] consent notice failed:`, err);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
/** index.ts 的 /plugins-api/:id/* 挂载点转发到这里:找到对应插件的已注册
|
|
321
|
+
* 路由并执行;未知插件/路径 → 404,handler 抛错 → 500(不炸进程)。 */
|
|
322
|
+
handleHttp(pluginId, method, pathIn, req, res) {
|
|
323
|
+
if (!ID_RE.test(pluginId)) {
|
|
324
|
+
res.status(404).end("plugin not found");
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
const table = this.loaded.get(pluginId)?.httpRoutes;
|
|
328
|
+
const path = "/" + pathIn.replace(/^\/+/, "");
|
|
329
|
+
const handler = table?.get(`${method.toUpperCase()} ${path}`);
|
|
330
|
+
if (!handler) {
|
|
331
|
+
res.status(404).end("not found");
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
try {
|
|
335
|
+
handler(req, res);
|
|
336
|
+
}
|
|
337
|
+
catch (err) {
|
|
338
|
+
console.error(`[plugin:${pluginId}] http ${method} ${path} failed:`, err);
|
|
339
|
+
if (!res.headersSent)
|
|
340
|
+
res.status(500).end("internal error");
|
|
341
|
+
else
|
|
342
|
+
res.end();
|
|
343
|
+
}
|
|
344
|
+
}
|
|
97
345
|
broadcast(pluginId, payload) {
|
|
98
346
|
this.deliverAll({ type: "plugin_data", pluginId, payload });
|
|
99
347
|
}
|
|
@@ -202,6 +450,52 @@ export class PluginManager {
|
|
|
202
450
|
}
|
|
203
451
|
};
|
|
204
452
|
}
|
|
453
|
+
/** 注册斜杠命令:跨插件重名拒绝(先注册者胜出),onCommandsChanged 通知目录刷新。 */
|
|
454
|
+
registerCommand(pluginId, cmd) {
|
|
455
|
+
const name = String(cmd?.name ?? "").replace(/^\/+/, ""); // 容忍误带的前导 /
|
|
456
|
+
if (!/^[a-zA-Z][a-zA-Z0-9:_-]*$/.test(name)) {
|
|
457
|
+
console.error(`[plugin:${pluginId}] registerCommand: 非法名称「${cmd?.name}」(需字母开头,允许字母数字:_-),忽略`);
|
|
458
|
+
return () => { };
|
|
459
|
+
}
|
|
460
|
+
if (typeof cmd?.run !== "function") {
|
|
461
|
+
console.error(`[plugin:${pluginId}] registerCommand: ${name} 缺少 run,忽略`);
|
|
462
|
+
return () => { };
|
|
463
|
+
}
|
|
464
|
+
for (const [pid, table] of this.pluginCommands) {
|
|
465
|
+
if (table.has(name) && pid !== pluginId) {
|
|
466
|
+
console.error(`[plugin:${pluginId}] 命令 /${name} 已被插件 ${pid} 注册,忽略重复`);
|
|
467
|
+
return () => { };
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
let table = this.pluginCommands.get(pluginId);
|
|
471
|
+
if (!table)
|
|
472
|
+
this.pluginCommands.set(pluginId, (table = new Map()));
|
|
473
|
+
if (table.has(name)) {
|
|
474
|
+
console.error(`[plugin:${pluginId}] 命令 /${name} 重复注册,忽略`);
|
|
475
|
+
return () => { };
|
|
476
|
+
}
|
|
477
|
+
const def = { ...cmd, name };
|
|
478
|
+
table.set(name, def);
|
|
479
|
+
console.log(`[plugin:${pluginId}] registered command: /${name}`);
|
|
480
|
+
try {
|
|
481
|
+
this.onCommandsChanged?.();
|
|
482
|
+
}
|
|
483
|
+
catch (err) {
|
|
484
|
+
console.error("[plugins] onCommandsChanged failed:", err);
|
|
485
|
+
}
|
|
486
|
+
return () => {
|
|
487
|
+
if (table.delete(name)) {
|
|
488
|
+
if (table.size === 0)
|
|
489
|
+
this.pluginCommands.delete(pluginId);
|
|
490
|
+
try {
|
|
491
|
+
this.onCommandsChanged?.();
|
|
492
|
+
}
|
|
493
|
+
catch {
|
|
494
|
+
/* shutting down */
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
};
|
|
498
|
+
}
|
|
205
499
|
deliverAll(msg) {
|
|
206
500
|
for (const s of this.senders) {
|
|
207
501
|
try {
|
|
@@ -266,7 +560,7 @@ export class PluginManager {
|
|
|
266
560
|
catch (err) {
|
|
267
561
|
console.error(`[plugin:${id}] deactivate failed:`, err);
|
|
268
562
|
}
|
|
269
|
-
for (const off of [...(p.agentToolUnsubscribers ?? [])]) {
|
|
563
|
+
for (const off of [...(p.agentToolUnsubscribers ?? []), ...(p.commandUnsubscribers ?? [])]) {
|
|
270
564
|
try {
|
|
271
565
|
off();
|
|
272
566
|
}
|
|
@@ -274,7 +568,15 @@ export class PluginManager {
|
|
|
274
568
|
/* shutting down */
|
|
275
569
|
}
|
|
276
570
|
}
|
|
571
|
+
// 反激活时停掉它注册的常驻后台任务(轮询器等),不留孤儿计时器。
|
|
572
|
+
for (const t of this.pluginBgTasks.get(id)?.values() ?? []) {
|
|
573
|
+
try {
|
|
574
|
+
t.stop?.();
|
|
575
|
+
}
|
|
576
|
+
catch { }
|
|
577
|
+
}
|
|
277
578
|
}
|
|
579
|
+
this.pluginBgTasks.clear();
|
|
278
580
|
this.loaded.clear();
|
|
279
581
|
this.messageHandlers.clear();
|
|
280
582
|
}
|
|
@@ -305,6 +607,13 @@ export class PluginManager {
|
|
|
305
607
|
icon: typeof m.icon === "string" && m.icon.trim() ? m.icon.trim() : undefined,
|
|
306
608
|
hasClient: existsSync(join(dir, "client", "entry.mjs")),
|
|
307
609
|
error: this.loaded.get(name)?.info.error,
|
|
610
|
+
// manifest 声明的能力清单(fs/net/tools…)——设置面板展示用
|
|
611
|
+
permissions: Array.isArray(m.permissions)
|
|
612
|
+
? m.permissions.filter((p) => typeof p === "string" && p.length > 0).slice(0, 16)
|
|
613
|
+
: undefined,
|
|
614
|
+
// 声明式设置 schema + 当前存值(⚙ 面板自动渲染表单用)
|
|
615
|
+
settingsSchema: parseSettingsSchema(m.settings),
|
|
616
|
+
settingsValues: storedSettingsValues(dir, parseSettingsSchema(m.settings)),
|
|
308
617
|
// 安装来源(pi-web-ui install 写入的 .pi-source.json)——
|
|
309
618
|
// 设置面板据此显示「更新」按钮;手工拷入的插件没有此文件。
|
|
310
619
|
source: await readFile(join(dir, ".pi-source.json"), "utf8")
|
|
@@ -334,8 +643,53 @@ export class PluginManager {
|
|
|
334
643
|
const toolHandlers = new Set();
|
|
335
644
|
const attachHandlers = new Set();
|
|
336
645
|
const cwdHandlers = new Set();
|
|
646
|
+
const httpRoutes = new Map();
|
|
337
647
|
const unregisterTools = [];
|
|
338
|
-
const
|
|
648
|
+
const unregisterCommands = [];
|
|
649
|
+
const bgTaskTable = new Map();
|
|
650
|
+
const settingsHandlers = new Set();
|
|
651
|
+
// 宿主 API 版本协商:插件要的比宿主新 → 明确拒绝(而不是让它在运行期
|
|
652
|
+
// 撞 undefined 接口莫名其妙地坏)。与激活失败同一处理:error 字段 + 置灰。
|
|
653
|
+
let apiVersion = 1;
|
|
654
|
+
try {
|
|
655
|
+
apiVersion = Number(JSON.parse(readFileSync(join(dir, "manifest.json"), "utf8")).apiVersion ?? 1) || 1;
|
|
656
|
+
}
|
|
657
|
+
catch { }
|
|
658
|
+
if (apiVersion > PLUGIN_API_VERSION) {
|
|
659
|
+
const msg = `插件要求宿主 API v${apiVersion},当前宿主 v${PLUGIN_API_VERSION} —— 请升级 pi-web-ui`;
|
|
660
|
+
console.error(`[plugin:${info.id}] ${msg}`);
|
|
661
|
+
this.loaded.set(info.id, { info: { ...info, error: msg }, toolHandlers, attachHandlers, cwdHandlers, httpRoutes, settingsHandlers: new Set() });
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
// 能力声明:写了 permissions → 严格模式(受控宿主 API 按声明族强制执行);
|
|
665
|
+
// 未写且 apiVersion < 2 → 旧全权模式(首次使用受控 API 时警告一次,v2 起默认拒绝)。
|
|
666
|
+
const permsDeclared = (info.permissions ?? []).slice();
|
|
667
|
+
const strict = permsDeclared.length > 0 || apiVersion >= 2;
|
|
668
|
+
const permFamilies = new Set(permsDeclared.map((x) => x.split(":")[0]));
|
|
669
|
+
const p = { info, toolHandlers, attachHandlers, cwdHandlers, commandUnsubscribers: unregisterCommands, httpRoutes, settingsHandlers };
|
|
670
|
+
p.permsDeclared = permsDeclared;
|
|
671
|
+
p.permFamilies = permFamilies;
|
|
672
|
+
p.legacyWarned = false;
|
|
673
|
+
// 每插件的私有设施:KV 存储 + 加密 secrets + 依赖自动补装(单飞)。
|
|
674
|
+
const storage = new PluginStorage(join(dir, "storage.json"));
|
|
675
|
+
const secrets = new PluginSecrets(this.dataDir, dir);
|
|
676
|
+
// 受限工作区文件访问(能力 "fs" 门控;根随 set_cwd 活值移动)。
|
|
677
|
+
const workspaceFs = new WorkspaceFS(() => self.cwdValue);
|
|
678
|
+
/** 能力门控:严格模式下查声明族;旧模式放行但每个激活期只警告一次。
|
|
679
|
+
* 返回 false = 已记日志,调用方应拒绝。 */
|
|
680
|
+
const can = (family) => {
|
|
681
|
+
if (permFamilies.has(family))
|
|
682
|
+
return true;
|
|
683
|
+
if (!strict) {
|
|
684
|
+
if (!p.legacyWarned) {
|
|
685
|
+
p.legacyWarned = true;
|
|
686
|
+
console.warn(`[plugin:${info.id}] manifest 未声明 permissions(旧格式全权模式)——已放行 "${family}";apiVersion 2 起将默认拒绝,请尽快声明`);
|
|
687
|
+
}
|
|
688
|
+
return true;
|
|
689
|
+
}
|
|
690
|
+
console.error(`[plugin:${info.id}] 缺少能力声明 "${family}"(manifest.permissions)——请求被拒`);
|
|
691
|
+
return false;
|
|
692
|
+
};
|
|
339
693
|
const self = this; // 对象字面量 getter 里不能用插件宿主的 this
|
|
340
694
|
const host = {
|
|
341
695
|
broadcast: (payload) => this.broadcast(info.id, payload),
|
|
@@ -357,8 +711,34 @@ export class PluginManager {
|
|
|
357
711
|
cwdHandlers.add(h);
|
|
358
712
|
return () => cwdHandlers.delete(h);
|
|
359
713
|
},
|
|
714
|
+
registerCommand: (cmd) => {
|
|
715
|
+
const off = this.registerCommand(info.id, cmd);
|
|
716
|
+
unregisterCommands.push(off);
|
|
717
|
+
return () => {
|
|
718
|
+
const i = unregisterCommands.indexOf(off);
|
|
719
|
+
if (i >= 0)
|
|
720
|
+
unregisterCommands.splice(i, 1);
|
|
721
|
+
off();
|
|
722
|
+
};
|
|
723
|
+
},
|
|
724
|
+
storage,
|
|
725
|
+
secrets,
|
|
726
|
+
ensureDeps: (specs, opts) => ensurePluginDeps(dir, specs ?? [], opts?.onProgress),
|
|
727
|
+
route: (method, path, handler) => {
|
|
728
|
+
if (!can("http"))
|
|
729
|
+
return () => { };
|
|
730
|
+
const m = String(method ?? "GET").toUpperCase();
|
|
731
|
+
if (!["GET", "POST", "PUT", "DELETE"].includes(m) || typeof path !== "string" || !path.startsWith("/") || typeof handler !== "function") {
|
|
732
|
+
console.error(`[plugin:${info.id}] route: 非法参数(method=${method} path=${path}),忽略`);
|
|
733
|
+
return () => { };
|
|
734
|
+
}
|
|
735
|
+
httpRoutes.set(`${m} ${path}`, handler);
|
|
736
|
+
return () => httpRoutes.delete(`${m} ${path}`);
|
|
737
|
+
},
|
|
360
738
|
// 包一层:插件反激活时自动注销它注册的全部 AI 工具,不留悬挂项。
|
|
361
739
|
registerAgentTool: (tool) => {
|
|
740
|
+
if (!can("tools"))
|
|
741
|
+
return () => { };
|
|
362
742
|
const off = this.registerAgentTool(info.id, tool);
|
|
363
743
|
unregisterTools.push(off);
|
|
364
744
|
return () => {
|
|
@@ -373,6 +753,61 @@ export class PluginManager {
|
|
|
373
753
|
get cwd() {
|
|
374
754
|
return self.cwdValue;
|
|
375
755
|
},
|
|
756
|
+
fs: {
|
|
757
|
+
list: (relDir) => (can("fs") ? workspaceFs.list(relDir) : NO_FS_PROMISE),
|
|
758
|
+
read: (p) => (can("fs") ? workspaceFs.read(p) : NO_FS_PROMISE),
|
|
759
|
+
readText: (p, max) => (can("fs") ? workspaceFs.readText(p, max) : NO_FS_PROMISE),
|
|
760
|
+
write: (p, data) => (can("fs") ? workspaceFs.write(p, data) : NO_FS_PROMISE),
|
|
761
|
+
remove: (p) => (can("fs") ? workspaceFs.remove(p) : NO_FS_PROMISE),
|
|
762
|
+
},
|
|
763
|
+
registerBackgroundTask: (task) => {
|
|
764
|
+
const id = String(task?.id ?? "").trim();
|
|
765
|
+
if (!id || bgTaskTable.has(id)) {
|
|
766
|
+
console.error(`[plugin:${info.id}] registerBackgroundTask: 非法/重复 id「${task?.id}」,忽略`);
|
|
767
|
+
return { update: () => { }, unregister: () => { } };
|
|
768
|
+
}
|
|
769
|
+
const entry = {
|
|
770
|
+
id,
|
|
771
|
+
label: String(task?.label ?? id),
|
|
772
|
+
since: Date.now(),
|
|
773
|
+
...(typeof task?.stop === "function" ? { stop: task.stop } : {}),
|
|
774
|
+
...(typeof task?.status === "string" ? { status: task.status } : {}),
|
|
775
|
+
};
|
|
776
|
+
bgTaskTable.set(id, entry);
|
|
777
|
+
this.pluginBgTasks.set(info.id, bgTaskTable);
|
|
778
|
+
const fire = () => {
|
|
779
|
+
try {
|
|
780
|
+
this.onBgTasksChanged?.();
|
|
781
|
+
}
|
|
782
|
+
catch { }
|
|
783
|
+
};
|
|
784
|
+
fire();
|
|
785
|
+
return {
|
|
786
|
+
update: (next) => {
|
|
787
|
+
if (!bgTaskTable.has(id))
|
|
788
|
+
return;
|
|
789
|
+
if (next.label !== undefined)
|
|
790
|
+
entry.label = String(next.label);
|
|
791
|
+
if (next.status !== undefined)
|
|
792
|
+
entry.status = next.status;
|
|
793
|
+
if (typeof next.stop === "function")
|
|
794
|
+
entry.stop = next.stop;
|
|
795
|
+
fire();
|
|
796
|
+
},
|
|
797
|
+
unregister: () => {
|
|
798
|
+
if (bgTaskTable.delete(id)) {
|
|
799
|
+
if (bgTaskTable.size === 0)
|
|
800
|
+
this.pluginBgTasks.delete(info.id);
|
|
801
|
+
fire();
|
|
802
|
+
}
|
|
803
|
+
},
|
|
804
|
+
};
|
|
805
|
+
},
|
|
806
|
+
getSettings: () => storedSettingsValues(dir, info.settingsSchema ?? []),
|
|
807
|
+
onSettingsChanged: (h) => {
|
|
808
|
+
settingsHandlers.add(h);
|
|
809
|
+
return () => settingsHandlers.delete(h);
|
|
810
|
+
},
|
|
376
811
|
log: (...args) => console.log(`[plugin:${info.id}]`, ...args),
|
|
377
812
|
};
|
|
378
813
|
try {
|
|
@@ -387,15 +822,25 @@ export class PluginManager {
|
|
|
387
822
|
attachHandlers,
|
|
388
823
|
cwdHandlers,
|
|
389
824
|
agentToolUnsubscribers: unregisterTools,
|
|
825
|
+
commandUnsubscribers: unregisterCommands,
|
|
826
|
+
httpRoutes,
|
|
827
|
+
settingsHandlers,
|
|
390
828
|
});
|
|
391
829
|
console.log(`[plugin:${info.id}] activated (v${info.version ?? "?"})`);
|
|
830
|
+
// 首次安装/能力变更提醒(尽力而为):<dir>/.pi-approved 记录上次激活时
|
|
831
|
+
// 的能力清单——新装或 permissions 变更后向在线客户端推一条警告通知,
|
|
832
|
+
// 用户装前可见、日常启动不打扰。
|
|
833
|
+
void this.maybeConsentNotice(info, dir, permsDeclared);
|
|
392
834
|
}
|
|
393
835
|
catch (err) {
|
|
836
|
+
httpRoutes.clear();
|
|
394
837
|
this.loaded.set(info.id, {
|
|
395
838
|
info: { ...info, error: err.message },
|
|
396
839
|
toolHandlers,
|
|
397
840
|
attachHandlers,
|
|
398
841
|
cwdHandlers,
|
|
842
|
+
httpRoutes,
|
|
843
|
+
settingsHandlers: new Set(),
|
|
399
844
|
});
|
|
400
845
|
console.error(`[plugin:${info.id}] activate failed:`, err);
|
|
401
846
|
}
|
|
@@ -102,6 +102,7 @@ export class SettingsService {
|
|
|
102
102
|
terminalToolsEnabled: this.settings.terminalToolsEnabled,
|
|
103
103
|
terminalBash: this.settings.terminalBash,
|
|
104
104
|
terminalBashIdleMs: this.settings.terminalBashIdleMs,
|
|
105
|
+
thinkingWrap: this.settings.thinkingWrap,
|
|
105
106
|
visionBridgeEnabled: this.settings.visionBridgeEnabled,
|
|
106
107
|
visionBridgeModel: this.settings.visionBridgeModel,
|
|
107
108
|
visionBridgePromptMode: this.settings.visionBridgePromptMode,
|
|
@@ -170,6 +171,9 @@ export class SettingsService {
|
|
|
170
171
|
if (partial.terminalBashIdleMs !== undefined) {
|
|
171
172
|
this.settings.terminalBashIdleMs = Math.max(0, Math.floor(partial.terminalBashIdleMs) || 0);
|
|
172
173
|
}
|
|
174
|
+
if (partial.thinkingWrap !== undefined) {
|
|
175
|
+
this.settings.thinkingWrap = partial.thinkingWrap;
|
|
176
|
+
}
|
|
173
177
|
if (partial.visionBridgeEnabled !== undefined) {
|
|
174
178
|
this.settings.visionBridgeEnabled = partial.visionBridgeEnabled;
|
|
175
179
|
}
|
|
@@ -243,6 +247,8 @@ export class SettingsService {
|
|
|
243
247
|
],
|
|
244
248
|
// Presets don't capture vision-bridge prefs — keep the current ones.
|
|
245
249
|
visionBridgeEnabled: this.settings.visionBridgeEnabled,
|
|
250
|
+
// 纯 UI 偏好不进预设——保留当前值。
|
|
251
|
+
thinkingWrap: this.settings.thinkingWrap,
|
|
246
252
|
visionBridgeModel: this.settings.visionBridgeModel,
|
|
247
253
|
visionBridgePromptMode: this.settings.visionBridgePromptMode,
|
|
248
254
|
visionBridgePrompt: this.settings.visionBridgePrompt,
|
|
@@ -84,11 +84,26 @@ export class SlashCommandsService {
|
|
|
84
84
|
description: skill.description,
|
|
85
85
|
source: "skill",
|
|
86
86
|
});
|
|
87
|
+
seen.add(name);
|
|
87
88
|
}
|
|
88
89
|
}
|
|
89
90
|
catch {
|
|
90
91
|
// Session not ready yet — native-only catalog still serves the picker.
|
|
91
92
|
}
|
|
93
|
+
// UI 插件注册的命令(host.registerCommand)——全局,不依赖会话就绪。
|
|
94
|
+
for (const cmd of this.host.pluginCommands?.() ?? []) {
|
|
95
|
+
if (seen.has(cmd.name))
|
|
96
|
+
continue; // 与内置/扩展重名时先到先得(内置优先)
|
|
97
|
+
commands.push({
|
|
98
|
+
name: cmd.name,
|
|
99
|
+
description: cmd.description,
|
|
100
|
+
descriptionEn: cmd.descriptionEn,
|
|
101
|
+
argumentHint: cmd.argumentHint,
|
|
102
|
+
argumentHintEn: cmd.argumentHintEn,
|
|
103
|
+
source: "plugin",
|
|
104
|
+
});
|
|
105
|
+
seen.add(cmd.name);
|
|
106
|
+
}
|
|
92
107
|
this.host.emit({ type: "slash_commands", commands });
|
|
93
108
|
}
|
|
94
109
|
/** Run a native slash command (see NATIVE_COMMANDS). Returns false when the
|
|
@@ -241,7 +256,8 @@ export class SlashCommandsService {
|
|
|
241
256
|
// swallow here so the SDK never sees them as plain prompt text.
|
|
242
257
|
return true;
|
|
243
258
|
default:
|
|
244
|
-
|
|
259
|
+
// 插件命令:拦截执行(纯配置动作,与内置命令同级,不到 SDK)。
|
|
260
|
+
return (await this.host.execPluginCommand?.(name, args)) ?? false;
|
|
245
261
|
}
|
|
246
262
|
}
|
|
247
263
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-web-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.44.0",
|
|
4
4
|
"description": "Web chat interface for the pi coding agent, powered by the pi SDK (@earendil-works/pi-coding-agent) — one-command run, Docker/systemd/launchd deployable",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|