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.
Files changed (40) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +295 -295
  3. package/README.zh-CN.md +279 -279
  4. package/bin/pi-web-ui.mjs +0 -0
  5. package/deploy/com.xingshuyin.pi-web-ui.plist +48 -48
  6. package/deploy/nginx-subpath.conf +88 -88
  7. package/deploy/pi-web-ui-task.xml +71 -71
  8. package/deploy/pi-web-ui.service +31 -31
  9. package/dist/server/agent-service.js +408 -3851
  10. package/dist/server/attachments.js +621 -0
  11. package/dist/server/bg-servers.js +138 -0
  12. package/dist/server/client-state.js +148 -0
  13. package/dist/server/files-service.js +633 -0
  14. package/dist/server/goal-service.js +869 -0
  15. package/dist/server/index.js +144 -8
  16. package/dist/server/model-admin.js +727 -0
  17. package/dist/server/process-utils.js +86 -0
  18. package/dist/server/protocol-version.js +11 -0
  19. package/dist/server/scm.js +298 -0
  20. package/dist/server/settings-service.js +268 -0
  21. package/dist/server/slash-commands.js +245 -0
  22. package/dist/server/terminals.js +98 -0
  23. package/dist/server/text-sniff.js +268 -0
  24. package/dist/server/uploads.js +107 -0
  25. package/dist/server/webui-context.js +208 -0
  26. package/extensions/webui.ts +192 -192
  27. package/package.json +94 -87
  28. package/themes/light.css +6318 -6318
  29. package/web/dist/assets/TerminalPanel-6GBZ9nXN.css +32 -0
  30. package/web/dist/assets/TerminalPanel-B-bsYqea.js +2 -0
  31. package/web/dist/assets/index-BsrqFaSZ.js +13 -0
  32. package/web/dist/assets/index-Dsb8Bak1.css +10 -0
  33. package/web/dist/assets/markdown-DRBrS2Nf.js +51 -0
  34. package/web/dist/assets/react-C9ovnpIm.js +24 -0
  35. package/web/dist/assets/xterm-D1D2FVe3.js +38 -0
  36. package/web/dist/favicon.svg +8 -8
  37. package/web/dist/index.html +17 -15
  38. package/web/public/favicon.svg +8 -8
  39. package/web/dist/assets/index-BnDkdKFN.css +0 -41
  40. package/web/dist/assets/index-DmmSVSzk.js +0 -129
