@youweichen/pi-web-ui 0.45.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 (140) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +45 -0
  3. package/bin/pi-web-ui.mjs +1818 -0
  4. package/deploy/com.xingshuyin.pi-web-ui.plist +48 -0
  5. package/deploy/nginx-subpath.conf +88 -0
  6. package/deploy/pi-web-ui-task.xml +71 -0
  7. package/deploy/pi-web-ui.service +31 -0
  8. package/dist/server/agent-service.js +2975 -0
  9. package/dist/server/attachments.js +672 -0
  10. package/dist/server/bg-servers.js +150 -0
  11. package/dist/server/client-state.js +234 -0
  12. package/dist/server/control-socket.js +177 -0
  13. package/dist/server/ensure-bash.js +80 -0
  14. package/dist/server/files-service.js +709 -0
  15. package/dist/server/goal-service.js +869 -0
  16. package/dist/server/index.js +879 -0
  17. package/dist/server/mcp-bridge.js +268 -0
  18. package/dist/server/model-admin.js +821 -0
  19. package/dist/server/patch-node-pty.js +80 -0
  20. package/dist/server/plugin-facilities.js +299 -0
  21. package/dist/server/plugin-updater.js +226 -0
  22. package/dist/server/plugins.js +893 -0
  23. package/dist/server/process-utils.js +112 -0
  24. package/dist/server/protocol-version.js +11 -0
  25. package/dist/server/protocol.js +6 -0
  26. package/dist/server/scm.js +298 -0
  27. package/dist/server/serialize.js +172 -0
  28. package/dist/server/settings-service.js +395 -0
  29. package/dist/server/slash-commands.js +263 -0
  30. package/dist/server/terminals.js +1372 -0
  31. package/dist/server/text-sniff.js +268 -0
  32. package/dist/server/uploads.js +107 -0
  33. package/dist/server/vision-bridge.js +131 -0
  34. package/dist/server/webui-context.js +222 -0
  35. package/extensions/webui.ts +192 -0
  36. package/package.json +99 -0
  37. package/web/dist/assets/TerminalPanel-6GBZ9nXN.css +32 -0
  38. package/web/dist/assets/TerminalPanel-D4AktsEn.js +2 -0
  39. package/web/dist/assets/abnfDiagram-VCTEODGH-Ct5StJwt.js +1 -0
  40. package/web/dist/assets/arc-pkuxEN8A.js +1 -0
  41. package/web/dist/assets/architectureDiagram-5GKGNRK7-C6P6yn4y.js +36 -0
  42. package/web/dist/assets/blockDiagram-I7D4REHJ-BmgfH1jt.js +129 -0
  43. package/web/dist/assets/c4Diagram-7LVT6UL2-CgmwTFsK.js +38 -0
  44. package/web/dist/assets/channel-0Y__6sq2.js +1 -0
  45. package/web/dist/assets/chunk-2Q5K7J3B-DX_NqAiH.js +1 -0
  46. package/web/dist/assets/chunk-5VM5RSS4-gLeuQuYv.js +15 -0
  47. package/web/dist/assets/chunk-F27PBJKO-D4WX4mhI.js +1 -0
  48. package/web/dist/assets/chunk-IMKFNOWR-DBpDxGQD.js +231 -0
  49. package/web/dist/assets/chunk-JWPE2WC7-DIQ8iZN5.js +1 -0
  50. package/web/dist/assets/chunk-POPQ4Y6H-F_YTNoGz.js +1 -0
  51. package/web/dist/assets/chunk-SVP7TREG-CmDIvX-r.js +88 -0
  52. package/web/dist/assets/chunk-TICWLB2K-BQUzHQyS.js +206 -0
  53. package/web/dist/assets/chunk-XXDRQBXY-DqR9iffH.js +1 -0
  54. package/web/dist/assets/classDiagram-ZZMXUADV-BEfGIpzu.js +1 -0
  55. package/web/dist/assets/classDiagram-v2-VYDZK3BY-BEfGIpzu.js +1 -0
  56. package/web/dist/assets/cose-bilkent-JH36ORCC-Dl67W4Dx.js +1 -0
  57. package/web/dist/assets/cynefin-OW5HDTMX-fWdqaCpX.js +178 -0
  58. package/web/dist/assets/cynefinDiagram-5FMLGOSQ-CSUAXbaP.js +62 -0
  59. package/web/dist/assets/cytoscape.esm-Bch-eiPH.js +331 -0
  60. package/web/dist/assets/dagre-GXQ25YYZ-CqUVwu1X.js +4 -0
  61. package/web/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
  62. package/web/dist/assets/diagram-S7CK7UJ4-bRce-WYV.js +30 -0
  63. package/web/dist/assets/diagram-UQ7AKVKN-CiqPtPBE.js +41 -0
  64. package/web/dist/assets/diagram-VSXAHHWV-CbEA05LX.js +3 -0
  65. package/web/dist/assets/diagram-VX7I27RA-CRcY9MD-.js +24 -0
  66. package/web/dist/assets/diagram-Z3DM3KII-DmTwvZdS.js +24 -0
  67. package/web/dist/assets/ebnfDiagram-PWID7BFC-DbwmiGVj.js +1 -0
  68. package/web/dist/assets/erDiagram-RLTQ6QDP-Cxiaf87y.js +99 -0
  69. package/web/dist/assets/flowDiagram-HODETNUW-BQiecP1I.js +168 -0
  70. package/web/dist/assets/ganttDiagram-EL5Y4UJY-CSr8RRNw.js +292 -0
  71. package/web/dist/assets/gitGraphDiagram-WWUBYQGX-C4Tdis3g.js +106 -0
  72. package/web/dist/assets/index-CniHcFxT.js +22 -0
  73. package/web/dist/assets/index-DYz2iP_B.css +10 -0
  74. package/web/dist/assets/infoDiagram-27XIBGKW-ij_5A0pY.js +2 -0
  75. package/web/dist/assets/init-Gi6I4Gst.js +1 -0
  76. package/web/dist/assets/ishikawaDiagram-5VMMS53U-C8-tm2LV.js +70 -0
  77. package/web/dist/assets/journeyDiagram-3NMN7TZE-ZJedkNel.js +139 -0
  78. package/web/dist/assets/kanban-definition-UXKFOSKX-BEuoM53n.js +89 -0
  79. package/web/dist/assets/katex-C5jXJg4s.js +257 -0
  80. package/web/dist/assets/layout-zq_9DigZ.js +1 -0
  81. package/web/dist/assets/linear-D-QXJ5Wc.js +1 -0
  82. package/web/dist/assets/markdown-CUPbftfV.js +51 -0
  83. package/web/dist/assets/mermaid.core-CCiG-bOm.js +312 -0
  84. package/web/dist/assets/mindmap-definition-YA3MSWOX-VaAisUIe.js +96 -0
  85. package/web/dist/assets/ordinal-Cboi1Yqb.js +1 -0
  86. package/web/dist/assets/pegDiagram-XKGWAZYB-D5vmRtkn.js +1 -0
  87. package/web/dist/assets/pieDiagram-E7YTZNPT-loD6V8fn.js +39 -0
  88. package/web/dist/assets/quadrantDiagram-AXDQQJYC-B7E4mH1-.js +7 -0
  89. package/web/dist/assets/railroadDiagram-O6MQD6OU-DTALHvDa.js +1 -0
  90. package/web/dist/assets/react-ZkKJMDQQ.js +24 -0
  91. package/web/dist/assets/requirementDiagram-BXWQKSXE-ZjJKuEcR.js +84 -0
  92. package/web/dist/assets/sankeyDiagram-P5KCCOFB-DufTudDP.js +40 -0
  93. package/web/dist/assets/sequenceDiagram-WJ2MYXX4-B-Zk4alI.js +162 -0
  94. package/web/dist/assets/sizeCapture-INFHLROL-B1pjlxXN.js +1 -0
  95. package/web/dist/assets/stateDiagram-D77RDMKH-BeWg6PIR.js +1 -0
  96. package/web/dist/assets/stateDiagram-v2-MP3YSRHH-DagWhIFJ.js +1 -0
  97. package/web/dist/assets/swimlanes-42K2YHIH-4J4jmMmO.js +1 -0
  98. package/web/dist/assets/swimlanesDiagram-VR7AAH4N-Dsi9x540.js +8 -0
  99. package/web/dist/assets/timeline-definition-24CTP7MA-ByqLfxqe.js +120 -0
  100. package/web/dist/assets/vennDiagram-4TSXK5OY-B_EqVW4R.js +34 -0
  101. package/web/dist/assets/wardleyDiagram-VM6X3IG4-Bu7sA6F8.js +78 -0
  102. package/web/dist/assets/xterm-D1D2FVe3.js +38 -0
  103. package/web/dist/assets/xychartDiagram-S5SC5T6Z-w1KPVGKk.js +7 -0
  104. package/web/dist/favicon.svg +8 -0
  105. package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-400-normal.woff2 +0 -0
  106. package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-500-normal.woff2 +0 -0
  107. package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-600-normal.woff2 +0 -0
  108. package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-400-normal.woff2 +0 -0
  109. package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-500-normal.woff2 +0 -0
  110. package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-600-normal.woff2 +0 -0
  111. package/web/dist/fonts/inter/inter-latin-400-normal.woff2 +0 -0
  112. package/web/dist/fonts/inter/inter-latin-500-normal.woff2 +0 -0
  113. package/web/dist/fonts/inter/inter-latin-600-normal.woff2 +0 -0
  114. package/web/dist/fonts/inter/inter-latin-700-normal.woff2 +0 -0
  115. package/web/dist/fonts/inter/inter-latin-800-normal.woff2 +0 -0
  116. package/web/dist/fonts/inter/inter-latin-ext-400-normal.woff2 +0 -0
  117. package/web/dist/fonts/inter/inter-latin-ext-500-normal.woff2 +0 -0
  118. package/web/dist/fonts/inter/inter-latin-ext-600-normal.woff2 +0 -0
  119. package/web/dist/fonts/inter/inter-latin-ext-700-normal.woff2 +0 -0
  120. package/web/dist/fonts/inter/inter-latin-ext-800-normal.woff2 +0 -0
  121. package/web/dist/icon.ico +0 -0
  122. package/web/dist/index.html +17 -0
  123. package/web/public/favicon.svg +8 -0
  124. package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-400-normal.woff2 +0 -0
  125. package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-500-normal.woff2 +0 -0
  126. package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-600-normal.woff2 +0 -0
  127. package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-400-normal.woff2 +0 -0
  128. package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-500-normal.woff2 +0 -0
  129. package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-600-normal.woff2 +0 -0
  130. package/web/public/fonts/inter/inter-latin-400-normal.woff2 +0 -0
  131. package/web/public/fonts/inter/inter-latin-500-normal.woff2 +0 -0
  132. package/web/public/fonts/inter/inter-latin-600-normal.woff2 +0 -0
  133. package/web/public/fonts/inter/inter-latin-700-normal.woff2 +0 -0
  134. package/web/public/fonts/inter/inter-latin-800-normal.woff2 +0 -0
  135. package/web/public/fonts/inter/inter-latin-ext-400-normal.woff2 +0 -0
  136. package/web/public/fonts/inter/inter-latin-ext-500-normal.woff2 +0 -0
  137. package/web/public/fonts/inter/inter-latin-ext-600-normal.woff2 +0 -0
  138. package/web/public/fonts/inter/inter-latin-ext-700-normal.woff2 +0 -0
  139. package/web/public/fonts/inter/inter-latin-ext-800-normal.woff2 +0 -0
  140. package/web/public/icon.ico +0 -0
