pi-web-ui 0.86.2 → 0.87.1
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 +84 -23
- package/README.md +1 -1
- package/bin/pi-web-ui.mjs +312 -40
- package/dist/server/agent-service.js +377 -25
- package/dist/server/client-state.js +8 -0
- package/dist/server/composer-drafts.js +138 -0
- package/dist/server/dsh/dsh-agent-service.js +535 -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 +244 -5
- 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/dist/server/update-check.js +22 -0
- package/package.json +1 -1
- package/plugins/catalog.json +9 -0
- package/themes/dark-teal.css +65 -22
- package/web/dist/assets/index-8xCnPMZP.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,21 @@ 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";
|
|
48
|
+
/** Strip npm-injected env (`npm start` exports `npm_config_*` / `npm_package_*` /
|
|
49
|
+
* `npm_lifecycle_*` into every child). Anything this server spawns — shells,
|
|
50
|
+
* `pi update` — would otherwise inherit `npm_config_allow_scripts`, which npm
|
|
51
|
+
* maps to its env config layer and rejects in project-scoped installs
|
|
52
|
+
* (EALLOWSCRIPTS). The unit keeps `npm ci && npm run build && npm start`;
|
|
53
|
+
* this is the single scrub point so no wrapper is needed. */
|
|
54
|
+
for (const k of Object.keys(process.env)) {
|
|
55
|
+
if (k === "npm_config_allow_scripts" ||
|
|
56
|
+
k.startsWith("npm_config_") ||
|
|
57
|
+
k.startsWith("npm_package_") ||
|
|
58
|
+
k.startsWith("npm_lifecycle_")) {
|
|
59
|
+
delete process.env[k];
|
|
60
|
+
}
|
|
61
|
+
}
|
|
47
62
|
/** 从 CLI 参数中取 flag 值:支持 --flag value 与 --flag=value 两种写法。
|
|
48
63
|
* 让 `node dist/server/index.js --host 0.0.0.0 --port 9000` 这类直接启动也能生效,
|
|
49
64
|
* 而不只是经由 bin/pi-web-ui.mjs 的 env 转发。bin 仍是主入口,此处仅作兜底。 */
|
|
@@ -223,8 +238,8 @@ if (AUTH_TOKEN) {
|
|
|
223
238
|
: "unauthorized: PI_WEB_TOKEN required (?token=…)");
|
|
224
239
|
});
|
|
225
240
|
}
|
|
226
|
-
/**
|
|
227
|
-
const ENGINE = process.env.PI_WEB_ENGINE === "dsh" ? "dsh" : "pi";
|
|
241
|
+
/** 引擎选择:--engine pi|dsh > PI_WEB_ENGINE > 默认 pi。重启生效。 */
|
|
242
|
+
const ENGINE = (cliFlag("--engine") ?? process.env.PI_WEB_ENGINE) === "dsh" ? "dsh" : "pi";
|
|
228
243
|
/** PI_WEB_MANAGED=1: this instance is updated by whoever deploys it. */
|
|
229
244
|
const MANAGED = isManaged();
|
|
230
245
|
/** Who started this process: a platform service manager (launchd / systemd /
|
|
@@ -490,6 +505,12 @@ app.all(["/plugins-api/:id/*", "/plugins-api/:id"], (req, res) => {
|
|
|
490
505
|
app.get("/plugins/:id/client/*", (req, res) => {
|
|
491
506
|
// express 4 的通配参数在运行时落在 params[0],但类型声明里没有 —— 显式取
|
|
492
507
|
const rest = String(req.params[0] ?? "");
|
|
508
|
+
// 特权 DOM 门禁:声明了 dom 能力的插件,其 bundle 需用户逐个授权后才下发
|
|
509
|
+
// (同源 bundle 技术上拦不住 DOM 访问,门只能放在这里;见 server/plugin-dom.ts)。
|
|
510
|
+
if (pluginMgr.isDomBundleBlocked(String(req.params.id ?? ""))) {
|
|
511
|
+
res.status(403).end("dom access not granted (settings > plugins > grant)");
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
493
514
|
const abs = resolvePluginClientFile(PLUGINS_DIR, req.params.id, rest);
|
|
494
515
|
if (!abs) {
|
|
495
516
|
res.status(404).end("plugin not found");
|
|
@@ -733,12 +754,37 @@ pluginMgr.pathAccessRequester = (pluginId, dir, reason) => new Promise((resolve)
|
|
|
733
754
|
});
|
|
734
755
|
// 用户点了「允许」→ 授权表变了 → 立刻重推给所有在线客户端(设置面板「已授权目录」即时可见)。
|
|
735
756
|
pluginMgr.onGrantsChanged = () => pushPluginGrants();
|
|
757
|
+
/** 广播通知条给全部在线客户端(全局事件,不属于某个 ClientSession —— 如 mcp.json 坏)。 */
|
|
758
|
+
function pushNoticeToAll(level, text, textEn) {
|
|
759
|
+
const payload = JSON.stringify({ type: "notice", level, text, textEn });
|
|
760
|
+
for (const client of wss.clients) {
|
|
761
|
+
if (client.readyState !== WebSocket.OPEN)
|
|
762
|
+
continue;
|
|
763
|
+
try {
|
|
764
|
+
client.send(payload);
|
|
765
|
+
}
|
|
766
|
+
catch {
|
|
767
|
+
/* 死连接:index.ts 自己会清理 */
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
}
|
|
736
771
|
// MCP 工具桥:读取 <dataDir>/mcp.json 启动外部 MCP 服务器(stdio),把它们的
|
|
737
772
|
// 工具并入与插件工具相同的 customTools 管线;单服务器失败不炸进程。
|
|
738
773
|
const mcpBridge = new McpBridge(DATA_DIR, (...a) => console.log("[mcp]", ...a));
|
|
774
|
+
// mcp.json 热加载:保存文件即生效,改完不必再重启服务。两半都在这里收口 —— reload 换入
|
|
775
|
+
// 新的服务器集合(只重启规格真变了的),applyPluginAgentTools 把新工具推给已有会话。
|
|
776
|
+
const mcpHotReload = createMcpHotReload({
|
|
777
|
+
dataDir: DATA_DIR,
|
|
778
|
+
reload: () => mcpBridge.reload(),
|
|
779
|
+
onToolsChanged: () => service.applyPluginAgentTools(),
|
|
780
|
+
onNotice: (level, text, textEn) => pushNoticeToAll(level, text, textEn),
|
|
781
|
+
log: (...a) => console.log(...a),
|
|
782
|
+
});
|
|
739
783
|
void mcpBridge.load().then(() => {
|
|
740
784
|
if (mcpBridge.getTools().length)
|
|
741
785
|
service.applyPluginAgentTools();
|
|
786
|
+
// 播种在 load 之后:否则指纹可能记在 load 读到的版本之前,白重载一次。
|
|
787
|
+
mcpHotReload.start();
|
|
742
788
|
});
|
|
743
789
|
// 插件扩展点:SDK 工具执行事件(bash/读文件等 start+end)转发给已注册的插件。
|
|
744
790
|
service.onToolEvent = (ev) => pluginMgr.emitToolEvent(ev);
|
|
@@ -761,6 +807,107 @@ service.pluginCommandsProvider = () => pluginMgr.listCommands();
|
|
|
761
807
|
pluginMgr.onBgTasksChanged = () => service.refreshBackgroundServers();
|
|
762
808
|
service.pluginBgTasksProvider = () => pluginMgr.bgTasks();
|
|
763
809
|
service.pluginStopBgTask = (taskId) => pluginMgr.stopPluginBgTask(taskId);
|
|
810
|
+
// ---------------------------------------------------------------------------
|
|
811
|
+
// 插件扩展点 v2(并行任务在 server/plugins.ts 加 host.conversations/prompt/
|
|
812
|
+
// steer/abortRun/chatWait/fs.watch/scm/bash/schedule/models/onStats/onStreaming/
|
|
813
|
+
// net.fetch/events + conversationLister/conversationSearcher/conversationWriter/
|
|
814
|
+
// runSteerer/runAborter/modelLister + emitStats/emitStreaming 注入点,web/ 侧加
|
|
815
|
+
// plugin-host v8 与新 slot/kind/messageWidget)。本文件只做接线,不实现宿主方法
|
|
816
|
+
// 本身:下面全是注入函数(读 service 现有逻辑组装数据),PluginManager 那边
|
|
817
|
+
// 存在即用、不存在即跳过。约束:全部用 (pm as any).xxx 赋值 + typeof 防御,绝不
|
|
818
|
+
// 假设 PluginManager 已有这些字段(并行任务可能还没合入);每个注入内部再
|
|
819
|
+
// try/catch,DSH 引擎(无 pluginClient/各 ForPlugins 方法)回退空列表或
|
|
820
|
+
// {ok:false},绝不抛错炸进程。
|
|
821
|
+
// ---------------------------------------------------------------------------
|
|
822
|
+
{
|
|
823
|
+
const pm = pluginMgr;
|
|
824
|
+
/** 挑一个客户端会话:标准 pi 引擎走 service.pluginClient(),DSH/未知引擎无此方法即 undefined。 */
|
|
825
|
+
const pickClient = () => {
|
|
826
|
+
try {
|
|
827
|
+
const svc = service;
|
|
828
|
+
return typeof svc.pluginClient === "function" ? svc.pluginClient() : undefined;
|
|
829
|
+
}
|
|
830
|
+
catch {
|
|
831
|
+
return undefined;
|
|
832
|
+
}
|
|
833
|
+
};
|
|
834
|
+
// conversationLister:本客户端运行中对话 + 当前项目历史会话摘要,只读组装
|
|
835
|
+
// {id,title,cwd,kind,isStreaming}。无客户端/方法缺失回空数组(插件显示空态)。
|
|
836
|
+
pm.conversationLister = async () => {
|
|
837
|
+
try {
|
|
838
|
+
const cs = pickClient();
|
|
839
|
+
if (!cs)
|
|
840
|
+
return [];
|
|
841
|
+
const running = typeof cs.listRunningForPlugins === "function" ? cs.listRunningForPlugins() : [];
|
|
842
|
+
const history = typeof cs.listHistoryForPlugins === "function" ? await cs.listHistoryForPlugins(50) : [];
|
|
843
|
+
return [...running, ...history];
|
|
844
|
+
}
|
|
845
|
+
catch {
|
|
846
|
+
return [];
|
|
847
|
+
}
|
|
848
|
+
};
|
|
849
|
+
// conversationSearcher:复用 search_sessions 逻辑,返回前 N 个 {id,title}。
|
|
850
|
+
pm.conversationSearcher = async (query, limit) => {
|
|
851
|
+
try {
|
|
852
|
+
const cs = pickClient();
|
|
853
|
+
if (!cs || typeof cs.searchForPlugins !== "function")
|
|
854
|
+
return [];
|
|
855
|
+
return await cs.searchForPlugins(query, limit ?? 20);
|
|
856
|
+
}
|
|
857
|
+
catch {
|
|
858
|
+
return [];
|
|
859
|
+
}
|
|
860
|
+
};
|
|
861
|
+
// conversationWriter:向指定对话投递 prompt(复用 prompt 路径);找不到对话回 {ok:false,error}。
|
|
862
|
+
pm.conversationWriter = async (id, text) => {
|
|
863
|
+
try {
|
|
864
|
+
const cs = pickClient();
|
|
865
|
+
if (!cs || typeof cs.writeForPlugins !== "function")
|
|
866
|
+
return { ok: false, error: "当前引擎不支持对话投递(仅标准 pi 引擎)" };
|
|
867
|
+
return await cs.writeForPlugins(id, text);
|
|
868
|
+
}
|
|
869
|
+
catch (err) {
|
|
870
|
+
return { ok: false, error: err.message };
|
|
871
|
+
}
|
|
872
|
+
};
|
|
873
|
+
// modelLister:复用现有模型列表,映射 {id,provider,vision}。
|
|
874
|
+
pm.modelLister = async () => {
|
|
875
|
+
try {
|
|
876
|
+
const cs = pickClient();
|
|
877
|
+
if (!cs || typeof cs.listModelsForPlugins !== "function")
|
|
878
|
+
return [];
|
|
879
|
+
return await cs.listModelsForPlugins();
|
|
880
|
+
}
|
|
881
|
+
catch {
|
|
882
|
+
return [];
|
|
883
|
+
}
|
|
884
|
+
};
|
|
885
|
+
// runSteerer:复用 ClientSession.steerForPlugins(sendUserMessage + deliverAs:'steer',
|
|
886
|
+
// 跨客户端查找由方法内部兜底);DSH/未知引擎无此方法即 not supported。
|
|
887
|
+
pm.runSteerer = async (id, text) => {
|
|
888
|
+
try {
|
|
889
|
+
const cs = pickClient();
|
|
890
|
+
if (!cs || typeof cs.steerForPlugins !== "function")
|
|
891
|
+
return { ok: false, error: "not supported" };
|
|
892
|
+
return await cs.steerForPlugins(id, text);
|
|
893
|
+
}
|
|
894
|
+
catch (err) {
|
|
895
|
+
return { ok: false, error: err.message };
|
|
896
|
+
}
|
|
897
|
+
};
|
|
898
|
+
// runAborter:有现成 abort 路径(interruptRun,卡住/空转强制重置语义继承)。
|
|
899
|
+
pm.runAborter = async (id) => {
|
|
900
|
+
try {
|
|
901
|
+
const cs = pickClient();
|
|
902
|
+
if (!cs || typeof cs.abortForPlugins !== "function")
|
|
903
|
+
return { ok: false, error: "not supported" };
|
|
904
|
+
return await cs.abortForPlugins(id);
|
|
905
|
+
}
|
|
906
|
+
catch (err) {
|
|
907
|
+
return { ok: false, error: err.message };
|
|
908
|
+
}
|
|
909
|
+
};
|
|
910
|
+
}
|
|
764
911
|
// 插件宿主工作区实时跟随当前项目:任意客户端 set_cwd 成功后同步给
|
|
765
912
|
// PluginManager,编辑器等工作区跟随型插件随即切根(详见 plugins.ts notifyCwd)。
|
|
766
913
|
service.onClientCwdChanged = (cwd, roots) => {
|
|
@@ -912,6 +1059,9 @@ wss.on("connection", (ws) => {
|
|
|
912
1059
|
case "queue_remove":
|
|
913
1060
|
cs.removeQueued(msg.kind, msg.text);
|
|
914
1061
|
break;
|
|
1062
|
+
case "draft_update":
|
|
1063
|
+
cs.saveDraft?.(msg.sessionId, msg.text, msg.ts);
|
|
1064
|
+
break;
|
|
915
1065
|
case "abort":
|
|
916
1066
|
void cs.abort();
|
|
917
1067
|
break;
|
|
@@ -931,7 +1081,7 @@ wss.on("connection", (ws) => {
|
|
|
931
1081
|
void cs.listBgServers();
|
|
932
1082
|
break;
|
|
933
1083
|
case "new_chat":
|
|
934
|
-
void cs.newChat();
|
|
1084
|
+
void cs.newChat(msg.preset);
|
|
935
1085
|
break;
|
|
936
1086
|
case "edit_message":
|
|
937
1087
|
void cs.editMessage(msg.messageId, msg.text, msg.attachments);
|
|
@@ -1010,6 +1160,18 @@ wss.on("connection", (ws) => {
|
|
|
1010
1160
|
case "upload_file":
|
|
1011
1161
|
void cs.uploadFile(msg.dirPath, msg.name, msg.data);
|
|
1012
1162
|
break;
|
|
1163
|
+
case "file_create":
|
|
1164
|
+
void cs.createEntry(msg.dir, msg.name, msg.kind);
|
|
1165
|
+
break;
|
|
1166
|
+
case "file_rename":
|
|
1167
|
+
void cs.renameEntry(msg.path, msg.newName);
|
|
1168
|
+
break;
|
|
1169
|
+
case "file_delete":
|
|
1170
|
+
void cs.deleteEntry(msg.path);
|
|
1171
|
+
break;
|
|
1172
|
+
case "file_copy":
|
|
1173
|
+
void cs.copyEntry(msg.src, msg.destDir, msg.move);
|
|
1174
|
+
break;
|
|
1013
1175
|
case "list_models":
|
|
1014
1176
|
void cs.listModels();
|
|
1015
1177
|
break;
|
|
@@ -1061,9 +1223,18 @@ wss.on("connection", (ws) => {
|
|
|
1061
1223
|
send({
|
|
1062
1224
|
type: "notice",
|
|
1063
1225
|
level: "info",
|
|
1064
|
-
text: "
|
|
1065
|
-
textEn: "Restarting the service… this page reconnects once it is back.",
|
|
1226
|
+
text: "正在重启服务…页面会在服务恢复后自动重连,进行中的对话会自动恢复并继续。",
|
|
1227
|
+
textEn: "Restarting the service… this page reconnects once it is back; running conversations resume automatically.",
|
|
1066
1228
|
});
|
|
1229
|
+
// Record streaming runs BEFORE exiting: under systemd this path
|
|
1230
|
+
// exits via process.exit without shutdown(), so without this the
|
|
1231
|
+
// post-restart resume would find nothing to continue.
|
|
1232
|
+
try {
|
|
1233
|
+
service.recordInterruptedRuns();
|
|
1234
|
+
}
|
|
1235
|
+
catch {
|
|
1236
|
+
/* best effort — never block the restart on bookkeeping */
|
|
1237
|
+
}
|
|
1067
1238
|
// Let the notice (and this socket's backlog) flush before we go down.
|
|
1068
1239
|
setTimeout(() => void scheduleQuit(), 400);
|
|
1069
1240
|
break;
|
|
@@ -1080,6 +1251,21 @@ wss.on("connection", (ws) => {
|
|
|
1080
1251
|
case "clear_provider_api_key":
|
|
1081
1252
|
void cs.clearProviderApiKey(msg.provider);
|
|
1082
1253
|
break;
|
|
1254
|
+
case "provider_oauth_start":
|
|
1255
|
+
cs.startProviderOAuth(msg.provider);
|
|
1256
|
+
break;
|
|
1257
|
+
case "provider_oauth_reply":
|
|
1258
|
+
cs.replyProviderOAuth(msg.flowId, msg.promptId, msg.value);
|
|
1259
|
+
break;
|
|
1260
|
+
case "provider_oauth_cancel":
|
|
1261
|
+
cs.cancelProviderOAuth(msg.flowId);
|
|
1262
|
+
break;
|
|
1263
|
+
case "list_provider_oauth_flows":
|
|
1264
|
+
cs.listProviderOAuthFlows();
|
|
1265
|
+
break;
|
|
1266
|
+
case "provider_oauth_logout":
|
|
1267
|
+
void cs.logoutProviderOAuth(msg.provider);
|
|
1268
|
+
break;
|
|
1083
1269
|
case "list_models_config":
|
|
1084
1270
|
void cs.listModelsConfig();
|
|
1085
1271
|
break;
|
|
@@ -1265,6 +1451,7 @@ wss.on("connection", (ws) => {
|
|
|
1265
1451
|
id: pluginId,
|
|
1266
1452
|
source: msg.source,
|
|
1267
1453
|
build: msg.build === true,
|
|
1454
|
+
noBuild: msg.noBuild === true,
|
|
1268
1455
|
}, {
|
|
1269
1456
|
lang: jobLang,
|
|
1270
1457
|
emit: (m) => send(m),
|
|
@@ -1305,6 +1492,22 @@ wss.on("connection", (ws) => {
|
|
|
1305
1492
|
}
|
|
1306
1493
|
break;
|
|
1307
1494
|
}
|
|
1495
|
+
case "plugin_dom_consent": {
|
|
1496
|
+
void pluginMgr
|
|
1497
|
+
.setDomConsent(msg.pluginId, msg.granted === true)
|
|
1498
|
+
.then((r) => {
|
|
1499
|
+
if (r.error)
|
|
1500
|
+
cs?.emitNotice("warning", `DOM 授权失败:${r.error}`, `DOM consent failed: ${r.error}`);
|
|
1501
|
+
else if (r.changed)
|
|
1502
|
+
cs?.emitNotice("info", msg.granted === true
|
|
1503
|
+
? `已授权插件「${msg.pluginId}」完全 DOM 访问`
|
|
1504
|
+
: `已撤销插件「${msg.pluginId}」完全 DOM 访问`, msg.granted === true
|
|
1505
|
+
? `Granted full DOM access to plugin "${msg.pluginId}"`
|
|
1506
|
+
: `Revoked full DOM access from plugin "${msg.pluginId}"`);
|
|
1507
|
+
})
|
|
1508
|
+
.catch(() => { });
|
|
1509
|
+
break;
|
|
1510
|
+
}
|
|
1308
1511
|
case "plugin_path_revoke": {
|
|
1309
1512
|
const removed = pluginMgr.grants.revoke(typeof msg.pluginId === "string" ? msg.pluginId : undefined, typeof msg.path === "string" ? msg.path : undefined);
|
|
1310
1513
|
cs?.emitNotice("info", `已撤销 ${removed} 条插件目录授权`, `Revoked ${removed} plugin path grant(s)`);
|
|
@@ -1336,6 +1539,21 @@ wss.on("connection", (ws) => {
|
|
|
1336
1539
|
case "dsh_patches_list":
|
|
1337
1540
|
void cs.listDshPatches?.();
|
|
1338
1541
|
break;
|
|
1542
|
+
case "dsh_preset_list":
|
|
1543
|
+
void cs.refreshAgentPresets?.();
|
|
1544
|
+
break;
|
|
1545
|
+
case "dsh_preset_select":
|
|
1546
|
+
void cs.selectAgentPreset?.(msg.preset);
|
|
1547
|
+
break;
|
|
1548
|
+
case "dsh_preset_default":
|
|
1549
|
+
void cs.setDefaultAgentPreset?.(msg.preset);
|
|
1550
|
+
break;
|
|
1551
|
+
case "dsh_permission_set":
|
|
1552
|
+
void cs.setPermissionPreset?.(msg.preset);
|
|
1553
|
+
break;
|
|
1554
|
+
case "dsh_permission_default":
|
|
1555
|
+
void cs.setDefaultPermissionPreset?.(msg.preset);
|
|
1556
|
+
break;
|
|
1339
1557
|
case "dsh_patches_rescan":
|
|
1340
1558
|
void cs.rescanDshPatches?.();
|
|
1341
1559
|
break;
|
|
@@ -1514,6 +1732,26 @@ httpServer.listen(PORT, HOST, () => {
|
|
|
1514
1732
|
});
|
|
1515
1733
|
// 上传文件保留期清理:启动扫一次 + 每 6 小时一次(best-effort,见 uploads.ts)
|
|
1516
1734
|
scheduleUploadCleanup();
|
|
1735
|
+
// 开机目录预同步(issue #165):PI_WEB_PLUGIN_CATALOG_URL 指向一份插件市场目录文档
|
|
1736
|
+
// (headless/预置场景,不开浏览器也能装插件)。只跑一次、失败只告警不阻断启动;
|
|
1737
|
+
// 条目逐条安装/更新(托管实例上安装会被安装器拒绝,但列表本身仍会更新)。
|
|
1738
|
+
const BOOT_CATALOG_URL = (process.env.PI_WEB_PLUGIN_CATALOG_URL ?? "").trim();
|
|
1739
|
+
if (BOOT_CATALOG_URL) {
|
|
1740
|
+
void syncPluginCatalog(BOOT_CATALOG_URL, { install: true }, {
|
|
1741
|
+
customCatalogPath: pluginMgr.customCatalogPath,
|
|
1742
|
+
pluginsDir: join(DATA_DIR, "plugins"),
|
|
1743
|
+
installer: pluginInstaller,
|
|
1744
|
+
afterWrite: () => reloadPluginsAndPush(),
|
|
1745
|
+
}).then((r) => {
|
|
1746
|
+
if (!r.ok) {
|
|
1747
|
+
console.warn(`[catalog] PI_WEB_PLUGIN_CATALOG_URL 同步失败(不阻断启动): ${r.error}`);
|
|
1748
|
+
return;
|
|
1749
|
+
}
|
|
1750
|
+
const bad = (r.installed ?? []).filter((i) => !i.ok);
|
|
1751
|
+
console.log(`[catalog] PI_WEB_PLUGIN_CATALOG_URL 同步完成:安装 ${(r.installed ?? []).length - bad.length} 成功 / ${bad.length} 失败` +
|
|
1752
|
+
(bad.length ? `:${bad.map((i) => `${i.id}(${i.error ?? "?"})`).join(";")}` : ""));
|
|
1753
|
+
});
|
|
1754
|
+
}
|
|
1517
1755
|
// Local control socket (status / quiesce / unquiesce) — same data dir the
|
|
1518
1756
|
// CLI uses, so `pi-web-ui server status|quiesce|unquiesce` just works.
|
|
1519
1757
|
const stopControl = startControlServer({ service, dataDir: DATA_DIR, port: PORT });
|
|
@@ -1527,6 +1765,7 @@ async function shutdown() {
|
|
|
1527
1765
|
stopControl();
|
|
1528
1766
|
pluginMgr.dispose();
|
|
1529
1767
|
pluginInstaller.dispose();
|
|
1768
|
+
mcpHotReload.dispose();
|
|
1530
1769
|
mcpBridge.dispose();
|
|
1531
1770
|
await service.disposeAll();
|
|
1532
1771
|
wss.close();
|