@@ -0,0 +1,107 @@
1
+ /**
2
+ * uploads — 文件对话上传的存储与清理。
3
+ *
4
+ * 上传文件落在 <dataDir>/uploads/<clientId>/<ts>-<name>(模型以绝对路径
5
+ * reference 读取)。此前这里硬编码 ~/.pi-web,不吃 PI_WEB_DATA_DIR —— 已改为
6
+ * 与 index.ts 相同的解析逻辑。清理策略:默认保留 14 天,启动时扫一次 +
7
+ * 每 6 小时扫一次;PI_WEB_UPLOAD_RETENTION_DAYS 覆盖保留天数,0 = 关闭清理。
8
+ * 全程 best-effort:清理失败绝不影响服务。
9
+ */
10
+ import { readdir, rm, stat } from "node:fs/promises";
11
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
12
+ import { homedir } from "node:os";
13
+ import { join, resolve } from "node:path";
14
+ /** Same resolution as index.ts DATA_DIR — kept in sync by env contract. */
15
+ export function resolveDataDir() {
16
+ return resolve(process.env.PI_WEB_DATA_DIR ?? join(homedir(), ".pi-web"));
17
+ }
18
+ export function uploadsRoot(dataDir = resolveDataDir()) {
19
+ return join(dataDir, "uploads");
20
+ }
21
+ /** Retention in days; 0 disables sweeping. */
22
+ export function uploadRetentionDays() {
23
+ const v = Number(process.env.PI_WEB_UPLOAD_RETENTION_DAYS);
24
+ return Number.isFinite(v) && v >= 0 ? v : 14;
25
+ }
26
+ /** Persist an uploaded buffer; returns the absolute path + sanitized display name. */
27
+ export function saveUpload(clientId, name, buf, dataDir = resolveDataDir()) {
28
+ const dir = join(uploadsRoot(dataDir), clientId);
29
+ mkdirSync(dir, { recursive: true });
30
+ const displayName = name
31
+ .replace(/[\\/:*?"<>|\x00-\x1f]/g, "_")
32
+ .slice(0, 80) || "file";
33
+ const abs = join(dir, `${Date.now()}-${displayName}`);
34
+ writeFileSync(abs, buf);
35
+ return { abs, displayName };
36
+ }
37
+ /**
38
+ * Delete uploaded files older than the retention window, then prune client
39
+ * dirs left empty. Returns { files, bytes, dirs } removed (best-effort:
40
+ * individual failures are skipped).
41
+ */
42
+ export async function cleanupUploads(dataDir = resolveDataDir(), retentionDays = uploadRetentionDays()) {
43
+ const out = { files: 0, bytes: 0, dirs: 0 };
44
+ if (retentionDays <= 0)
45
+ return out;
46
+ const root = uploadsRoot(dataDir);
47
+ if (!existsSync(root))
48
+ return out;
49
+ const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1000;
50
+ let clients;
51
+ try {
52
+ clients = await readdir(root);
53
+ }
54
+ catch {
55
+ return out;
56
+ }
57
+ for (const clientId of clients) {
58
+ const dir = join(root, clientId);
59
+ let entries;
60
+ try {
61
+ entries = await readdir(dir);
62
+ }
63
+ catch {
64
+ continue; // not a dir / vanished
65
+ }
66
+ for (const entry of entries) {
67
+ const abs = join(dir, entry);
68
+ try {
69
+ const st = await stat(abs);
70
+ if (st.mtimeMs >= cutoff)
71
+ continue;
72
+ await rm(abs, { recursive: true });
73
+ out.files++;
74
+ out.bytes += st.size;
75
+ }
76
+ catch {
77
+ // skip on any per-file failure
78
+ }
79
+ }
80
+ try {
81
+ if ((await readdir(dir)).length === 0) {
82
+ await rmdirSafe(dir);
83
+ out.dirs++;
84
+ }
85
+ }
86
+ catch {
87
+ // ignore
88
+ }
89
+ }
90
+ return out;
91
+ }
92
+ async function rmdirSafe(dir) {
93
+ try {
94
+ await rm(dir, { recursive: false });
95
+ }
96
+ catch {
97
+ // non-empty or race — fine
98
+ }
99
+ }
100
+ /** Startup sweep + periodic re-sweep. Timer is unref'd so it never blocks exit. */
101
+ export function scheduleUploadCleanup(intervalMs = 6 * 60 * 60 * 1000) {
102
+ void cleanupUploads().catch(() => { });
103
+ const timer = setInterval(() => {
104
+ void cleanupUploads().catch(() => { });
105
+ }, intervalMs);
106
+ timer.unref?.();
107
+ }
@@ -0,0 +1,208 @@
1
+ const WIDGET_WIDTH = 80;
2
+ /** Mock theme: TUI color functions degrade to identity so widget text survives. */
3
+ const mockTheme = new Proxy({
4
+ fg: (_color, text) => text,
5
+ bold: (text) => text,
6
+ strikethrough: (text) => text,
7
+ dim: (text) => text,
8
+ }, {
9
+ get(target, prop) {
10
+ if (prop in target)
11
+ return target[prop];
12
+ // Unknown theme methods → no-op passthrough.
13
+ return (_arg, text) => text !== undefined ? text : "";
14
+ },
15
+ });
16
+ /** Mock TUI: any method call is a safe no-op. */
17
+ const mockTui = new Proxy({
18
+ requestRender: () => { },
19
+ render: () => { },
20
+ }, {
21
+ get(target, prop) {
22
+ if (prop in target)
23
+ return target[prop];
24
+ return () => { };
25
+ },
26
+ });
27
+ /**
28
+ * Implements the subset of ExtensionUIContext that makes sense for a web UI.
29
+ * TUI-only affordances (select/confirm/input dialogs, terminal input, custom
30
+ * footer) are inert: dialogs resolve to cancellation instead of blocking.
31
+ */
32
+ export class WebUIContext {
33
+ theme = mockTheme;
34
+ widgets = new Map();
35
+ lastLines = new Map();
36
+ emit;
37
+ constructor(emit) {
38
+ this.emit = emit;
39
+ }
40
+ // -- widgets -------------------------------------------------------------
41
+ /** Matches ExtensionUIContext's overloaded setWidget exactly. */
42
+ setWidget = (key, content, options) => {
43
+ void options;
44
+ if (content === undefined) {
45
+ this.widgets.delete(key);
46
+ this.lastLines.delete(key);
47
+ this.push();
48
+ return;
49
+ }
50
+ if (typeof content === "function") {
51
+ let comp;
52
+ try {
53
+ // Mock TUI/theme: extensions only read a handful of theme helpers;
54
+ // everything else is a no-op, so the widget renders to plain text.
55
+ comp = content(mockTui, mockTheme);
56
+ }
57
+ catch {
58
+ comp = undefined;
59
+ }
60
+ this.widgets.set(key, {
61
+ render: (w) => comp?.render?.(w),
62
+ dispose: comp?.dispose,
63
+ });
64
+ }
65
+ else {
66
+ this.widgets.set(key, { render: () => content });
67
+ }
68
+ this.push();
69
+ };
70
+ /** Re-render all widgets and push when content changed (polled + on demand). */
71
+ refresh() {
72
+ let changed = false;
73
+ for (const [key, w] of this.widgets) {
74
+ let lines;
75
+ try {
76
+ lines = w.render(WIDGET_WIDTH);
77
+ }
78
+ catch {
79
+ lines = undefined;
80
+ }
81
+ const prev = this.lastLines.get(key);
82
+ if (JSON.stringify(lines ?? null) !== JSON.stringify(prev ?? null)) {
83
+ this.lastLines.set(key, lines ?? []);
84
+ changed = true;
85
+ }
86
+ }
87
+ if (changed)
88
+ this.push();
89
+ }
90
+ push() {
91
+ const widgets = this.snapshot();
92
+ this.emit({ type: "widgets", widgets });
93
+ }
94
+ /** Render all widgets to their current text lines (without emitting). */
95
+ snapshot() {
96
+ return [...this.widgets.entries()].map(([key, w]) => {
97
+ let lines;
98
+ try {
99
+ lines = w.render(WIDGET_WIDTH);
100
+ }
101
+ catch {
102
+ lines = undefined;
103
+ }
104
+ this.lastLines.set(key, lines ?? []);
105
+ return { key, lines: lines ?? [] };
106
+ });
107
+ }
108
+ // -- notifications --------------------------------------------------------
109
+ notify(message, type) {
110
+ this.emit({ type: "notice", level: type ?? "info", text: message });
111
+ }
112
+ // -- footer status (pi-lens "LSP Inactive", pi-cache-optimizer cache stats) --
113
+ statuses = new Map();
114
+ setStatus(key, text) {
115
+ if (text === undefined || text === "") {
116
+ this.statuses.delete(key);
117
+ }
118
+ else {
119
+ this.statuses.set(key, text);
120
+ }
121
+ this.pushStatuses();
122
+ }
123
+ pushStatuses() {
124
+ this.emit({
125
+ type: "statuses",
126
+ statuses: [...this.statuses.entries()].map(([k, v]) => ({
127
+ key: k,
128
+ text: v,
129
+ })),
130
+ });
131
+ }
132
+ /** Current footer status entries (for replay on socket attach). */
133
+ statusSnapshot() {
134
+ return [...this.statuses.entries()].map(([k, v]) => ({ key: k, text: v }));
135
+ }
136
+ // -- dialogs (select/confirm/input bridged to the browser) ---------------
137
+ dialogSeq = 0;
138
+ pendingDialogs = new Map();
139
+ select = (title, options) => this.openDialog("select", title, [options]);
140
+ confirm = (title, message) => this.openDialog("confirm", title, [message]);
141
+ input = (title, placeholder) => this.openDialog("input", title, [placeholder ?? ""]);
142
+ openDialog(kind, title, args) {
143
+ return new Promise((resolve) => {
144
+ const id = ++this.dialogSeq;
145
+ this.pendingDialogs.set(id, resolve);
146
+ this.emit({ type: "dialog", id, kind, title, args });
147
+ });
148
+ }
149
+ /** Resolve a pending dialog with the user's choice (called from the client). */
150
+ resolveDialog(id, value) {
151
+ const resolve = this.pendingDialogs.get(id);
152
+ if (resolve) {
153
+ this.pendingDialogs.delete(id);
154
+ resolve(value);
155
+ this.emit({ type: "dialog_closed", id });
156
+ }
157
+ }
158
+ /** Close every pending dialog as cancelled (used when a goal wizard aborts —
159
+ * its unanswered browser dialogs must vanish, not linger). */
160
+ cancelPendingDialogs() {
161
+ for (const [id, resolve] of this.pendingDialogs) {
162
+ this.pendingDialogs.delete(id);
163
+ resolve(null);
164
+ this.emit({ type: "dialog_closed", id });
165
+ }
166
+ }
167
+ // -- inert TUI-only affordances ------------------------------------------
168
+ onTerminalInput = () => () => { };
169
+ setWorkingMessage = () => { };
170
+ setWorkingVisible = () => { };
171
+ setWorkingIndicator = () => { };
172
+ setHiddenThinkingLabel = () => { };
173
+ setFooter = () => { };
174
+ setHeader = () => { };
175
+ setTitle = () => { };
176
+ custom = (_factory, _done) => new Promise(() => { });
177
+ pasteToEditor = () => { };
178
+ setEditorText = () => { };
179
+ getEditorText = () => "";
180
+ editor = async () => undefined;
181
+ addAutocompleteProvider = () => { };
182
+ setEditorComponent = () => { };
183
+ getEditorComponent = () => undefined;
184
+ getAllThemes = () => [];
185
+ getTheme = () => undefined;
186
+ setTheme = () => ({ success: false });
187
+ getToolsExpanded = () => false;
188
+ setToolsExpanded = () => { };
189
+ /** Dispose all widgets (extension reload / session teardown). */
190
+ dispose() {
191
+ for (const w of this.widgets.values()) {
192
+ try {
193
+ w.dispose?.();
194
+ }
195
+ catch {
196
+ // best effort
197
+ }
198
+ }
199
+ this.widgets.clear();
200
+ this.lastLines.clear();
201
+ // Cancel any pending dialogs.
202
+ for (const [id, resolve] of this.pendingDialogs) {
203
+ resolve(null);
204
+ this.emit({ type: "dialog_closed", id });
205
+ }
206
+ this.pendingDialogs.clear();
207
+ }
208
+ }