pi-web-ui 0.86.2 → 0.87.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/CHANGELOG.md +59 -21
- package/README.md +1 -1
- package/bin/pi-web-ui.mjs +312 -40
- package/dist/server/agent-service.js +297 -6
- package/dist/server/client-state.js +8 -0
- package/dist/server/composer-drafts.js +138 -0
- package/dist/server/dsh/dsh-agent-service.js +531 -54
- package/dist/server/dsh/dsh-client.js +28 -0
- package/dist/server/dsh/dsh-sessions.js +28 -0
- package/dist/server/dsh/dsh-usage.js +82 -0
- package/dist/server/dsh/preset-clones.js +260 -0
- package/dist/server/dsh/runtime/custom-prompt.mjs +33 -0
- package/dist/server/dsh/runtime/goal-rpc.mjs +406 -22
- package/dist/server/dsh/runtime/launcher.mjs +17 -0
- package/dist/server/dsh/runtime/override.patch.yml +19 -1
- package/dist/server/files-service.js +259 -1
- package/dist/server/index.js +219 -3
- package/dist/server/mcp-bridge.js +126 -22
- package/dist/server/mcp-hot-reload.js +117 -0
- package/dist/server/model-admin.js +93 -3
- package/dist/server/plugin-dom.js +83 -0
- package/dist/server/plugin-facilities.js +15 -1
- package/dist/server/plugin-installer.js +6 -0
- package/dist/server/plugins.js +781 -74
- package/dist/server/protocol-version.js +1 -1
- package/dist/server/provider-oauth-flow.js +157 -0
- package/package.json +1 -1
- package/plugins/catalog.json +9 -0
- package/themes/dark-teal.css +63 -22
- package/web/dist/assets/index-DePnXpq-.js +374 -0
- package/web/dist/assets/index-F86qWlJy.css +41 -0
- package/web/dist/index.html +3 -2
- package/web/dist/assets/TerminalPanel-BytY8dx7.js +0 -6
- package/web/dist/assets/TerminalPanel-DOrYoP_4.css +0 -32
- package/web/dist/assets/index-CKoVyDkP.css +0 -10
- package/web/dist/assets/index-Dmwji4Cr.js +0 -361
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* 全部为无状态 fs 操作 + 两个自持的 watcher(当前列出目录、git dir),
|
|
6
6
|
* 经 FilesHost 回调与 ClientSession 解耦。
|
|
7
7
|
*/
|
|
8
|
-
import { mkdirSync, statSync, writeFileSync, watch } from "node:fs";
|
|
8
|
+
import { mkdirSync, readFileSync, statSync, writeFileSync, watch } from "node:fs";
|
|
9
|
+
import { homedir } from "node:os";
|
|
9
10
|
import { resolve, relative, sep } from "node:path";
|
|
10
11
|
import { pick } from "./i18n.js";
|
|
11
12
|
import { previewKind, looksLikeText, decodeText, hexDump, countLines } from "./text-sniff.js";
|
|
@@ -14,6 +15,36 @@ export const IS_WIN32 = process.platform === "win32";
|
|
|
14
15
|
/** 机器根虚拟路径:工作区「上一级」到达此处,列出所有盘符(Windows)/ "/"(posix)。
|
|
15
16
|
* 这是 wire 字面量,前端 web/src/components/{RightPanel,FooterBar}.tsx 里同值使用。 */
|
|
16
17
|
export const MACHINE_ROOT = "@root";
|
|
18
|
+
/** 桌面目录(wire 格式):存在且为目录才返回,否则空串(前端不渲染 🖥️)。
|
|
19
|
+
* Linux 优先读 XDG user-dirs(中文环境可能是 ~/桌面),其余平台即 ~/Desktop。 */
|
|
20
|
+
export function desktopDirWire(homeWire) {
|
|
21
|
+
const cands = [];
|
|
22
|
+
if (process.platform === "linux") {
|
|
23
|
+
try {
|
|
24
|
+
const conf = readFileSync(resolve(homedir(), ".config", "user-dirs.dirs"), "utf8");
|
|
25
|
+
const m = /XDG_DESKTOP_DIR="([^"]+)"/.exec(conf);
|
|
26
|
+
if (m) {
|
|
27
|
+
const dir = m[1].replace(/\$HOME/g, homeWire);
|
|
28
|
+
if (dir.startsWith("/"))
|
|
29
|
+
cands.push(dir);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// 无 XDG 配置就回落 ~/Desktop
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
cands.push(`${homeWire}/Desktop`);
|
|
37
|
+
for (const c of cands) {
|
|
38
|
+
try {
|
|
39
|
+
if (statSync(wireToAbs(c)).isDirectory())
|
|
40
|
+
return c;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// 不存在就试下一个
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return "";
|
|
47
|
+
}
|
|
17
48
|
/** wire 路径统一用 "/"。绝对 = posix "/...";win32 还有 "C:/..." / 裸 "C:"。
|
|
18
49
|
* 机器浏览(越过工作区根换盘符)发送这些路径;工作区相对树不会产生它们(Windows
|
|
19
50
|
* 文件名不能含 ":",相对路径经 relative() 归一化后也不以 "/" 开头)。 */
|
|
@@ -918,6 +949,233 @@ export class FilesService {
|
|
|
918
949
|
});
|
|
919
950
|
}
|
|
920
951
|
}
|
|
952
|
+
/* ---- 文件树右键菜单的文件操作(`contextmenu.file`,见 protocol.ts file_*) ---- */
|
|
953
|
+
/** 右键文件操作共用的路径解析:绝对 wire(机器浏览)直接转原生绝对路径;
|
|
954
|
+
* 相对路径限定在工作区内(越界 → null,与 readFile/writeFile 同口径)。
|
|
955
|
+
* 空串(工作区根须由调用方特判)与机器根 "@root" 本身不可作为操作对象 → null。 */
|
|
956
|
+
resolveOpTarget(raw) {
|
|
957
|
+
const wire = normWirePath(raw.trim());
|
|
958
|
+
if (!wire || wire === MACHINE_ROOT)
|
|
959
|
+
return null;
|
|
960
|
+
if (isAbsoluteWirePath(wire))
|
|
961
|
+
return { abs: wireToAbs(wire) };
|
|
962
|
+
const w = workspacePath(resolve(this.host.getCwd()), wire);
|
|
963
|
+
return w ? { abs: w.abs } : null;
|
|
964
|
+
}
|
|
965
|
+
/** 文件名清洗(与 uploadFile 同口径再收紧):只取 basename,去 Windows 非法字符;
|
|
966
|
+
* 空/纯点/尾点空格/Windows 保留名 → null(建了删不掉的东西不如直接拒绝)。 */
|
|
967
|
+
sanitizeName(name) {
|
|
968
|
+
const base = name.split(/[\\/]/).pop() ?? "";
|
|
969
|
+
const safe = base
|
|
970
|
+
.replace(/[/:*?"<>|\x00-\x1f]/g, "_")
|
|
971
|
+
.trim()
|
|
972
|
+
.slice(0, 200);
|
|
973
|
+
if (!safe || safe === "." || safe === ".." || /[. ]$/.test(safe))
|
|
974
|
+
return null;
|
|
975
|
+
if (IS_WIN32 && /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i.test(safe))
|
|
976
|
+
return null;
|
|
977
|
+
return safe;
|
|
978
|
+
}
|
|
979
|
+
/** wire 路径的父目录(wire 字符串层面切分,保证 file_changed 与前端 currentPath 同形)。
|
|
980
|
+
* 工作区根 "" / 机器根 / posix 根 "/" / 盘符根 → null(这些不可重命名/删除/作复制源)。 */
|
|
981
|
+
wireParent(wire) {
|
|
982
|
+
const w = normWirePath(wire.trim());
|
|
983
|
+
if (!w || w === MACHINE_ROOT || w === "/" || /^[A-Za-z]:$/.test(w))
|
|
984
|
+
return null;
|
|
985
|
+
const i = w.lastIndexOf("/");
|
|
986
|
+
if (i < 0)
|
|
987
|
+
return ""; // 工作区相对单层 → 工作区根
|
|
988
|
+
if (i === 0)
|
|
989
|
+
return "/"; // "/a" → "/"
|
|
990
|
+
return w.slice(0, i);
|
|
991
|
+
}
|
|
992
|
+
/** destDir(wire,空串 = 工作区根)→ 原生绝对目录;不存在/非目录 → null(调用方报错)。 */
|
|
993
|
+
async resolveOpDir(dir) {
|
|
994
|
+
const fsp = await import("node:fs/promises");
|
|
995
|
+
if (!dir.trim())
|
|
996
|
+
return { abs: resolve(this.host.getCwd()), wire: "" };
|
|
997
|
+
const t = this.resolveOpTarget(dir);
|
|
998
|
+
if (!t)
|
|
999
|
+
return null;
|
|
1000
|
+
const st = await fsp.stat(t.abs).catch(() => null);
|
|
1001
|
+
if (!st?.isDirectory())
|
|
1002
|
+
return null;
|
|
1003
|
+
return { abs: t.abs, wire: normWirePath(dir.trim()) };
|
|
1004
|
+
}
|
|
1005
|
+
/** 在 dir 下新建空文件或空文件夹。已存在不覆盖(报错);成功后对 dir 推 file_changed。 */
|
|
1006
|
+
async createEntry(dir, name, kind) {
|
|
1007
|
+
const err = (text, textEn) => this.host.emit({ type: "notice", level: "error", text, textEn });
|
|
1008
|
+
try {
|
|
1009
|
+
const fsp = await import("node:fs/promises");
|
|
1010
|
+
const { join } = await import("node:path");
|
|
1011
|
+
const safe = this.sanitizeName(name);
|
|
1012
|
+
if (!safe) {
|
|
1013
|
+
err(`文件名不合法:${name}`, `Invalid name: ${name}`);
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
const target = await this.resolveOpDir(dir);
|
|
1017
|
+
if (!target) {
|
|
1018
|
+
err(`目录不存在或超出工作区:${dir || "根目录"}`, `Directory not found: ${dir || "root"}`);
|
|
1019
|
+
return;
|
|
1020
|
+
}
|
|
1021
|
+
const abs = join(target.abs, safe);
|
|
1022
|
+
if (await fsp.stat(abs).catch(() => null)) {
|
|
1023
|
+
err(`已存在:${safe}`, `Already exists: ${safe}`);
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
1026
|
+
if (kind === "dir")
|
|
1027
|
+
await fsp.mkdir(abs);
|
|
1028
|
+
else
|
|
1029
|
+
await fsp.writeFile(abs, "");
|
|
1030
|
+
this.host.emit({
|
|
1031
|
+
type: "notice",
|
|
1032
|
+
level: "info",
|
|
1033
|
+
text: kind === "dir" ? `已新建文件夹:${safe}` : `已新建文件:${safe}`,
|
|
1034
|
+
textEn: kind === "dir" ? `Folder created: ${safe}` : `File created: ${safe}`,
|
|
1035
|
+
});
|
|
1036
|
+
this.host.emit({ type: "file_changed", path: target.wire });
|
|
1037
|
+
}
|
|
1038
|
+
catch (e) {
|
|
1039
|
+
err(`新建失败:${e.message}`, `Create failed: ${e.message}`);
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
/** 同目录内重命名(newName 只取 basename,不跨目录)。成功后对父目录推 file_changed。 */
|
|
1043
|
+
async renameEntry(path, newName) {
|
|
1044
|
+
const err = (text, textEn) => this.host.emit({ type: "notice", level: "error", text, textEn });
|
|
1045
|
+
try {
|
|
1046
|
+
const fsp = await import("node:fs/promises");
|
|
1047
|
+
const { join, dirname } = await import("node:path");
|
|
1048
|
+
const t = this.resolveOpTarget(path);
|
|
1049
|
+
const parent = this.wireParent(path);
|
|
1050
|
+
if (!t || parent === null) {
|
|
1051
|
+
err(`此处不可重命名:${path}`, `Cannot rename here: ${path}`);
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
const safe = this.sanitizeName(newName);
|
|
1055
|
+
if (!safe) {
|
|
1056
|
+
err(`新名称不合法:${newName}`, `Invalid name: ${newName}`);
|
|
1057
|
+
return;
|
|
1058
|
+
}
|
|
1059
|
+
const dest = join(dirname(t.abs), safe);
|
|
1060
|
+
if (await fsp.stat(dest).catch(() => null)) {
|
|
1061
|
+
err(`已存在:${safe}`, `Already exists: ${safe}`);
|
|
1062
|
+
return;
|
|
1063
|
+
}
|
|
1064
|
+
await fsp.rename(t.abs, dest);
|
|
1065
|
+
this.host.emit({
|
|
1066
|
+
type: "notice",
|
|
1067
|
+
level: "info",
|
|
1068
|
+
text: `已重命名为:${safe}`,
|
|
1069
|
+
textEn: `Renamed to: ${safe}`,
|
|
1070
|
+
});
|
|
1071
|
+
this.host.emit({ type: "file_changed", path: parent });
|
|
1072
|
+
}
|
|
1073
|
+
catch (e) {
|
|
1074
|
+
err(`重命名失败:${e.message}`, `Rename failed: ${e.message}`);
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
/** 删除文件或目录(目录递归删)。工作区根/机器根/盘符根拒绝;成功后对父目录推 file_changed。 */
|
|
1078
|
+
async deleteEntry(path) {
|
|
1079
|
+
const err = (text, textEn) => this.host.emit({ type: "notice", level: "error", text, textEn });
|
|
1080
|
+
try {
|
|
1081
|
+
const fsp = await import("node:fs/promises");
|
|
1082
|
+
const t = this.resolveOpTarget(path);
|
|
1083
|
+
const parent = this.wireParent(path);
|
|
1084
|
+
if (!t || parent === null) {
|
|
1085
|
+
err(`此处不可删除:${path}`, `Cannot delete here: ${path}`);
|
|
1086
|
+
return;
|
|
1087
|
+
}
|
|
1088
|
+
await fsp.rm(t.abs, { recursive: true, force: true });
|
|
1089
|
+
this.host.emit({
|
|
1090
|
+
type: "notice",
|
|
1091
|
+
level: "info",
|
|
1092
|
+
text: `已删除:${path.split(/[\\/]/).pop() ?? path}`,
|
|
1093
|
+
textEn: `Deleted: ${path.split(/[\\/]/).pop() ?? path}`,
|
|
1094
|
+
});
|
|
1095
|
+
this.host.emit({ type: "file_changed", path: parent });
|
|
1096
|
+
}
|
|
1097
|
+
catch (e) {
|
|
1098
|
+
err(`删除失败:${e.message}`, `Delete failed: ${e.message}`);
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
/** 复制或移动(move=true 即剪切粘贴)。destDir 与源同目录即「创建副本」;
|
|
1102
|
+
* 重名自动加 " copy" 后缀;目录搬进自身/子目录拒绝;跨盘移动回落为复制+删源。 */
|
|
1103
|
+
async copyEntry(src, destDir, move) {
|
|
1104
|
+
const err = (text, textEn) => this.host.emit({ type: "notice", level: "error", text, textEn });
|
|
1105
|
+
try {
|
|
1106
|
+
const fsp = await import("node:fs/promises");
|
|
1107
|
+
const { join, basename, sep } = await import("node:path");
|
|
1108
|
+
const s = this.resolveOpTarget(src);
|
|
1109
|
+
const srcParent = this.wireParent(src);
|
|
1110
|
+
if (!s || srcParent === null) {
|
|
1111
|
+
err(`此处不可${move ? "移动" : "复制"}:${src}`, `Cannot ${move ? "move" : "copy"}: ${src}`);
|
|
1112
|
+
return;
|
|
1113
|
+
}
|
|
1114
|
+
const target = await this.resolveOpDir(destDir);
|
|
1115
|
+
if (!target) {
|
|
1116
|
+
err(`目标目录不存在:${destDir || "根目录"}`, `Target directory not found: ${destDir || "root"}`);
|
|
1117
|
+
return;
|
|
1118
|
+
}
|
|
1119
|
+
// 目录搬进自身或子目录 → 无限递归,必须拒绝(文件无此问题,但统一判一次)。
|
|
1120
|
+
if (target.abs === s.abs || target.abs.startsWith(s.abs + sep)) {
|
|
1121
|
+
err("不可复制/移动到自身或子目录", "Cannot copy/move into itself");
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
1124
|
+
const base = basename(s.abs);
|
|
1125
|
+
let dest = join(target.abs, base);
|
|
1126
|
+
if (!move)
|
|
1127
|
+
dest = await this.dedupeCopyDest(dest);
|
|
1128
|
+
else if (await fsp.stat(dest).catch(() => null)) {
|
|
1129
|
+
err(`目标已存在:${base}`, `Already exists at target: ${base}`);
|
|
1130
|
+
return;
|
|
1131
|
+
}
|
|
1132
|
+
const verb = move ? ["已移动", "Moved"] : ["已复制", "Copied"];
|
|
1133
|
+
if (move) {
|
|
1134
|
+
try {
|
|
1135
|
+
await fsp.rename(s.abs, dest);
|
|
1136
|
+
}
|
|
1137
|
+
catch (e) {
|
|
1138
|
+
// 跨盘/跨挂载点 rename 报 EXDEV → 回落复制+删源(与文件管理器同行为)。
|
|
1139
|
+
if (e.code !== "EXDEV")
|
|
1140
|
+
throw e;
|
|
1141
|
+
await fsp.cp(s.abs, dest, { recursive: true, force: false });
|
|
1142
|
+
await fsp.rm(s.abs, { recursive: true, force: true });
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
else {
|
|
1146
|
+
await fsp.cp(s.abs, dest, { recursive: true, force: false });
|
|
1147
|
+
}
|
|
1148
|
+
this.host.emit({
|
|
1149
|
+
type: "notice",
|
|
1150
|
+
level: "info",
|
|
1151
|
+
text: `${verb[0]}:${base}`,
|
|
1152
|
+
textEn: `${verb[1]}: ${base}`,
|
|
1153
|
+
});
|
|
1154
|
+
this.host.emit({ type: "file_changed", path: target.wire });
|
|
1155
|
+
if (move && target.wire !== srcParent)
|
|
1156
|
+
this.host.emit({ type: "file_changed", path: srcParent });
|
|
1157
|
+
}
|
|
1158
|
+
catch (e) {
|
|
1159
|
+
err(`${move ? "移动" : "复制"}失败:${e.message}`, `${move ? "Move" : "Copy"} failed: ${e.message}`);
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
/** 副本目标去重:"a.txt" → "a copy.txt" → "a copy 2.txt"…(目录/无后缀同理)。 */
|
|
1163
|
+
async dedupeCopyDest(dest) {
|
|
1164
|
+
const fsp = await import("node:fs/promises");
|
|
1165
|
+
const { join, dirname, basename, extname } = await import("node:path");
|
|
1166
|
+
if (!(await fsp.stat(dest).catch(() => null)))
|
|
1167
|
+
return dest;
|
|
1168
|
+
const dir = dirname(dest);
|
|
1169
|
+
const base = basename(dest);
|
|
1170
|
+
const ext = extname(base);
|
|
1171
|
+
const stem = ext ? base.slice(0, -ext.length) : base;
|
|
1172
|
+
for (let i = 1; i < 100; i++) {
|
|
1173
|
+
const cand = join(dir, `${stem} copy${i === 1 ? "" : ` ${i}`}${ext}`);
|
|
1174
|
+
if (!(await fsp.stat(cand).catch(() => null)))
|
|
1175
|
+
return cand;
|
|
1176
|
+
}
|
|
1177
|
+
return join(dir, `${stem} copy ${Date.now()}${ext}`);
|
|
1178
|
+
}
|
|
921
1179
|
/**
|
|
922
1180
|
* Path completion for the cwd input: expand ~/relative paths, list the parent
|
|
923
1181
|
* directory, and return prefix matches (dirs first, capped).
|
package/dist/server/index.js
CHANGED
|
@@ -44,6 +44,7 @@ import { PluginManager, resolvePluginClientFile, } from "./plugins.js";
|
|
|
44
44
|
import { PluginInstaller } from "./plugin-installer.js";
|
|
45
45
|
import { syncPluginCatalog } from "./plugin-catalog-sync.js";
|
|
46
46
|
import { McpBridge } from "./mcp-bridge.js";
|
|
47
|
+
import { createMcpHotReload } from "./mcp-hot-reload.js";
|
|
47
48
|
/** 从 CLI 参数中取 flag 值:支持 --flag value 与 --flag=value 两种写法。
|
|
48
49
|
* 让 `node dist/server/index.js --host 0.0.0.0 --port 9000` 这类直接启动也能生效,
|
|
49
50
|
* 而不只是经由 bin/pi-web-ui.mjs 的 env 转发。bin 仍是主入口,此处仅作兜底。 */
|
|
@@ -223,8 +224,8 @@ if (AUTH_TOKEN) {
|
|
|
223
224
|
: "unauthorized: PI_WEB_TOKEN required (?token=…)");
|
|
224
225
|
});
|
|
225
226
|
}
|
|
226
|
-
/**
|
|
227
|
-
const ENGINE = process.env.PI_WEB_ENGINE === "dsh" ? "dsh" : "pi";
|
|
227
|
+
/** 引擎选择:--engine pi|dsh > PI_WEB_ENGINE > 默认 pi。重启生效。 */
|
|
228
|
+
const ENGINE = (cliFlag("--engine") ?? process.env.PI_WEB_ENGINE) === "dsh" ? "dsh" : "pi";
|
|
228
229
|
/** PI_WEB_MANAGED=1: this instance is updated by whoever deploys it. */
|
|
229
230
|
const MANAGED = isManaged();
|
|
230
231
|
/** Who started this process: a platform service manager (launchd / systemd /
|
|
@@ -490,6 +491,12 @@ app.all(["/plugins-api/:id/*", "/plugins-api/:id"], (req, res) => {
|
|
|
490
491
|
app.get("/plugins/:id/client/*", (req, res) => {
|
|
491
492
|
// express 4 的通配参数在运行时落在 params[0],但类型声明里没有 —— 显式取
|
|
492
493
|
const rest = String(req.params[0] ?? "");
|
|
494
|
+
// 特权 DOM 门禁:声明了 dom 能力的插件,其 bundle 需用户逐个授权后才下发
|
|
495
|
+
// (同源 bundle 技术上拦不住 DOM 访问,门只能放在这里;见 server/plugin-dom.ts)。
|
|
496
|
+
if (pluginMgr.isDomBundleBlocked(String(req.params.id ?? ""))) {
|
|
497
|
+
res.status(403).end("dom access not granted (settings > plugins > grant)");
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
493
500
|
const abs = resolvePluginClientFile(PLUGINS_DIR, req.params.id, rest);
|
|
494
501
|
if (!abs) {
|
|
495
502
|
res.status(404).end("plugin not found");
|
|
@@ -733,12 +740,37 @@ pluginMgr.pathAccessRequester = (pluginId, dir, reason) => new Promise((resolve)
|
|
|
733
740
|
});
|
|
734
741
|
// 用户点了「允许」→ 授权表变了 → 立刻重推给所有在线客户端(设置面板「已授权目录」即时可见)。
|
|
735
742
|
pluginMgr.onGrantsChanged = () => pushPluginGrants();
|
|
743
|
+
/** 广播通知条给全部在线客户端(全局事件,不属于某个 ClientSession —— 如 mcp.json 坏)。 */
|
|
744
|
+
function pushNoticeToAll(level, text, textEn) {
|
|
745
|
+
const payload = JSON.stringify({ type: "notice", level, text, textEn });
|
|
746
|
+
for (const client of wss.clients) {
|
|
747
|
+
if (client.readyState !== WebSocket.OPEN)
|
|
748
|
+
continue;
|
|
749
|
+
try {
|
|
750
|
+
client.send(payload);
|
|
751
|
+
}
|
|
752
|
+
catch {
|
|
753
|
+
/* 死连接:index.ts 自己会清理 */
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
}
|
|
736
757
|
// MCP 工具桥:读取 <dataDir>/mcp.json 启动外部 MCP 服务器(stdio),把它们的
|
|
737
758
|
// 工具并入与插件工具相同的 customTools 管线;单服务器失败不炸进程。
|
|
738
759
|
const mcpBridge = new McpBridge(DATA_DIR, (...a) => console.log("[mcp]", ...a));
|
|
760
|
+
// mcp.json 热加载:保存文件即生效,改完不必再重启服务。两半都在这里收口 —— reload 换入
|
|
761
|
+
// 新的服务器集合(只重启规格真变了的),applyPluginAgentTools 把新工具推给已有会话。
|
|
762
|
+
const mcpHotReload = createMcpHotReload({
|
|
763
|
+
dataDir: DATA_DIR,
|
|
764
|
+
reload: () => mcpBridge.reload(),
|
|
765
|
+
onToolsChanged: () => service.applyPluginAgentTools(),
|
|
766
|
+
onNotice: (level, text, textEn) => pushNoticeToAll(level, text, textEn),
|
|
767
|
+
log: (...a) => console.log(...a),
|
|
768
|
+
});
|
|
739
769
|
void mcpBridge.load().then(() => {
|
|
740
770
|
if (mcpBridge.getTools().length)
|
|
741
771
|
service.applyPluginAgentTools();
|
|
772
|
+
// 播种在 load 之后:否则指纹可能记在 load 读到的版本之前,白重载一次。
|
|
773
|
+
mcpHotReload.start();
|
|
742
774
|
});
|
|
743
775
|
// 插件扩展点:SDK 工具执行事件(bash/读文件等 start+end)转发给已注册的插件。
|
|
744
776
|
service.onToolEvent = (ev) => pluginMgr.emitToolEvent(ev);
|
|
@@ -761,6 +793,107 @@ service.pluginCommandsProvider = () => pluginMgr.listCommands();
|
|
|
761
793
|
pluginMgr.onBgTasksChanged = () => service.refreshBackgroundServers();
|
|
762
794
|
service.pluginBgTasksProvider = () => pluginMgr.bgTasks();
|
|
763
795
|
service.pluginStopBgTask = (taskId) => pluginMgr.stopPluginBgTask(taskId);
|
|
796
|
+
// ---------------------------------------------------------------------------
|
|
797
|
+
// 插件扩展点 v2(并行任务在 server/plugins.ts 加 host.conversations/prompt/
|
|
798
|
+
// steer/abortRun/chatWait/fs.watch/scm/bash/schedule/models/onStats/onStreaming/
|
|
799
|
+
// net.fetch/events + conversationLister/conversationSearcher/conversationWriter/
|
|
800
|
+
// runSteerer/runAborter/modelLister + emitStats/emitStreaming 注入点,web/ 侧加
|
|
801
|
+
// plugin-host v8 与新 slot/kind/messageWidget)。本文件只做接线,不实现宿主方法
|
|
802
|
+
// 本身:下面全是注入函数(读 service 现有逻辑组装数据),PluginManager 那边
|
|
803
|
+
// 存在即用、不存在即跳过。约束:全部用 (pm as any).xxx 赋值 + typeof 防御,绝不
|
|
804
|
+
// 假设 PluginManager 已有这些字段(并行任务可能还没合入);每个注入内部再
|
|
805
|
+
// try/catch,DSH 引擎(无 pluginClient/各 ForPlugins 方法)回退空列表或
|
|
806
|
+
// {ok:false},绝不抛错炸进程。
|
|
807
|
+
// ---------------------------------------------------------------------------
|
|
808
|
+
{
|
|
809
|
+
const pm = pluginMgr;
|
|
810
|
+
/** 挑一个客户端会话:标准 pi 引擎走 service.pluginClient(),DSH/未知引擎无此方法即 undefined。 */
|
|
811
|
+
const pickClient = () => {
|
|
812
|
+
try {
|
|
813
|
+
const svc = service;
|
|
814
|
+
return typeof svc.pluginClient === "function" ? svc.pluginClient() : undefined;
|
|
815
|
+
}
|
|
816
|
+
catch {
|
|
817
|
+
return undefined;
|
|
818
|
+
}
|
|
819
|
+
};
|
|
820
|
+
// conversationLister:本客户端运行中对话 + 当前项目历史会话摘要,只读组装
|
|
821
|
+
// {id,title,cwd,kind,isStreaming}。无客户端/方法缺失回空数组(插件显示空态)。
|
|
822
|
+
pm.conversationLister = async () => {
|
|
823
|
+
try {
|
|
824
|
+
const cs = pickClient();
|
|
825
|
+
if (!cs)
|
|
826
|
+
return [];
|
|
827
|
+
const running = typeof cs.listRunningForPlugins === "function" ? cs.listRunningForPlugins() : [];
|
|
828
|
+
const history = typeof cs.listHistoryForPlugins === "function" ? await cs.listHistoryForPlugins(50) : [];
|
|
829
|
+
return [...running, ...history];
|
|
830
|
+
}
|
|
831
|
+
catch {
|
|
832
|
+
return [];
|
|
833
|
+
}
|
|
834
|
+
};
|
|
835
|
+
// conversationSearcher:复用 search_sessions 逻辑,返回前 N 个 {id,title}。
|
|
836
|
+
pm.conversationSearcher = async (query, limit) => {
|
|
837
|
+
try {
|
|
838
|
+
const cs = pickClient();
|
|
839
|
+
if (!cs || typeof cs.searchForPlugins !== "function")
|
|
840
|
+
return [];
|
|
841
|
+
return await cs.searchForPlugins(query, limit ?? 20);
|
|
842
|
+
}
|
|
843
|
+
catch {
|
|
844
|
+
return [];
|
|
845
|
+
}
|
|
846
|
+
};
|
|
847
|
+
// conversationWriter:向指定对话投递 prompt(复用 prompt 路径);找不到对话回 {ok:false,error}。
|
|
848
|
+
pm.conversationWriter = async (id, text) => {
|
|
849
|
+
try {
|
|
850
|
+
const cs = pickClient();
|
|
851
|
+
if (!cs || typeof cs.writeForPlugins !== "function")
|
|
852
|
+
return { ok: false, error: "当前引擎不支持对话投递(仅标准 pi 引擎)" };
|
|
853
|
+
return await cs.writeForPlugins(id, text);
|
|
854
|
+
}
|
|
855
|
+
catch (err) {
|
|
856
|
+
return { ok: false, error: err.message };
|
|
857
|
+
}
|
|
858
|
+
};
|
|
859
|
+
// modelLister:复用现有模型列表,映射 {id,provider,vision}。
|
|
860
|
+
pm.modelLister = async () => {
|
|
861
|
+
try {
|
|
862
|
+
const cs = pickClient();
|
|
863
|
+
if (!cs || typeof cs.listModelsForPlugins !== "function")
|
|
864
|
+
return [];
|
|
865
|
+
return await cs.listModelsForPlugins();
|
|
866
|
+
}
|
|
867
|
+
catch {
|
|
868
|
+
return [];
|
|
869
|
+
}
|
|
870
|
+
};
|
|
871
|
+
// runSteerer:复用 ClientSession.steerForPlugins(sendUserMessage + deliverAs:'steer',
|
|
872
|
+
// 跨客户端查找由方法内部兜底);DSH/未知引擎无此方法即 not supported。
|
|
873
|
+
pm.runSteerer = async (id, text) => {
|
|
874
|
+
try {
|
|
875
|
+
const cs = pickClient();
|
|
876
|
+
if (!cs || typeof cs.steerForPlugins !== "function")
|
|
877
|
+
return { ok: false, error: "not supported" };
|
|
878
|
+
return await cs.steerForPlugins(id, text);
|
|
879
|
+
}
|
|
880
|
+
catch (err) {
|
|
881
|
+
return { ok: false, error: err.message };
|
|
882
|
+
}
|
|
883
|
+
};
|
|
884
|
+
// runAborter:有现成 abort 路径(interruptRun,卡住/空转强制重置语义继承)。
|
|
885
|
+
pm.runAborter = async (id) => {
|
|
886
|
+
try {
|
|
887
|
+
const cs = pickClient();
|
|
888
|
+
if (!cs || typeof cs.abortForPlugins !== "function")
|
|
889
|
+
return { ok: false, error: "not supported" };
|
|
890
|
+
return await cs.abortForPlugins(id);
|
|
891
|
+
}
|
|
892
|
+
catch (err) {
|
|
893
|
+
return { ok: false, error: err.message };
|
|
894
|
+
}
|
|
895
|
+
};
|
|
896
|
+
}
|
|
764
897
|
// 插件宿主工作区实时跟随当前项目:任意客户端 set_cwd 成功后同步给
|
|
765
898
|
// PluginManager,编辑器等工作区跟随型插件随即切根(详见 plugins.ts notifyCwd)。
|
|
766
899
|
service.onClientCwdChanged = (cwd, roots) => {
|
|
@@ -912,6 +1045,9 @@ wss.on("connection", (ws) => {
|
|
|
912
1045
|
case "queue_remove":
|
|
913
1046
|
cs.removeQueued(msg.kind, msg.text);
|
|
914
1047
|
break;
|
|
1048
|
+
case "draft_update":
|
|
1049
|
+
cs.saveDraft?.(msg.sessionId, msg.text, msg.ts);
|
|
1050
|
+
break;
|
|
915
1051
|
case "abort":
|
|
916
1052
|
void cs.abort();
|
|
917
1053
|
break;
|
|
@@ -931,7 +1067,7 @@ wss.on("connection", (ws) => {
|
|
|
931
1067
|
void cs.listBgServers();
|
|
932
1068
|
break;
|
|
933
1069
|
case "new_chat":
|
|
934
|
-
void cs.newChat();
|
|
1070
|
+
void cs.newChat(msg.preset);
|
|
935
1071
|
break;
|
|
936
1072
|
case "edit_message":
|
|
937
1073
|
void cs.editMessage(msg.messageId, msg.text, msg.attachments);
|
|
@@ -1010,6 +1146,18 @@ wss.on("connection", (ws) => {
|
|
|
1010
1146
|
case "upload_file":
|
|
1011
1147
|
void cs.uploadFile(msg.dirPath, msg.name, msg.data);
|
|
1012
1148
|
break;
|
|
1149
|
+
case "file_create":
|
|
1150
|
+
void cs.createEntry(msg.dir, msg.name, msg.kind);
|
|
1151
|
+
break;
|
|
1152
|
+
case "file_rename":
|
|
1153
|
+
void cs.renameEntry(msg.path, msg.newName);
|
|
1154
|
+
break;
|
|
1155
|
+
case "file_delete":
|
|
1156
|
+
void cs.deleteEntry(msg.path);
|
|
1157
|
+
break;
|
|
1158
|
+
case "file_copy":
|
|
1159
|
+
void cs.copyEntry(msg.src, msg.destDir, msg.move);
|
|
1160
|
+
break;
|
|
1013
1161
|
case "list_models":
|
|
1014
1162
|
void cs.listModels();
|
|
1015
1163
|
break;
|
|
@@ -1080,6 +1228,21 @@ wss.on("connection", (ws) => {
|
|
|
1080
1228
|
case "clear_provider_api_key":
|
|
1081
1229
|
void cs.clearProviderApiKey(msg.provider);
|
|
1082
1230
|
break;
|
|
1231
|
+
case "provider_oauth_start":
|
|
1232
|
+
cs.startProviderOAuth(msg.provider);
|
|
1233
|
+
break;
|
|
1234
|
+
case "provider_oauth_reply":
|
|
1235
|
+
cs.replyProviderOAuth(msg.flowId, msg.promptId, msg.value);
|
|
1236
|
+
break;
|
|
1237
|
+
case "provider_oauth_cancel":
|
|
1238
|
+
cs.cancelProviderOAuth(msg.flowId);
|
|
1239
|
+
break;
|
|
1240
|
+
case "list_provider_oauth_flows":
|
|
1241
|
+
cs.listProviderOAuthFlows();
|
|
1242
|
+
break;
|
|
1243
|
+
case "provider_oauth_logout":
|
|
1244
|
+
void cs.logoutProviderOAuth(msg.provider);
|
|
1245
|
+
break;
|
|
1083
1246
|
case "list_models_config":
|
|
1084
1247
|
void cs.listModelsConfig();
|
|
1085
1248
|
break;
|
|
@@ -1265,6 +1428,7 @@ wss.on("connection", (ws) => {
|
|
|
1265
1428
|
id: pluginId,
|
|
1266
1429
|
source: msg.source,
|
|
1267
1430
|
build: msg.build === true,
|
|
1431
|
+
noBuild: msg.noBuild === true,
|
|
1268
1432
|
}, {
|
|
1269
1433
|
lang: jobLang,
|
|
1270
1434
|
emit: (m) => send(m),
|
|
@@ -1305,6 +1469,22 @@ wss.on("connection", (ws) => {
|
|
|
1305
1469
|
}
|
|
1306
1470
|
break;
|
|
1307
1471
|
}
|
|
1472
|
+
case "plugin_dom_consent": {
|
|
1473
|
+
void pluginMgr
|
|
1474
|
+
.setDomConsent(msg.pluginId, msg.granted === true)
|
|
1475
|
+
.then((r) => {
|
|
1476
|
+
if (r.error)
|
|
1477
|
+
cs?.emitNotice("warning", `DOM 授权失败:${r.error}`, `DOM consent failed: ${r.error}`);
|
|
1478
|
+
else if (r.changed)
|
|
1479
|
+
cs?.emitNotice("info", msg.granted === true
|
|
1480
|
+
? `已授权插件「${msg.pluginId}」完全 DOM 访问`
|
|
1481
|
+
: `已撤销插件「${msg.pluginId}」完全 DOM 访问`, msg.granted === true
|
|
1482
|
+
? `Granted full DOM access to plugin "${msg.pluginId}"`
|
|
1483
|
+
: `Revoked full DOM access from plugin "${msg.pluginId}"`);
|
|
1484
|
+
})
|
|
1485
|
+
.catch(() => { });
|
|
1486
|
+
break;
|
|
1487
|
+
}
|
|
1308
1488
|
case "plugin_path_revoke": {
|
|
1309
1489
|
const removed = pluginMgr.grants.revoke(typeof msg.pluginId === "string" ? msg.pluginId : undefined, typeof msg.path === "string" ? msg.path : undefined);
|
|
1310
1490
|
cs?.emitNotice("info", `已撤销 ${removed} 条插件目录授权`, `Revoked ${removed} plugin path grant(s)`);
|
|
@@ -1336,6 +1516,21 @@ wss.on("connection", (ws) => {
|
|
|
1336
1516
|
case "dsh_patches_list":
|
|
1337
1517
|
void cs.listDshPatches?.();
|
|
1338
1518
|
break;
|
|
1519
|
+
case "dsh_preset_list":
|
|
1520
|
+
void cs.refreshAgentPresets?.();
|
|
1521
|
+
break;
|
|
1522
|
+
case "dsh_preset_select":
|
|
1523
|
+
void cs.selectAgentPreset?.(msg.preset);
|
|
1524
|
+
break;
|
|
1525
|
+
case "dsh_preset_default":
|
|
1526
|
+
void cs.setDefaultAgentPreset?.(msg.preset);
|
|
1527
|
+
break;
|
|
1528
|
+
case "dsh_permission_set":
|
|
1529
|
+
void cs.setPermissionPreset?.(msg.preset);
|
|
1530
|
+
break;
|
|
1531
|
+
case "dsh_permission_default":
|
|
1532
|
+
void cs.setDefaultPermissionPreset?.(msg.preset);
|
|
1533
|
+
break;
|
|
1339
1534
|
case "dsh_patches_rescan":
|
|
1340
1535
|
void cs.rescanDshPatches?.();
|
|
1341
1536
|
break;
|
|
@@ -1514,6 +1709,26 @@ httpServer.listen(PORT, HOST, () => {
|
|
|
1514
1709
|
});
|
|
1515
1710
|
// 上传文件保留期清理:启动扫一次 + 每 6 小时一次(best-effort,见 uploads.ts)
|
|
1516
1711
|
scheduleUploadCleanup();
|
|
1712
|
+
// 开机目录预同步(issue #165):PI_WEB_PLUGIN_CATALOG_URL 指向一份插件市场目录文档
|
|
1713
|
+
// (headless/预置场景,不开浏览器也能装插件)。只跑一次、失败只告警不阻断启动;
|
|
1714
|
+
// 条目逐条安装/更新(托管实例上安装会被安装器拒绝,但列表本身仍会更新)。
|
|
1715
|
+
const BOOT_CATALOG_URL = (process.env.PI_WEB_PLUGIN_CATALOG_URL ?? "").trim();
|
|
1716
|
+
if (BOOT_CATALOG_URL) {
|
|
1717
|
+
void syncPluginCatalog(BOOT_CATALOG_URL, { install: true }, {
|
|
1718
|
+
customCatalogPath: pluginMgr.customCatalogPath,
|
|
1719
|
+
pluginsDir: join(DATA_DIR, "plugins"),
|
|
1720
|
+
installer: pluginInstaller,
|
|
1721
|
+
afterWrite: () => reloadPluginsAndPush(),
|
|
1722
|
+
}).then((r) => {
|
|
1723
|
+
if (!r.ok) {
|
|
1724
|
+
console.warn(`[catalog] PI_WEB_PLUGIN_CATALOG_URL 同步失败(不阻断启动): ${r.error}`);
|
|
1725
|
+
return;
|
|
1726
|
+
}
|
|
1727
|
+
const bad = (r.installed ?? []).filter((i) => !i.ok);
|
|
1728
|
+
console.log(`[catalog] PI_WEB_PLUGIN_CATALOG_URL 同步完成:安装 ${(r.installed ?? []).length - bad.length} 成功 / ${bad.length} 失败` +
|
|
1729
|
+
(bad.length ? `:${bad.map((i) => `${i.id}(${i.error ?? "?"})`).join(";")}` : ""));
|
|
1730
|
+
});
|
|
1731
|
+
}
|
|
1517
1732
|
// Local control socket (status / quiesce / unquiesce) — same data dir the
|
|
1518
1733
|
// CLI uses, so `pi-web-ui server status|quiesce|unquiesce` just works.
|
|
1519
1734
|
const stopControl = startControlServer({ service, dataDir: DATA_DIR, port: PORT });
|
|
@@ -1527,6 +1742,7 @@ async function shutdown() {
|
|
|
1527
1742
|
stopControl();
|
|
1528
1743
|
pluginMgr.dispose();
|
|
1529
1744
|
pluginInstaller.dispose();
|
|
1745
|
+
mcpHotReload.dispose();
|
|
1530
1746
|
mcpBridge.dispose();
|
|
1531
1747
|
await service.disposeAll();
|
|
1532
1748
|
wss.close();
|