pi-web-ui 0.28.2 → 0.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +295 -295
- package/README.zh-CN.md +279 -279
- package/bin/pi-web-ui.mjs +0 -0
- package/deploy/com.xingshuyin.pi-web-ui.plist +48 -48
- package/deploy/nginx-subpath.conf +88 -88
- package/deploy/pi-web-ui-task.xml +71 -71
- package/deploy/pi-web-ui.service +31 -31
- package/dist/server/agent-service.js +408 -3851
- package/dist/server/attachments.js +621 -0
- package/dist/server/bg-servers.js +138 -0
- package/dist/server/client-state.js +148 -0
- package/dist/server/files-service.js +633 -0
- package/dist/server/goal-service.js +869 -0
- package/dist/server/index.js +144 -8
- package/dist/server/model-admin.js +727 -0
- package/dist/server/process-utils.js +86 -0
- package/dist/server/protocol-version.js +11 -0
- package/dist/server/scm.js +298 -0
- package/dist/server/settings-service.js +268 -0
- package/dist/server/slash-commands.js +245 -0
- package/dist/server/terminals.js +98 -0
- package/dist/server/text-sniff.js +268 -0
- package/dist/server/uploads.js +107 -0
- package/dist/server/webui-context.js +208 -0
- package/extensions/webui.ts +192 -192
- package/package.json +94 -87
- package/themes/light.css +6318 -6318
- package/web/dist/assets/TerminalPanel-6GBZ9nXN.css +32 -0
- package/web/dist/assets/TerminalPanel-B-bsYqea.js +2 -0
- package/web/dist/assets/index-BsrqFaSZ.js +13 -0
- package/web/dist/assets/index-Dsb8Bak1.css +10 -0
- package/web/dist/assets/markdown-DRBrS2Nf.js +51 -0
- package/web/dist/assets/react-C9ovnpIm.js +24 -0
- package/web/dist/assets/xterm-D1D2FVe3.js +38 -0
- package/web/dist/favicon.svg +8 -8
- package/web/dist/index.html +17 -15
- package/web/public/favicon.svg +8 -8
- package/web/dist/assets/index-BnDkdKFN.css +0 -41
- package/web/dist/assets/index-DmmSVSzk.js +0 -129
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* process-utils — 跨平台进程工具:监听端口快照、进程树查杀、进程名查询。
|
|
3
|
+
* 后台任务面板(bgServers)用它们检测/停止 agent 在后台拉起的服务。
|
|
4
|
+
* 从 agent-service.ts 抽出,行为保持不变。全平台 best-effort:失败静默。
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* Snapshot currently LISTENING TCP ports → owning pid. Windows: netstat;
|
|
8
|
+
* POSIX: lsof. Used to detect servers the agent started in the background
|
|
9
|
+
* (the bash tool itself exits, leaving e.g. `npm run dev &` listening).
|
|
10
|
+
*/
|
|
11
|
+
export async function snapshotListeningPorts() {
|
|
12
|
+
const m = new Map();
|
|
13
|
+
try {
|
|
14
|
+
const { execFile } = await import("node:child_process");
|
|
15
|
+
if (process.platform === "win32") {
|
|
16
|
+
const out = await new Promise((resolve, reject) => execFile("netstat", ["-ano", "-p", "tcp"], { windowsHide: true, timeout: 8000 }, (err, stdout) => (err ? reject(err) : resolve(stdout))));
|
|
17
|
+
for (const line of out.split(/\r?\n/)) {
|
|
18
|
+
const p = line.trim().split(/\s+/);
|
|
19
|
+
// TCP 0.0.0.0:5173 0.0.0.0:0 LISTENING 12345
|
|
20
|
+
if (p.length >= 5 && p[0] === "TCP" && p[3] === "LISTENING") {
|
|
21
|
+
const port = Number(p[1].split(":").pop());
|
|
22
|
+
const pid = Number(p[4]);
|
|
23
|
+
if (Number.isFinite(port) && Number.isFinite(pid))
|
|
24
|
+
m.set(port, pid);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
const out = await new Promise((resolve, reject) => execFile("lsof", ["-iTCP", "-sTCP:LISTEN", "-P", "-n"], { timeout: 8000 }, (err, stdout) => (err ? reject(err) : resolve(stdout))));
|
|
30
|
+
for (const line of out.split(/\r?\n/).slice(1)) {
|
|
31
|
+
const p = line.trim().split(/\s+/);
|
|
32
|
+
if (p.length >= 9) {
|
|
33
|
+
// NAME column tail: "*:5173 (LISTEN)" or "[::1]:5173 (LISTEN)"
|
|
34
|
+
const mm = (p[p.length - 1] ?? "").match(/(\d+)\)?\s*$/);
|
|
35
|
+
const port = mm ? Number(mm[1]) : NaN;
|
|
36
|
+
const pid = Number(p[1]);
|
|
37
|
+
if (Number.isFinite(port) && Number.isFinite(pid))
|
|
38
|
+
m.set(port, pid);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// best effort — snapshot failure just means no tracking this round
|
|
45
|
+
}
|
|
46
|
+
return m;
|
|
47
|
+
}
|
|
48
|
+
/** Kill a pid and its whole process tree (cross-platform). */
|
|
49
|
+
export function killPidTree(pid) {
|
|
50
|
+
try {
|
|
51
|
+
if (process.platform === "win32") {
|
|
52
|
+
void import("node:child_process").then(({ spawn }) => {
|
|
53
|
+
spawn("taskkill", ["/F", "/T", "/PID", String(pid)], {
|
|
54
|
+
stdio: "ignore",
|
|
55
|
+
detached: true,
|
|
56
|
+
windowsHide: true,
|
|
57
|
+
}).unref();
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
process.kill(-pid, "SIGKILL");
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// already dead
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** Best-effort process name for a pid (tasklist on win32, ps on POSIX).
|
|
69
|
+
* Returns undefined when the process is gone or the lookup fails. */
|
|
70
|
+
export async function lookupProcessName(pid) {
|
|
71
|
+
try {
|
|
72
|
+
const { execFile } = await import("node:child_process");
|
|
73
|
+
if (process.platform === "win32") {
|
|
74
|
+
const out = await new Promise((resolve, reject) => execFile("tasklist", ["/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"], { windowsHide: true, timeout: 4000 }, (err, stdout) => (err ? reject(err) : resolve(stdout))));
|
|
75
|
+
// CSV: "node.exe","12345",...
|
|
76
|
+
const m = out.match(/"([^"]+)"/);
|
|
77
|
+
return m ? m[1] : undefined;
|
|
78
|
+
}
|
|
79
|
+
const out = await new Promise((resolve, reject) => execFile("ps", ["-o", "comm=", "-p", String(pid)], { timeout: 4000 }, (err, stdout) => (err ? reject(err) : resolve(stdout))));
|
|
80
|
+
const name = out.trim();
|
|
81
|
+
return name || undefined;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire-protocol version. Bump whenever a protocol change would make an old
|
|
3
|
+
* browser tab talk to a newer server (or vice versa) in a broken way — the
|
|
4
|
+
* classic symptom is "UI is new, WS handling is old" after an in-place app
|
|
5
|
+
* update before the auto-restart completes.
|
|
6
|
+
*
|
|
7
|
+
* Lives OUTSIDE protocol.ts (which must stay pure types). The frontend keeps
|
|
8
|
+
* its own copy in web/src/protocol-version.ts; scripts/check-protocol-sync.mjs
|
|
9
|
+
* verifies the two never drift.
|
|
10
|
+
*/
|
|
11
|
+
export const PROTOCOL_VERSION = 1;
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only git queries backing the source-control panel.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the old hidden-PTY + text-scraping approach: every query is a
|
|
5
|
+
* plain `execFile("git", …)` — no shell, no prompts, no echo, no sentinel
|
|
6
|
+
* parsing. Output is parsed into structured JSON on the server; the client
|
|
7
|
+
* just renders it.
|
|
8
|
+
*/
|
|
9
|
+
import { execFile } from "node:child_process";
|
|
10
|
+
import { promisify } from "node:util";
|
|
11
|
+
const exec = promisify(execFile);
|
|
12
|
+
/** Per-command timeout / output cap — a stuck repo must not hang the panel. */
|
|
13
|
+
const GIT_TIMEOUT_MS = 15_000;
|
|
14
|
+
const MAX_GIT_OUTPUT = 16 * 1024 * 1024;
|
|
15
|
+
/** Run one git command; throws Error with a readable message on failure. */
|
|
16
|
+
async function git(cwd, args) {
|
|
17
|
+
try {
|
|
18
|
+
const { stdout } = await exec("git", ["-c", "core.quotepath=false", ...args], {
|
|
19
|
+
cwd,
|
|
20
|
+
timeout: GIT_TIMEOUT_MS,
|
|
21
|
+
maxBuffer: MAX_GIT_OUTPUT,
|
|
22
|
+
windowsHide: true,
|
|
23
|
+
});
|
|
24
|
+
return stdout;
|
|
25
|
+
}
|
|
26
|
+
catch (err) {
|
|
27
|
+
const e = err;
|
|
28
|
+
if (e.code === "ENOENT")
|
|
29
|
+
throw new Error("未找到 git 命令——请确认已安装 Git 并在 PATH 中");
|
|
30
|
+
if (e.killed)
|
|
31
|
+
throw new Error("git 命令超时");
|
|
32
|
+
const detail = (e.stderr ?? e.message ?? "").trim().split("\n")[0];
|
|
33
|
+
throw new Error(detail || "git 命令失败");
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/** Absolute path of the repo's git dir (handles worktrees/submodules), or
|
|
37
|
+
* null when cwd isn't inside a repository. */
|
|
38
|
+
export async function gitDirOf(cwd) {
|
|
39
|
+
try {
|
|
40
|
+
const { stdout } = await exec("git", ["rev-parse", "--absolute-git-dir"], {
|
|
41
|
+
cwd,
|
|
42
|
+
timeout: 5_000,
|
|
43
|
+
windowsHide: true,
|
|
44
|
+
});
|
|
45
|
+
return stdout.trim() || null;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** True when the error is "this directory isn't a git repository". */
|
|
52
|
+
export function isNotRepoError(err) {
|
|
53
|
+
return /not a git repository/i.test(err instanceof Error ? err.message : String(err));
|
|
54
|
+
}
|
|
55
|
+
/* ------------------------------------------------------------------ */
|
|
56
|
+
/* parsers */
|
|
57
|
+
/* ------------------------------------------------------------------ */
|
|
58
|
+
/** Undo git's C-style quoting for unusual file names ("a\tb" → a<TAB>b).
|
|
59
|
+
* core.quotepath=false already handles non-ASCII; this covers control chars. */
|
|
60
|
+
function unquotePath(s) {
|
|
61
|
+
if (!s.startsWith('"'))
|
|
62
|
+
return s;
|
|
63
|
+
const inner = s.endsWith('"') ? s.slice(1, -1) : s.slice(1);
|
|
64
|
+
return inner.replace(/\\(.)/g, (_m, c) => {
|
|
65
|
+
switch (c) {
|
|
66
|
+
case "n": return "\n";
|
|
67
|
+
case "t": return "\t";
|
|
68
|
+
case "r": return "\r";
|
|
69
|
+
case "b": return "\b";
|
|
70
|
+
case "a": return "\a";
|
|
71
|
+
case "f": return "\f";
|
|
72
|
+
case "v": return "\v";
|
|
73
|
+
case "\\": return "\\";
|
|
74
|
+
case '"': return '"';
|
|
75
|
+
default: return c;
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
function parseStatusHeader(rest) {
|
|
80
|
+
const out = {
|
|
81
|
+
branch: "HEAD",
|
|
82
|
+
detached: false,
|
|
83
|
+
upstream: null,
|
|
84
|
+
ahead: 0,
|
|
85
|
+
behind: 0,
|
|
86
|
+
upstreamGone: false,
|
|
87
|
+
};
|
|
88
|
+
let branchPart = rest;
|
|
89
|
+
let flags = "";
|
|
90
|
+
const bi = rest.indexOf(" [");
|
|
91
|
+
if (bi >= 0) {
|
|
92
|
+
branchPart = rest.slice(0, bi);
|
|
93
|
+
flags = rest.slice(bi + 2);
|
|
94
|
+
if (flags.endsWith("]"))
|
|
95
|
+
flags = flags.slice(0, -1);
|
|
96
|
+
}
|
|
97
|
+
if (branchPart === "HEAD (no branch)" || branchPart === "HEAD") {
|
|
98
|
+
out.detached = true;
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
if (branchPart.startsWith("No commits yet on "))
|
|
102
|
+
branchPart = branchPart.slice("No commits yet on ".length);
|
|
103
|
+
const up = branchPart.indexOf("...");
|
|
104
|
+
if (up >= 0) {
|
|
105
|
+
out.branch = branchPart.slice(0, up);
|
|
106
|
+
out.upstream = branchPart.slice(up + 3);
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
out.branch = branchPart;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (flags) {
|
|
113
|
+
for (const part of flags.split(",")) {
|
|
114
|
+
const p = part.trim();
|
|
115
|
+
const m = p.match(/^(ahead|behind) (\d+)$/);
|
|
116
|
+
if (m) {
|
|
117
|
+
if (m[1] === "ahead")
|
|
118
|
+
out.ahead = Number(m[2]);
|
|
119
|
+
else
|
|
120
|
+
out.behind = Number(m[2]);
|
|
121
|
+
}
|
|
122
|
+
else if (p === "gone") {
|
|
123
|
+
out.upstreamGone = true;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
function parseStatusFiles(text) {
|
|
130
|
+
const out = [];
|
|
131
|
+
for (const rawLine of text.split("\n")) {
|
|
132
|
+
const line = rawLine.trimEnd();
|
|
133
|
+
if (!line || line.startsWith("## "))
|
|
134
|
+
continue;
|
|
135
|
+
if (line.length >= 3) {
|
|
136
|
+
let path = line.slice(3);
|
|
137
|
+
const arrow = path.indexOf(" -> "); // rename: "R old -> new"
|
|
138
|
+
if (arrow >= 0)
|
|
139
|
+
path = path.slice(arrow + 4);
|
|
140
|
+
out.push({ path: unquotePath(path), x: line[0], y: line[1] });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
function parseBranches(text) {
|
|
146
|
+
const out = [];
|
|
147
|
+
for (const line of text.split("\n")) {
|
|
148
|
+
const parts = line.split("\t");
|
|
149
|
+
if (parts.length < 2)
|
|
150
|
+
continue;
|
|
151
|
+
const ref = parts[0];
|
|
152
|
+
const isHead = parts[1] === "*";
|
|
153
|
+
if (ref.startsWith("refs/heads/")) {
|
|
154
|
+
out.push({ name: ref.slice("refs/heads/".length), current: isHead });
|
|
155
|
+
}
|
|
156
|
+
else if (ref.startsWith("refs/remotes/")) {
|
|
157
|
+
const short = ref.slice("refs/remotes/".length);
|
|
158
|
+
// Skip the "origin/HEAD -> origin/main" symlink.
|
|
159
|
+
if (short.endsWith("/HEAD"))
|
|
160
|
+
continue;
|
|
161
|
+
const slash = short.indexOf("/");
|
|
162
|
+
out.push({
|
|
163
|
+
name: short,
|
|
164
|
+
current: false,
|
|
165
|
+
remote: slash > 0 ? short.slice(0, slash) : true,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
/** Parse `git log --graph --pretty=format:%H%x09…` preserving graph prefixes. */
|
|
172
|
+
function parseCommitHistory(text) {
|
|
173
|
+
const out = [];
|
|
174
|
+
for (const line of text.split("\n")) {
|
|
175
|
+
const tab = line.indexOf("\t");
|
|
176
|
+
if (tab < 0)
|
|
177
|
+
continue; // connector-only graph line
|
|
178
|
+
const prefixAndHash = line.slice(0, tab);
|
|
179
|
+
const match = prefixAndHash.match(/([0-9a-f]{7,40})$/i);
|
|
180
|
+
if (!match || match.index === undefined)
|
|
181
|
+
continue;
|
|
182
|
+
const fields = line.slice(tab + 1).split("\t");
|
|
183
|
+
// An empty decoration field removes the final tab — four fields is a
|
|
184
|
+
// valid undecorated commit.
|
|
185
|
+
if (fields.length < 4)
|
|
186
|
+
continue;
|
|
187
|
+
out.push({
|
|
188
|
+
hash: match[1],
|
|
189
|
+
shortHash: fields[0],
|
|
190
|
+
author: fields[1],
|
|
191
|
+
date: fields[2],
|
|
192
|
+
subject: fields[3],
|
|
193
|
+
decorations: fields[4] ?? "",
|
|
194
|
+
graph: prefixAndHash.slice(0, match.index),
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
return out;
|
|
198
|
+
}
|
|
199
|
+
/** Parse `git diff --numstat` output: "12\t3\tpath" → { path: [add, del] }. */
|
|
200
|
+
function parseNumStat(text) {
|
|
201
|
+
const stats = {};
|
|
202
|
+
for (const line of text.split("\n")) {
|
|
203
|
+
if (!line.trim())
|
|
204
|
+
continue;
|
|
205
|
+
const tab1 = line.indexOf("\t");
|
|
206
|
+
const tab2 = tab1 < 0 ? -1 : line.indexOf("\t", tab1 + 1);
|
|
207
|
+
if (tab2 < 0)
|
|
208
|
+
continue;
|
|
209
|
+
let add = Number(line.slice(0, tab1));
|
|
210
|
+
let del = Number(line.slice(tab1 + 1, tab2));
|
|
211
|
+
if (!Number.isFinite(add))
|
|
212
|
+
add = 0; // binary file → "-"
|
|
213
|
+
if (!Number.isFinite(del))
|
|
214
|
+
del = 0;
|
|
215
|
+
const path = unquotePath(line.slice(tab2 + 1).trim());
|
|
216
|
+
const prev = stats[path];
|
|
217
|
+
stats[path] = [
|
|
218
|
+
(prev?.[0] ?? 0) + add,
|
|
219
|
+
(prev?.[1] ?? 0) + del,
|
|
220
|
+
];
|
|
221
|
+
}
|
|
222
|
+
return stats;
|
|
223
|
+
}
|
|
224
|
+
/* ------------------------------------------------------------------ */
|
|
225
|
+
/* queries */
|
|
226
|
+
/* ------------------------------------------------------------------ */
|
|
227
|
+
const HISTORY_ARGS = [
|
|
228
|
+
"log",
|
|
229
|
+
"--all",
|
|
230
|
+
"--graph",
|
|
231
|
+
"--decorate=short",
|
|
232
|
+
"--date=short",
|
|
233
|
+
"--pretty=format:%H%x09%h%x09%an%x09%ad%x09%s%x09%D",
|
|
234
|
+
"-n",
|
|
235
|
+
"120",
|
|
236
|
+
];
|
|
237
|
+
/** Commit graph for the history tab — fetched lazily so the common
|
|
238
|
+
* "changes" view never pays for it on huge repos. */
|
|
239
|
+
export async function scmHistory(cwd) {
|
|
240
|
+
return parseCommitHistory(await git(cwd, HISTORY_ARGS));
|
|
241
|
+
}
|
|
242
|
+
/** Status refresh payload (status + branches + numstat) — parallel. */
|
|
243
|
+
export async function scmStatus(cwd) {
|
|
244
|
+
const [statusText, branchText, statText, cachedStatText] = await Promise.all([
|
|
245
|
+
git(cwd, ["status", "--porcelain=v1", "-b", "--find-renames"]),
|
|
246
|
+
git(cwd, [
|
|
247
|
+
"for-each-ref",
|
|
248
|
+
"refs/heads",
|
|
249
|
+
"refs/remotes",
|
|
250
|
+
"--format=%(refname)%09%(HEAD)",
|
|
251
|
+
]),
|
|
252
|
+
git(cwd, ["diff", "--numstat"]),
|
|
253
|
+
git(cwd, ["diff", "--cached", "--numstat"]),
|
|
254
|
+
]);
|
|
255
|
+
const header = parseStatusHeader(statusText.split("\n").find((l) => l.startsWith("## "))?.slice(3) ?? "");
|
|
256
|
+
// Worktree + staged line counts, merged per path.
|
|
257
|
+
const merged = {};
|
|
258
|
+
for (const [path, pair] of Object.entries(parseNumStat(statText))) {
|
|
259
|
+
merged[path] = pair;
|
|
260
|
+
}
|
|
261
|
+
for (const [path, pair] of Object.entries(parseNumStat(cachedStatText))) {
|
|
262
|
+
const prev = merged[path];
|
|
263
|
+
merged[path] = [(prev?.[0] ?? 0) + pair[0], (prev?.[1] ?? 0) + pair[1]];
|
|
264
|
+
}
|
|
265
|
+
return {
|
|
266
|
+
notRepo: false,
|
|
267
|
+
branch: header.branch,
|
|
268
|
+
detached: header.detached,
|
|
269
|
+
upstream: header.upstream,
|
|
270
|
+
ahead: header.ahead,
|
|
271
|
+
behind: header.behind,
|
|
272
|
+
upstreamGone: header.upstreamGone,
|
|
273
|
+
files: parseStatusFiles(statusText),
|
|
274
|
+
branches: parseBranches(branchText),
|
|
275
|
+
stats: merged,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
/** Staged + worktree diffs for one file (empty strings when no diff). */
|
|
279
|
+
export async function scmFileDiff(cwd, path) {
|
|
280
|
+
const [staged, worktree] = await Promise.all([
|
|
281
|
+
git(cwd, ["diff", "--cached", "--no-color", "--no-ext-diff", "--", path]).catch(() => ""),
|
|
282
|
+
git(cwd, ["diff", "--no-color", "--no-ext-diff", "--", path]).catch(() => ""),
|
|
283
|
+
]);
|
|
284
|
+
return { staged, worktree };
|
|
285
|
+
}
|
|
286
|
+
/** Full patch of one commit (`git show`). */
|
|
287
|
+
export async function scmCommitDetail(cwd, hash) {
|
|
288
|
+
return git(cwd, [
|
|
289
|
+
"show",
|
|
290
|
+
"--no-color",
|
|
291
|
+
"--no-ext-diff",
|
|
292
|
+
"--find-renames",
|
|
293
|
+
"--format=fuller",
|
|
294
|
+
"--stat",
|
|
295
|
+
"--patch",
|
|
296
|
+
hash,
|
|
297
|
+
]);
|
|
298
|
+
}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings service — 从 agent-service.ts 抽出(系统提示词 / 技能插件开关 /
|
|
3
|
+
* 目标审查提示词 / 预设 / 视觉桥偏好)。设置持久化在 client-state.json 按客户端隔离。
|
|
4
|
+
*
|
|
5
|
+
* 经 SettingsHost 回调与 ClientSession 解耦:本模块只管「设置状态 + 面板推送 +
|
|
6
|
+
* 预设存取 + 何时需要 reload」,真正动 runtime 的 session.reload() 走宿主回调
|
|
7
|
+
* (reloadSession 里还会刷新斜杠命令目录)。
|
|
8
|
+
*/
|
|
9
|
+
import { basename } from "node:path";
|
|
10
|
+
import { extensionKey } from "./client-state.js";
|
|
11
|
+
import { findVisionModels, SYSTEM_PROMPT } from "./vision-bridge.js";
|
|
12
|
+
export class SettingsService {
|
|
13
|
+
host;
|
|
14
|
+
settings;
|
|
15
|
+
presets;
|
|
16
|
+
knownSkills = new Map();
|
|
17
|
+
knownExtensions = new Map();
|
|
18
|
+
/** 流式中改了需要 reload 的设置 → agent_end 后延迟应用(防拆毁运行中 run)。 */
|
|
19
|
+
pendingReload = false;
|
|
20
|
+
constructor(host) {
|
|
21
|
+
this.host = host;
|
|
22
|
+
this.settings = host.stateStore.getSettings(host.clientId);
|
|
23
|
+
this.presets = host.stateStore.getPresets(host.clientId);
|
|
24
|
+
}
|
|
25
|
+
get current() {
|
|
26
|
+
return this.settings;
|
|
27
|
+
}
|
|
28
|
+
get reviewPrefs() {
|
|
29
|
+
return {
|
|
30
|
+
reviewPrompt: this.settings.reviewPrompt,
|
|
31
|
+
reviewDisabledSkills: this.settings.reviewDisabledSkills,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
hasPendingReload() {
|
|
35
|
+
return this.pendingReload;
|
|
36
|
+
}
|
|
37
|
+
consumePendingReload() {
|
|
38
|
+
const v = this.pendingReload;
|
|
39
|
+
this.pendingReload = false;
|
|
40
|
+
return v;
|
|
41
|
+
}
|
|
42
|
+
push() {
|
|
43
|
+
const disabledSkills = new Set(this.settings.disabledSkills);
|
|
44
|
+
const reviewDisabledSkills = new Set(this.settings.reviewDisabledSkills);
|
|
45
|
+
const disabledExts = new Set(this.settings.disabledExtensions);
|
|
46
|
+
try {
|
|
47
|
+
// Refresh the cache with the CURRENTLY loaded set (post-filter).
|
|
48
|
+
for (const s of this.host.getSession().resourceLoader.getSkills().skills) {
|
|
49
|
+
this.knownSkills.set(s.name, {
|
|
50
|
+
name: s.name,
|
|
51
|
+
description: s.description,
|
|
52
|
+
enabled: true,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
for (const e of this.host.getSession().resourceLoader.getExtensions().extensions) {
|
|
56
|
+
const id = extensionKey(e);
|
|
57
|
+
const p = e.sourceInfo?.path ?? e.path;
|
|
58
|
+
this.knownExtensions.set(id, {
|
|
59
|
+
id,
|
|
60
|
+
name: e.sourceInfo?.origin === "package" && e.sourceInfo.source
|
|
61
|
+
? e.sourceInfo.source
|
|
62
|
+
: basename(p),
|
|
63
|
+
path: p,
|
|
64
|
+
enabled: true,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// Session not ready yet — keep whatever we already know.
|
|
70
|
+
}
|
|
71
|
+
// Disabled entries are filtered out of the loader — keep them in the
|
|
72
|
+
// panel (with the last-known description) so they can be re-enabled.
|
|
73
|
+
for (const name of this.settings.disabledSkills) {
|
|
74
|
+
if (!this.knownSkills.has(name)) {
|
|
75
|
+
this.knownSkills.set(name, { name, description: "", enabled: false });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
for (const id of this.settings.disabledExtensions) {
|
|
79
|
+
if (!this.knownExtensions.has(id)) {
|
|
80
|
+
this.knownExtensions.set(id, {
|
|
81
|
+
id,
|
|
82
|
+
name: id.startsWith("npm:") ? id : basename(id),
|
|
83
|
+
path: "",
|
|
84
|
+
enabled: false,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const skills = [...this.knownSkills.values()]
|
|
89
|
+
.map((s) => ({ ...s, enabled: !disabledSkills.has(s.name) }))
|
|
90
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
91
|
+
const reviewSkills = [...this.knownSkills.values()]
|
|
92
|
+
.map((s) => ({ ...s, enabled: !reviewDisabledSkills.has(s.name) }))
|
|
93
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
94
|
+
const extensions = [...this.knownExtensions.values()]
|
|
95
|
+
.map((e) => ({ ...e, enabled: !disabledExts.has(e.id) }))
|
|
96
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
97
|
+
this.host.emit({
|
|
98
|
+
type: "settings_state",
|
|
99
|
+
settings: {
|
|
100
|
+
promptMode: this.settings.promptMode,
|
|
101
|
+
customSystemPrompt: this.settings.customSystemPrompt,
|
|
102
|
+
visionBridgeEnabled: this.settings.visionBridgeEnabled,
|
|
103
|
+
visionBridgeModel: this.settings.visionBridgeModel,
|
|
104
|
+
visionBridgePromptMode: this.settings.visionBridgePromptMode,
|
|
105
|
+
visionBridgePrompt: this.settings.visionBridgePrompt,
|
|
106
|
+
reviewPrompt: this.settings.reviewPrompt,
|
|
107
|
+
reviewDisabledSkills: [...this.settings.reviewDisabledSkills],
|
|
108
|
+
// The built-in prompts, so the replace-mode editors can prefill the
|
|
109
|
+
// text they would otherwise replace (empty until the resource-loader
|
|
110
|
+
// has run once for the system prompt).
|
|
111
|
+
defaultSystemPrompt: this.host.effectiveDefaultSystemPrompt(),
|
|
112
|
+
visionBridgeDefaultPrompt: SYSTEM_PROMPT,
|
|
113
|
+
visionModels: this.collectVisionModels(),
|
|
114
|
+
disabledSkills: [...this.settings.disabledSkills],
|
|
115
|
+
disabledExtensions: [...this.settings.disabledExtensions],
|
|
116
|
+
skills,
|
|
117
|
+
reviewSkills,
|
|
118
|
+
extensions,
|
|
119
|
+
presets: this.presets.map((p) => ({ ...p })),
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
/** Vision-capable configured models, for the settings-panel picker. */
|
|
124
|
+
collectVisionModels() {
|
|
125
|
+
try {
|
|
126
|
+
return findVisionModels(this.host.getSession().modelRuntime).map((m) => ({
|
|
127
|
+
provider: m.provider,
|
|
128
|
+
id: m.id,
|
|
129
|
+
label: m.label,
|
|
130
|
+
}));
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
// Session not ready yet — the picker stays empty until next push.
|
|
134
|
+
return [];
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/** Persist + apply a partial settings update (prompt text/mode, toggles). */
|
|
138
|
+
async set(partial) {
|
|
139
|
+
const needsReload = partial.promptMode !== undefined ||
|
|
140
|
+
partial.customSystemPrompt !== undefined ||
|
|
141
|
+
partial.disabledSkills !== undefined ||
|
|
142
|
+
partial.disabledExtensions !== undefined;
|
|
143
|
+
if (partial.promptMode !== undefined)
|
|
144
|
+
this.settings.promptMode = partial.promptMode;
|
|
145
|
+
if (partial.customSystemPrompt !== undefined) {
|
|
146
|
+
this.settings.customSystemPrompt = partial.customSystemPrompt;
|
|
147
|
+
}
|
|
148
|
+
if (partial.disabledSkills !== undefined) {
|
|
149
|
+
this.settings.disabledSkills = partial.disabledSkills;
|
|
150
|
+
}
|
|
151
|
+
if (partial.disabledExtensions !== undefined) {
|
|
152
|
+
this.settings.disabledExtensions = partial.disabledExtensions;
|
|
153
|
+
}
|
|
154
|
+
if (partial.visionBridgeEnabled !== undefined) {
|
|
155
|
+
this.settings.visionBridgeEnabled = partial.visionBridgeEnabled;
|
|
156
|
+
}
|
|
157
|
+
if (partial.visionBridgeModel !== undefined) {
|
|
158
|
+
this.settings.visionBridgeModel = partial.visionBridgeModel ?? null;
|
|
159
|
+
}
|
|
160
|
+
if (partial.visionBridgePromptMode !== undefined) {
|
|
161
|
+
this.settings.visionBridgePromptMode = partial.visionBridgePromptMode;
|
|
162
|
+
}
|
|
163
|
+
if (partial.visionBridgePrompt !== undefined) {
|
|
164
|
+
this.settings.visionBridgePrompt = partial.visionBridgePrompt;
|
|
165
|
+
}
|
|
166
|
+
if (partial.reviewPrompt !== undefined) {
|
|
167
|
+
this.settings.reviewPrompt = partial.reviewPrompt;
|
|
168
|
+
}
|
|
169
|
+
if (partial.reviewDisabledSkills !== undefined) {
|
|
170
|
+
this.settings.reviewDisabledSkills = partial.reviewDisabledSkills;
|
|
171
|
+
}
|
|
172
|
+
this.host.stateStore.saveSettings(this.host.clientId, this.settings);
|
|
173
|
+
this.push();
|
|
174
|
+
if (needsReload)
|
|
175
|
+
await this.applyRuntime();
|
|
176
|
+
}
|
|
177
|
+
/** Save the CURRENT settings as a named preset (overwrites if exists). */
|
|
178
|
+
async savePreset(name) {
|
|
179
|
+
const n = name.trim();
|
|
180
|
+
if (!n) {
|
|
181
|
+
this.host.emit({ type: "notice", level: "error", text: "预设名称不能为空" });
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const preset = {
|
|
185
|
+
name: n,
|
|
186
|
+
promptMode: this.settings.promptMode,
|
|
187
|
+
customSystemPrompt: this.settings.customSystemPrompt,
|
|
188
|
+
disabledSkills: [...this.settings.disabledSkills],
|
|
189
|
+
disabledExtensions: [...this.settings.disabledExtensions],
|
|
190
|
+
reviewPrompt: this.settings.reviewPrompt,
|
|
191
|
+
reviewDisabledSkills: [...this.settings.reviewDisabledSkills],
|
|
192
|
+
};
|
|
193
|
+
const existing = this.presets.findIndex((p) => p.name === n);
|
|
194
|
+
if (existing >= 0)
|
|
195
|
+
this.presets[existing] = preset;
|
|
196
|
+
else
|
|
197
|
+
this.presets.push(preset);
|
|
198
|
+
this.host.stateStore.savePresets(this.host.clientId, this.presets);
|
|
199
|
+
this.push();
|
|
200
|
+
}
|
|
201
|
+
/** Replace the current settings with the named preset and apply it. */
|
|
202
|
+
async applyPreset(name) {
|
|
203
|
+
const p = this.presets.find((x) => x.name === name);
|
|
204
|
+
if (!p) {
|
|
205
|
+
this.host.emit({ type: "notice", level: "error", text: `预设不存在:${name}` });
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
this.settings = {
|
|
209
|
+
promptMode: p.promptMode,
|
|
210
|
+
customSystemPrompt: p.customSystemPrompt,
|
|
211
|
+
disabledSkills: [...p.disabledSkills],
|
|
212
|
+
disabledExtensions: [...p.disabledExtensions],
|
|
213
|
+
reviewPrompt: p.reviewPrompt ?? this.settings.reviewPrompt,
|
|
214
|
+
reviewDisabledSkills: [
|
|
215
|
+
...(p.reviewDisabledSkills ?? this.settings.reviewDisabledSkills),
|
|
216
|
+
],
|
|
217
|
+
// Presets don't capture vision-bridge prefs — keep the current ones.
|
|
218
|
+
visionBridgeEnabled: this.settings.visionBridgeEnabled,
|
|
219
|
+
visionBridgeModel: this.settings.visionBridgeModel,
|
|
220
|
+
visionBridgePromptMode: this.settings.visionBridgePromptMode,
|
|
221
|
+
visionBridgePrompt: this.settings.visionBridgePrompt,
|
|
222
|
+
};
|
|
223
|
+
this.host.stateStore.saveSettings(this.host.clientId, this.settings);
|
|
224
|
+
this.push();
|
|
225
|
+
await this.applyRuntime();
|
|
226
|
+
}
|
|
227
|
+
/** Remove a named preset. */
|
|
228
|
+
async deletePreset(name) {
|
|
229
|
+
this.presets = this.presets.filter((p) => p.name !== name);
|
|
230
|
+
this.host.stateStore.savePresets(this.host.clientId, this.presets);
|
|
231
|
+
this.push();
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Make settings changes effective in the running runtime. The resource-loader
|
|
235
|
+
* overrides read this.settings at call time, so a reload re-applies them.
|
|
236
|
+
* Reloading mid-stream would tear down the in-flight run — defer instead.
|
|
237
|
+
*/
|
|
238
|
+
async applyRuntime() {
|
|
239
|
+
if (this.host.isDisposed())
|
|
240
|
+
return;
|
|
241
|
+
if (this.host.isStreaming()) {
|
|
242
|
+
this.pendingReload = true;
|
|
243
|
+
this.host.emit({
|
|
244
|
+
type: "notice",
|
|
245
|
+
level: "info",
|
|
246
|
+
text: "当前回复进行中,设置将在回复结束后自动应用",
|
|
247
|
+
});
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
await this.applyReload();
|
|
251
|
+
}
|
|
252
|
+
/** session.reload() + refresh the slash-command catalog + push state. */
|
|
253
|
+
async applyReload() {
|
|
254
|
+
try {
|
|
255
|
+
await this.host.reloadSession();
|
|
256
|
+
this.push();
|
|
257
|
+
this.host.flushSnapshot();
|
|
258
|
+
this.host.emit({ type: "notice", level: "info", text: "设置已应用" });
|
|
259
|
+
}
|
|
260
|
+
catch (err) {
|
|
261
|
+
this.host.emit({
|
|
262
|
+
type: "notice",
|
|
263
|
+
level: "error",
|
|
264
|
+
text: `设置应用失败:${err.message}`,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|