@@ -0,0 +1,268 @@
1
+ /**
2
+ * text-sniff — 文件预览相关的纯函数:扩展名分类、内容嗅探、文本解码
3
+ * (UTF-8 → GBK → latin1 回退)、十六进制视图、行数统计。
4
+ *
5
+ * 全部无副作用、不碰 fs —— 便于单元测试(tests/unit/text-sniff.test.ts)。
6
+ * 从 agent-service.ts 抽出,行为保持不变。
7
+ */
8
+ const PREVIEW_IMAGE_EXTS = new Set([
9
+ "png",
10
+ "jpg",
11
+ "jpeg",
12
+ "gif",
13
+ "webp",
14
+ "svg",
15
+ "bmp",
16
+ "ico",
17
+ "avif",
18
+ "jfif",
19
+ "tif",
20
+ "tiff",
21
+ ]);
22
+ const PREVIEW_VIDEO_EXTS = new Set([
23
+ "mp4",
24
+ "webm",
25
+ "mov",
26
+ "mkv",
27
+ "avi",
28
+ "m4v",
29
+ "ogv",
30
+ "mpg",
31
+ "mpeg",
32
+ "wmv",
33
+ "flv",
34
+ ]);
35
+ const PREVIEW_TEXT_EXTS = new Set([
36
+ // code
37
+ "ts",
38
+ "tsx",
39
+ "js",
40
+ "jsx",
41
+ "mjs",
42
+ "cjs",
43
+ "jsm",
44
+ "es6",
45
+ "vue",
46
+ "svelte",
47
+ "py",
48
+ "pyw",
49
+ "ipynb",
50
+ "go",
51
+ "rs",
52
+ "c",
53
+ "h",
54
+ "cpp",
55
+ "hpp",
56
+ "cc",
57
+ "cxx",
58
+ "hh",
59
+ "csh",
60
+ "java",
61
+ "kt",
62
+ "kts",
63
+ "scala",
64
+ "sc",
65
+ "cs",
66
+ "fs",
67
+ "fsx",
68
+ "fsi",
69
+ "sh",
70
+ "bash",
71
+ "zsh",
72
+ "fish",
73
+ "bat",
74
+ "cmd",
75
+ "ps1",
76
+ "psd1",
77
+ "psm1",
78
+ "rb",
79
+ "php",
80
+ "pl",
81
+ "pm",
82
+ "tcl",
83
+ "lua",
84
+ "r",
85
+ "rmd",
86
+ "sql",
87
+ "swift",
88
+ "dart",
89
+ "groovy",
90
+ "gradle",
91
+ "tf",
92
+ "tfvars",
93
+ "hcl",
94
+ "nim",
95
+ "zig",
96
+ "v",
97
+ "vala",
98
+ "d",
99
+ "clj",
100
+ "cljs",
101
+ "cljc",
102
+ "edn",
103
+ "ex",
104
+ "exs",
105
+ "erl",
106
+ "hrl",
107
+ "ml",
108
+ "mli",
109
+ // markup / config / data
110
+ "json",
111
+ "jsonc",
112
+ "json5",
113
+ "jsonl",
114
+ "md",
115
+ "mdx",
116
+ "markdown",
117
+ "html",
118
+ "htm",
119
+ "xhtml",
120
+ "css",
121
+ "scss",
122
+ "sass",
123
+ "less",
124
+ "styl",
125
+ "xml",
126
+ "dtd",
127
+ "yaml",
128
+ "yml",
129
+ "toml",
130
+ "ini",
131
+ "cfg",
132
+ "conf",
133
+ "properties",
134
+ "env",
135
+ "log",
136
+ "txt",
137
+ "text",
138
+ "csv",
139
+ "tsv",
140
+ "lock",
141
+ "sqlite",
142
+ "graphql",
143
+ "gql",
144
+ "proto",
145
+ "prisma",
146
+ "asm",
147
+ "s",
148
+ ]);
149
+ /**
150
+ * Classify a file name into its preview category. Files with no extension
151
+ * (README, Makefile, .gitignore, …) are treated as text. Everything not in an
152
+ * allowlist (exe, jar, dll, zip, …) is "none" — never previewed.
153
+ */
154
+ export function previewKind(name) {
155
+ const dot = name.lastIndexOf(".");
156
+ // A leading dot with nothing after it (.gitignore, .env) counts as no ext.
157
+ const ext = dot > 0 ? name.slice(dot + 1).toLowerCase() : "";
158
+ if (PREVIEW_IMAGE_EXTS.has(ext))
159
+ return "image";
160
+ if (PREVIEW_VIDEO_EXTS.has(ext))
161
+ return "video";
162
+ if (ext === "" || PREVIEW_TEXT_EXTS.has(ext))
163
+ return "text";
164
+ return "none";
165
+ }
166
+ /**
167
+ * Content sniff for the preview: any data that has no NUL bytes and no
168
+ * meaningful control-char ratio is treated as text — so files with unknown
169
+ * or absent extensions (jsonl, .log.1, …) still open as text. NULs catch
170
+ * zip/sqlite/png/… even when the extension claims text.
171
+ */
172
+ export function looksLikeText(buf) {
173
+ if (buf.length === 0)
174
+ return true;
175
+ if (buf.includes(0))
176
+ return false;
177
+ const text = buf.toString("utf8");
178
+ let control = 0;
179
+ for (const ch of text) {
180
+ const c = ch.charCodeAt(0);
181
+ // Keep \t \n \r \f (and \b); everything else < 0x20 is binary-ish.
182
+ if (c < 0x20 && c !== 9 && c !== 10 && c !== 12 && c !== 13)
183
+ control++;
184
+ }
185
+ return control / Math.max(text.length, 1) < 0.02;
186
+ }
187
+ /** Decode bytes: strict UTF-8 first, falling back to GBK (Windows legacy
188
+ * Chinese files), then latin1 as a last resort — so previews and inline
189
+ * attachments never show mojibake for GBK/GB2312 encoded files. */
190
+ export function decodeText(buf) {
191
+ try {
192
+ return new TextDecoder("utf-8", { fatal: true }).decode(buf);
193
+ }
194
+ catch {
195
+ try {
196
+ return new TextDecoder("gbk").decode(buf);
197
+ }
198
+ catch {
199
+ return buf.toString("latin1");
200
+ }
201
+ }
202
+ }
203
+ /** Sniff an image MIME type from magic bytes (extension is only a hint).
204
+ * Returns null when the bytes don't look like a known raster format —
205
+ * callers keep such files as plain path references. */
206
+ export function sniffImageMime(buf, ext) {
207
+ if (buf.length >= 8 &&
208
+ buf[0] === 0x89 &&
209
+ buf[1] === 0x50 &&
210
+ buf[2] === 0x4e &&
211
+ buf[3] === 0x47) {
212
+ return "image/png";
213
+ }
214
+ if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) {
215
+ return "image/jpeg";
216
+ }
217
+ const head = buf.slice(0, 6).toString("ascii");
218
+ if (head === "GIF87a" || head === "GIF89a")
219
+ return "image/gif";
220
+ if (buf.length >= 12 &&
221
+ buf.slice(0, 4).toString("ascii") === "RIFF" &&
222
+ buf.slice(8, 12).toString("ascii") === "WEBP") {
223
+ return "image/webp";
224
+ }
225
+ if (buf.length >= 2 && buf[0] === 0x42 && buf[1] === 0x4d)
226
+ return "image/bmp";
227
+ // Unknown but raster-looking extension — trust the extension so existing
228
+ // image attachments keep working.
229
+ const known = {
230
+ ".png": "image/png",
231
+ ".jpg": "image/jpeg",
232
+ ".jpeg": "image/jpeg",
233
+ ".gif": "image/gif",
234
+ ".webp": "image/webp",
235
+ ".bmp": "image/bmp",
236
+ };
237
+ return known[ext] ?? null;
238
+ }
239
+ /** First few KB of binary data as a classic hex + ASCII dump (preview only). */
240
+ export function hexDump(buf, maxBytes = 4096) {
241
+ const data = buf.subarray(0, Math.min(buf.length, maxBytes));
242
+ const rows = [];
243
+ for (let off = 0; off < data.length; off += 16) {
244
+ const chunk = data.subarray(off, off + 16);
245
+ const hex = [...chunk]
246
+ .map((b) => b.toString(16).padStart(2, "0"))
247
+ .join(" ");
248
+ const ascii = [...chunk]
249
+ .map((b) => (b >= 0x20 && b < 0x7f ? String.fromCharCode(b) : "."))
250
+ .join("");
251
+ rows.push(`${off.toString(16).padStart(8, "0")} ${hex.padEnd(47, " ")} ${ascii}`);
252
+ }
253
+ return rows.join("\n");
254
+ }
255
+ /** Count lines in a buffer; a trailing newline terminates the last line
256
+ * instead of starting an empty one — matches the client preview's
257
+ * split-based line numbering. */
258
+ export function countLines(buf) {
259
+ if (buf.length === 0)
260
+ return 0;
261
+ const hasTrailingNewline = buf[buf.length - 1] === 10; /* \n */
262
+ let lines = 0;
263
+ for (let i = 0; i < buf.length; i++) {
264
+ if (buf[i] === 10)
265
+ lines++;
266
+ }
267
+ return hasTrailingNewline ? lines : lines + 1;
268
+ }
@@ -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,131 @@
1
+ /** Per-batch timeout; a slow vision provider shouldn't stall a prompt forever. */
2
+ const TRANSCRIBE_TIMEOUT_MS = Number(process.env.PI_WEB_VISION_TIMEOUT_MS ?? 90_000);
3
+ /** Cap the transcript length so it doesn't blow up the main context. */
4
+ const MAX_TRANSCRIBE_TOKENS = 4000;
5
+ /**
6
+ * Scan every configured provider for models that accept image input.
7
+ * Providers the user already configured in pi (models.json + auth.json) are
8
+ * reused as-is — zero new credentials to set up.
9
+ */
10
+ export function findVisionModels(runtime) {
11
+ const out = [];
12
+ for (const p of runtime.getProviders()) {
13
+ // Only providers that actually have credentials — SDK built-ins like
14
+ // amazon-bedrock ship vision-capable models but are not configured
15
+ // unless the user added auth, and calling them would just fail.
16
+ if (!runtime.hasConfiguredAuth(p.id))
17
+ continue;
18
+ for (const m of runtime.getModels(p.id)) {
19
+ if (m.input?.includes("image")) {
20
+ out.push({
21
+ provider: p.id,
22
+ id: m.id,
23
+ label: `${m.name ?? m.id} (${p.id})`,
24
+ });
25
+ }
26
+ }
27
+ }
28
+ return out;
29
+ }
30
+ /**
31
+ * Evidence-first transcription prompt, modeled on modlens' output contract:
32
+ * full verbatim text, reading-order layout blocks, entities/relations, chart
33
+ * axes & data. Emphasizes honesty over hallucination.
34
+ *
35
+ * Exported so the settings panel can offer a custom prompt (append to this
36
+ * default or replace it entirely).
37
+ */
38
+ export const SYSTEM_PROMPT = `You are a vision bridge for a text-only language model. You receive one or more images and must transcribe them into precise, structured text evidence so another model that cannot see images can answer questions about them accurately.
39
+
40
+ Follow these rules:
41
+ 1. Transcribe ALL visible text verbatim, preserving wording, spelling, punctuation and line breaks. This is the most important part — the reader relies on your transcription, not on the image.
42
+ 2. Describe the layout in reading order: headers, paragraphs, lists, tables, buttons, panels — say what appears where.
43
+ 3. For tables/charts/diagrams: read axes, scales (note log scale), legend entries, series names, highlighted points and their coordinates, and any data values you can discern.
44
+ 4. Name entities: people, products, companies, colors, style, objects, actions.
45
+ 5. If part of the image is too blurry/low-resolution to read, say "(读不清)" or "unclear" for that part — NEVER invent or guess content you cannot see.
46
+ 6. If there are multiple images, address them in order (图 1 / Image 1, 图 2 / Image 2, ...).
47
+ 7. Output only the transcript. No preamble, no commentary about the image itself.`;
48
+ /**
49
+ * Assemble the final vision-model system prompt from the settings-panel prefs.
50
+ * mode "append": custom text appended after the default prompt (empty custom =
51
+ * pure default). mode "replace": custom text REPLACES the default prompt, but
52
+ * an empty custom text still falls back to the default (never send an empty
53
+ * system prompt to the vision model).
54
+ */
55
+ export function buildVisionBridgePrompt(mode, custom) {
56
+ const text = custom?.trim() ?? "";
57
+ if (mode === "replace" && text)
58
+ return text;
59
+ if (text)
60
+ return `${SYSTEM_PROMPT}\n\n${text}`;
61
+ return SYSTEM_PROMPT;
62
+ }
63
+ /** Per-batch user instruction appended after the images. */
64
+ function buildUserPrompt(count) {
65
+ if (count <= 1) {
66
+ return "请逐字转写这张图片的内容,并按上述规则输出结构化文字证据。";
67
+ }
68
+ return `请按图片顺序(图 1 到 图 ${count})逐张转写每张图片的内容,并按上述规则输出结构化文字证据。`;
69
+ }
70
+ /**
71
+ * Send one batch of images to a vision model and return its transcript.
72
+ * Throws on timeout, abort, provider error or an empty response.
73
+ */
74
+ export async function transcribeImages(runtime, images, options = {}) {
75
+ const model = options.model ??
76
+ (() => {
77
+ const found = findVisionModels(runtime);
78
+ if (found.length === 0) {
79
+ throw new Error("未找到可用的视觉模型(models.json 中没有任何 input 含 image 的模型)");
80
+ }
81
+ return runtime.getModel(found[0].provider, found[0].id);
82
+ })();
83
+ if (!model)
84
+ throw new Error("视觉模型不可用(ModelRuntime.getModel 返回空)");
85
+ const ac = new AbortController();
86
+ const timer = setTimeout(() => ac.abort(), TRANSCRIBE_TIMEOUT_MS);
87
+ const onOuterAbort = () => ac.abort();
88
+ options.signal?.addEventListener("abort", onOuterAbort);
89
+ try {
90
+ const imageBlocks = images.map((img) => ({
91
+ type: "image",
92
+ data: img.data.replace(/^data:[^;]*;base64,/, ""),
93
+ mimeType: img.mimeType?.startsWith("image/")
94
+ ? img.mimeType
95
+ : "image/png",
96
+ }));
97
+ const context = {
98
+ systemPrompt: options.systemPrompt ?? SYSTEM_PROMPT,
99
+ messages: [
100
+ {
101
+ role: "user",
102
+ timestamp: Date.now(),
103
+ content: [
104
+ ...imageBlocks,
105
+ { type: "text", text: buildUserPrompt(images.length) },
106
+ ],
107
+ },
108
+ ],
109
+ };
110
+ const msg = await runtime.completeSimple(model, context, {
111
+ signal: ac.signal,
112
+ maxTokens: MAX_TRANSCRIBE_TOKENS,
113
+ });
114
+ if (msg.stopReason === "error" || msg.stopReason === "aborted") {
115
+ throw new Error(msg.errorMessage || `视觉模型异常终止(${msg.stopReason})`);
116
+ }
117
+ const text = msg.content
118
+ .filter((b) => b.type === "text")
119
+ .map((b) => b.text ?? "")
120
+ .join("\n")
121
+ .trim();
122
+ if (!text) {
123
+ throw new Error("视觉模型返回了空的转写结果");
124
+ }
125
+ return text;
126
+ }
127
+ finally {
128
+ clearTimeout(timer);
129
+ options.signal?.removeEventListener("abort", onOuterAbort);
130
+ }
131
+ }