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,138 @@
|
|
|
1
|
+
import { killPidTree, lookupProcessName, snapshotListeningPorts } from "./process-utils.js";
|
|
2
|
+
const BG_REFRESH_INTERVAL_MS = 30_000;
|
|
3
|
+
/** bash 结束后等这么久再拍「后」快照——给后台服务绑定端口的时间。 */
|
|
4
|
+
const BG_BIND_WAIT_MS = 1500;
|
|
5
|
+
export class BgServerTracker {
|
|
6
|
+
opts;
|
|
7
|
+
servers = new Map();
|
|
8
|
+
/** bash 工具开始执行前拍的监听端口快照(tool_execution_start 时设置)。 */
|
|
9
|
+
listenBefore = null;
|
|
10
|
+
refreshTimer = null;
|
|
11
|
+
constructor(opts) {
|
|
12
|
+
this.opts = opts;
|
|
13
|
+
}
|
|
14
|
+
/** 启动周期性存活检查(死项静默剔除)。 */
|
|
15
|
+
start() {
|
|
16
|
+
this.refreshTimer = setInterval(() => void this.refresh(), BG_REFRESH_INTERVAL_MS);
|
|
17
|
+
this.refreshTimer.unref?.();
|
|
18
|
+
}
|
|
19
|
+
stop() {
|
|
20
|
+
if (this.refreshTimer) {
|
|
21
|
+
clearInterval(this.refreshTimer);
|
|
22
|
+
this.refreshTimer = null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** tool_execution_start(bash):先记下「前」快照。 */
|
|
26
|
+
snapshotBefore() {
|
|
27
|
+
void snapshotListeningPorts().then((m) => {
|
|
28
|
+
this.listenBefore = m;
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
/** After a bash tool run, wait briefly for background servers to bind,
|
|
32
|
+
* then diff the listening-port snapshot against the pre-run one and
|
|
33
|
+
* remember anything new — those are servers the agent left running. */
|
|
34
|
+
async trackAfterBash() {
|
|
35
|
+
const before = this.listenBefore;
|
|
36
|
+
this.listenBefore = null;
|
|
37
|
+
if (!before)
|
|
38
|
+
return;
|
|
39
|
+
await new Promise((r) => setTimeout(r, BG_BIND_WAIT_MS));
|
|
40
|
+
const after = await snapshotListeningPorts();
|
|
41
|
+
let added = false;
|
|
42
|
+
for (const [port, pid] of after) {
|
|
43
|
+
if (!before.has(port) && !this.servers.has(port)) {
|
|
44
|
+
this.servers.set(port, { pid, since: Date.now() });
|
|
45
|
+
added = true;
|
|
46
|
+
// Best-effort process name so the panel shows something readable.
|
|
47
|
+
void lookupProcessName(pid).then((name) => {
|
|
48
|
+
const cur = this.servers.get(port);
|
|
49
|
+
if (cur && cur.pid === pid && name) {
|
|
50
|
+
cur.name = name;
|
|
51
|
+
this.push();
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
this.opts.emit({
|
|
55
|
+
type: "notice",
|
|
56
|
+
level: "info",
|
|
57
|
+
text: `检测到 AI 启动的后台服务:端口 ${port}(pid ${pid})——可在顶栏「后台任务」里单独停止或全部关闭`,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (added)
|
|
62
|
+
this.push();
|
|
63
|
+
}
|
|
64
|
+
/** The current background-server list, oldest first. */
|
|
65
|
+
list() {
|
|
66
|
+
return [...this.servers.entries()]
|
|
67
|
+
.map(([port, v]) => ({
|
|
68
|
+
port,
|
|
69
|
+
pid: v.pid,
|
|
70
|
+
since: v.since,
|
|
71
|
+
...(v.name ? { name: v.name } : {}),
|
|
72
|
+
}))
|
|
73
|
+
.sort((a, b) => a.since - b.since);
|
|
74
|
+
}
|
|
75
|
+
/** Push the current background-task list to every connected socket. */
|
|
76
|
+
push() {
|
|
77
|
+
this.opts.emit({ type: "bg_servers", servers: this.list() });
|
|
78
|
+
}
|
|
79
|
+
/** Re-snapshot listening ports and drop tracked entries that are no longer
|
|
80
|
+
* listening — the process exited on its own, so it must leave the panel.
|
|
81
|
+
* Port AND pid must both match: a port reused by an unrelated process is
|
|
82
|
+
* not our server anymore. Silent (the list just updates). */
|
|
83
|
+
async refresh() {
|
|
84
|
+
if (this.opts.isDisposed() || this.servers.size === 0)
|
|
85
|
+
return;
|
|
86
|
+
const now = await snapshotListeningPorts();
|
|
87
|
+
let changed = false;
|
|
88
|
+
for (const [port, v] of [...this.servers]) {
|
|
89
|
+
if (now.get(port) !== v.pid) {
|
|
90
|
+
this.servers.delete(port);
|
|
91
|
+
changed = true;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (changed)
|
|
95
|
+
this.push();
|
|
96
|
+
}
|
|
97
|
+
/** Re-push the current list on request (panel opened); prunes dead entries first. */
|
|
98
|
+
async listAndPush() {
|
|
99
|
+
await this.refresh();
|
|
100
|
+
this.push();
|
|
101
|
+
}
|
|
102
|
+
/** Kill ONE background server (by port); returns whether anything was killed. */
|
|
103
|
+
async killOne(port) {
|
|
104
|
+
const entry = this.servers.get(port);
|
|
105
|
+
if (!entry) {
|
|
106
|
+
this.opts.emit({
|
|
107
|
+
type: "notice",
|
|
108
|
+
level: "info",
|
|
109
|
+
text: `端口 ${port} 不在后台任务列表中`,
|
|
110
|
+
});
|
|
111
|
+
this.opts.flushSnapshot();
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
killPidTree(entry.pid);
|
|
115
|
+
this.servers.delete(port);
|
|
116
|
+
this.push();
|
|
117
|
+
this.opts.emit({
|
|
118
|
+
type: "notice",
|
|
119
|
+
level: "info",
|
|
120
|
+
text: `已停止后台任务:端口 ${port}(pid ${entry.pid})`,
|
|
121
|
+
});
|
|
122
|
+
this.opts.flushSnapshot();
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
/** Kill every background server the agent started; returns the freed ports. */
|
|
126
|
+
async killAll() {
|
|
127
|
+
if (this.servers.size === 0)
|
|
128
|
+
return [];
|
|
129
|
+
const killed = [];
|
|
130
|
+
for (const [port, { pid }] of [...this.servers]) {
|
|
131
|
+
killPidTree(pid);
|
|
132
|
+
killed.push(String(port));
|
|
133
|
+
}
|
|
134
|
+
this.servers.clear();
|
|
135
|
+
this.push();
|
|
136
|
+
return killed;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* client-state — 每浏览器客户端的持久化 UI 状态(<dataDir>/client-state.json):
|
|
3
|
+
* 最近项目/工作目录、目标审查偏好、设置面板状态(提示词模式 + 技能/插件开关 +
|
|
4
|
+
* 视觉桥偏好)、命名预设。文件 I/O 一律 best-effort:持久化故障绝不能
|
|
5
|
+
* 弄崩 server 或阻塞会话。
|
|
6
|
+
*
|
|
7
|
+
* 从 agent-service.ts 抽出,行为保持不变。
|
|
8
|
+
*/
|
|
9
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { dirname } from "node:path";
|
|
11
|
+
/** Stable identity of an extension for the enable/disable toggle: the npm
|
|
12
|
+
* spec for packages (survives version bumps), the resolved entry path
|
|
13
|
+
* otherwise. */
|
|
14
|
+
export function extensionKey(e) {
|
|
15
|
+
const src = e.sourceInfo;
|
|
16
|
+
if (src?.origin === "package" && src.source)
|
|
17
|
+
return src.source;
|
|
18
|
+
return src?.path ?? e.path;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Persists which workspace each browser client last used + which workspaces it
|
|
22
|
+
* has opened, so a server restart / page reload restores the same project and
|
|
23
|
+
* the UI can offer a one-click recent-project list. File I/O is best-effort:
|
|
24
|
+
* persistence problems must never crash the server or block a session.
|
|
25
|
+
*/
|
|
26
|
+
export class ClientStateStore {
|
|
27
|
+
filePath;
|
|
28
|
+
cache = null;
|
|
29
|
+
constructor(filePath) {
|
|
30
|
+
this.filePath = filePath;
|
|
31
|
+
}
|
|
32
|
+
load() {
|
|
33
|
+
if (this.cache)
|
|
34
|
+
return this.cache;
|
|
35
|
+
try {
|
|
36
|
+
const parsed = JSON.parse(readFileSync(this.filePath, "utf8"));
|
|
37
|
+
this.cache = parsed && typeof parsed === "object" ? parsed : {};
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
this.cache = {};
|
|
41
|
+
}
|
|
42
|
+
return this.cache;
|
|
43
|
+
}
|
|
44
|
+
save() {
|
|
45
|
+
try {
|
|
46
|
+
mkdirSync(dirname(this.filePath), { recursive: true });
|
|
47
|
+
// Atomic write (tmp + rename): a crash mid-write must never leave a
|
|
48
|
+
// half-written JSON — that would wipe ALL persisted state (recent
|
|
49
|
+
// projects / presets / settings / goal prefs) on next load.
|
|
50
|
+
const tmp = `${this.filePath}.${process.pid}.tmp`;
|
|
51
|
+
writeFileSync(tmp, JSON.stringify(this.cache, null, 2) + "\n");
|
|
52
|
+
renameSync(tmp, this.filePath);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// best effort
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
get(clientId) {
|
|
59
|
+
return this.load()[clientId] ?? { projects: [] };
|
|
60
|
+
}
|
|
61
|
+
/** Remember which workspace a client last used; bumps its project entry. */
|
|
62
|
+
remember(clientId, cwd) {
|
|
63
|
+
const all = this.load();
|
|
64
|
+
const state = (all[clientId] ??= { projects: [] });
|
|
65
|
+
state.lastCwd = cwd;
|
|
66
|
+
const now = Date.now();
|
|
67
|
+
state.projects = [
|
|
68
|
+
{ path: cwd, lastUsed: now },
|
|
69
|
+
...state.projects.filter((p) => p.path !== cwd),
|
|
70
|
+
].slice(0, 30);
|
|
71
|
+
this.save();
|
|
72
|
+
}
|
|
73
|
+
/** Last-used goal/review prefs for a client, or undefined if never set. */
|
|
74
|
+
getGoalPrefs(clientId) {
|
|
75
|
+
const s = this.load()[clientId];
|
|
76
|
+
if (!s?.goalPrefs)
|
|
77
|
+
return undefined;
|
|
78
|
+
return {
|
|
79
|
+
reviewModel: s.goalPrefs.reviewModel ?? null,
|
|
80
|
+
maxRounds: s.goalPrefs.maxRounds ?? 0,
|
|
81
|
+
locked: s.goalPrefs.locked ?? true,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/** Persist the client's goal/review preferences (model choice, rounds, lock). */
|
|
85
|
+
saveGoalPrefs(clientId, prefs) {
|
|
86
|
+
const all = this.load();
|
|
87
|
+
const state = (all[clientId] ??= { projects: [] });
|
|
88
|
+
state.goalPrefs = {
|
|
89
|
+
reviewModel: prefs?.reviewModel ?? null,
|
|
90
|
+
maxRounds: prefs?.maxRounds ?? 0,
|
|
91
|
+
locked: prefs?.locked ?? true,
|
|
92
|
+
};
|
|
93
|
+
this.save();
|
|
94
|
+
}
|
|
95
|
+
/** Last-used settings-panel state for a client, or defaults. */
|
|
96
|
+
getSettings(clientId) {
|
|
97
|
+
const s = this.load()[clientId];
|
|
98
|
+
return {
|
|
99
|
+
promptMode: s?.settings?.promptMode === "replace" ? "replace" : "append",
|
|
100
|
+
customSystemPrompt: s?.settings?.customSystemPrompt ?? "",
|
|
101
|
+
disabledSkills: s?.settings?.disabledSkills ?? [],
|
|
102
|
+
disabledExtensions: s?.settings?.disabledExtensions ?? [],
|
|
103
|
+
visionBridgeEnabled: s?.settings?.visionBridgeEnabled ?? true,
|
|
104
|
+
visionBridgeModel: s?.settings?.visionBridgeModel ?? null,
|
|
105
|
+
visionBridgePromptMode: s?.settings?.visionBridgePromptMode === "replace" ? "replace" : "append",
|
|
106
|
+
visionBridgePrompt: s?.settings?.visionBridgePrompt ?? "",
|
|
107
|
+
reviewPrompt: s?.settings?.reviewPrompt ?? "",
|
|
108
|
+
reviewDisabledSkills: s?.settings?.reviewDisabledSkills ?? [],
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/** Persist the client's settings-panel state (partial merge). */
|
|
112
|
+
saveSettings(clientId, settings) {
|
|
113
|
+
const all = this.load();
|
|
114
|
+
const state = (all[clientId] ??= { projects: [] });
|
|
115
|
+
const cur = state.settings ?? {};
|
|
116
|
+
state.settings = {
|
|
117
|
+
promptMode: settings.promptMode ?? cur.promptMode ?? "append",
|
|
118
|
+
customSystemPrompt: settings.customSystemPrompt ?? cur.customSystemPrompt ?? "",
|
|
119
|
+
disabledSkills: settings.disabledSkills ?? cur.disabledSkills ?? [],
|
|
120
|
+
disabledExtensions: settings.disabledExtensions ?? cur.disabledExtensions ?? [],
|
|
121
|
+
visionBridgeEnabled: settings.visionBridgeEnabled ?? cur.visionBridgeEnabled ?? true,
|
|
122
|
+
visionBridgeModel: settings.visionBridgeModel ?? cur.visionBridgeModel ?? null,
|
|
123
|
+
visionBridgePromptMode: settings.visionBridgePromptMode ??
|
|
124
|
+
cur.visionBridgePromptMode ??
|
|
125
|
+
"append",
|
|
126
|
+
visionBridgePrompt: settings.visionBridgePrompt ?? cur.visionBridgePrompt ?? "",
|
|
127
|
+
reviewPrompt: settings.reviewPrompt ?? cur.reviewPrompt ?? "",
|
|
128
|
+
reviewDisabledSkills: settings.reviewDisabledSkills ?? cur.reviewDisabledSkills ?? [],
|
|
129
|
+
};
|
|
130
|
+
this.save();
|
|
131
|
+
}
|
|
132
|
+
/** Named settings presets for a client (empty if never saved). */
|
|
133
|
+
getPresets(clientId) {
|
|
134
|
+
return (this.load()[clientId]?.presets ?? []).map((p) => ({
|
|
135
|
+
...p,
|
|
136
|
+
// Older client-state files predate review settings.
|
|
137
|
+
reviewPrompt: p.reviewPrompt ?? "",
|
|
138
|
+
reviewDisabledSkills: p.reviewDisabledSkills ?? [],
|
|
139
|
+
}));
|
|
140
|
+
}
|
|
141
|
+
/** Persist the client's named settings presets. */
|
|
142
|
+
savePresets(clientId, presets) {
|
|
143
|
+
const all = this.load();
|
|
144
|
+
const state = (all[clientId] ??= { projects: [] });
|
|
145
|
+
state.presets = presets;
|
|
146
|
+
this.save();
|
|
147
|
+
}
|
|
148
|
+
}
|