@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.
- package/LICENSE +21 -0
- package/README.md +45 -0
- package/bin/pi-web-ui.mjs +1818 -0
- package/deploy/com.xingshuyin.pi-web-ui.plist +48 -0
- package/deploy/nginx-subpath.conf +88 -0
- package/deploy/pi-web-ui-task.xml +71 -0
- package/deploy/pi-web-ui.service +31 -0
- package/dist/server/agent-service.js +2975 -0
- package/dist/server/attachments.js +672 -0
- package/dist/server/bg-servers.js +150 -0
- package/dist/server/client-state.js +234 -0
- package/dist/server/control-socket.js +177 -0
- package/dist/server/ensure-bash.js +80 -0
- package/dist/server/files-service.js +709 -0
- package/dist/server/goal-service.js +869 -0
- package/dist/server/index.js +879 -0
- package/dist/server/mcp-bridge.js +268 -0
- package/dist/server/model-admin.js +821 -0
- package/dist/server/patch-node-pty.js +80 -0
- package/dist/server/plugin-facilities.js +299 -0
- package/dist/server/plugin-updater.js +226 -0
- package/dist/server/plugins.js +893 -0
- package/dist/server/process-utils.js +112 -0
- package/dist/server/protocol-version.js +11 -0
- package/dist/server/protocol.js +6 -0
- package/dist/server/scm.js +298 -0
- package/dist/server/serialize.js +172 -0
- package/dist/server/settings-service.js +395 -0
- package/dist/server/slash-commands.js +263 -0
- package/dist/server/terminals.js +1372 -0
- package/dist/server/text-sniff.js +268 -0
- package/dist/server/uploads.js +107 -0
- package/dist/server/vision-bridge.js +131 -0
- package/dist/server/webui-context.js +222 -0
- package/extensions/webui.ts +192 -0
- package/package.json +99 -0
- package/web/dist/assets/TerminalPanel-6GBZ9nXN.css +32 -0
- package/web/dist/assets/TerminalPanel-D4AktsEn.js +2 -0
- package/web/dist/assets/abnfDiagram-VCTEODGH-Ct5StJwt.js +1 -0
- package/web/dist/assets/arc-pkuxEN8A.js +1 -0
- package/web/dist/assets/architectureDiagram-5GKGNRK7-C6P6yn4y.js +36 -0
- package/web/dist/assets/blockDiagram-I7D4REHJ-BmgfH1jt.js +129 -0
- package/web/dist/assets/c4Diagram-7LVT6UL2-CgmwTFsK.js +38 -0
- package/web/dist/assets/channel-0Y__6sq2.js +1 -0
- package/web/dist/assets/chunk-2Q5K7J3B-DX_NqAiH.js +1 -0
- package/web/dist/assets/chunk-5VM5RSS4-gLeuQuYv.js +15 -0
- package/web/dist/assets/chunk-F27PBJKO-D4WX4mhI.js +1 -0
- package/web/dist/assets/chunk-IMKFNOWR-DBpDxGQD.js +231 -0
- package/web/dist/assets/chunk-JWPE2WC7-DIQ8iZN5.js +1 -0
- package/web/dist/assets/chunk-POPQ4Y6H-F_YTNoGz.js +1 -0
- package/web/dist/assets/chunk-SVP7TREG-CmDIvX-r.js +88 -0
- package/web/dist/assets/chunk-TICWLB2K-BQUzHQyS.js +206 -0
- package/web/dist/assets/chunk-XXDRQBXY-DqR9iffH.js +1 -0
- package/web/dist/assets/classDiagram-ZZMXUADV-BEfGIpzu.js +1 -0
- package/web/dist/assets/classDiagram-v2-VYDZK3BY-BEfGIpzu.js +1 -0
- package/web/dist/assets/cose-bilkent-JH36ORCC-Dl67W4Dx.js +1 -0
- package/web/dist/assets/cynefin-OW5HDTMX-fWdqaCpX.js +178 -0
- package/web/dist/assets/cynefinDiagram-5FMLGOSQ-CSUAXbaP.js +62 -0
- package/web/dist/assets/cytoscape.esm-Bch-eiPH.js +331 -0
- package/web/dist/assets/dagre-GXQ25YYZ-CqUVwu1X.js +4 -0
- package/web/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/web/dist/assets/diagram-S7CK7UJ4-bRce-WYV.js +30 -0
- package/web/dist/assets/diagram-UQ7AKVKN-CiqPtPBE.js +41 -0
- package/web/dist/assets/diagram-VSXAHHWV-CbEA05LX.js +3 -0
- package/web/dist/assets/diagram-VX7I27RA-CRcY9MD-.js +24 -0
- package/web/dist/assets/diagram-Z3DM3KII-DmTwvZdS.js +24 -0
- package/web/dist/assets/ebnfDiagram-PWID7BFC-DbwmiGVj.js +1 -0
- package/web/dist/assets/erDiagram-RLTQ6QDP-Cxiaf87y.js +99 -0
- package/web/dist/assets/flowDiagram-HODETNUW-BQiecP1I.js +168 -0
- package/web/dist/assets/ganttDiagram-EL5Y4UJY-CSr8RRNw.js +292 -0
- package/web/dist/assets/gitGraphDiagram-WWUBYQGX-C4Tdis3g.js +106 -0
- package/web/dist/assets/index-CniHcFxT.js +22 -0
- package/web/dist/assets/index-DYz2iP_B.css +10 -0
- package/web/dist/assets/infoDiagram-27XIBGKW-ij_5A0pY.js +2 -0
- package/web/dist/assets/init-Gi6I4Gst.js +1 -0
- package/web/dist/assets/ishikawaDiagram-5VMMS53U-C8-tm2LV.js +70 -0
- package/web/dist/assets/journeyDiagram-3NMN7TZE-ZJedkNel.js +139 -0
- package/web/dist/assets/kanban-definition-UXKFOSKX-BEuoM53n.js +89 -0
- package/web/dist/assets/katex-C5jXJg4s.js +257 -0
- package/web/dist/assets/layout-zq_9DigZ.js +1 -0
- package/web/dist/assets/linear-D-QXJ5Wc.js +1 -0
- package/web/dist/assets/markdown-CUPbftfV.js +51 -0
- package/web/dist/assets/mermaid.core-CCiG-bOm.js +312 -0
- package/web/dist/assets/mindmap-definition-YA3MSWOX-VaAisUIe.js +96 -0
- package/web/dist/assets/ordinal-Cboi1Yqb.js +1 -0
- package/web/dist/assets/pegDiagram-XKGWAZYB-D5vmRtkn.js +1 -0
- package/web/dist/assets/pieDiagram-E7YTZNPT-loD6V8fn.js +39 -0
- package/web/dist/assets/quadrantDiagram-AXDQQJYC-B7E4mH1-.js +7 -0
- package/web/dist/assets/railroadDiagram-O6MQD6OU-DTALHvDa.js +1 -0
- package/web/dist/assets/react-ZkKJMDQQ.js +24 -0
- package/web/dist/assets/requirementDiagram-BXWQKSXE-ZjJKuEcR.js +84 -0
- package/web/dist/assets/sankeyDiagram-P5KCCOFB-DufTudDP.js +40 -0
- package/web/dist/assets/sequenceDiagram-WJ2MYXX4-B-Zk4alI.js +162 -0
- package/web/dist/assets/sizeCapture-INFHLROL-B1pjlxXN.js +1 -0
- package/web/dist/assets/stateDiagram-D77RDMKH-BeWg6PIR.js +1 -0
- package/web/dist/assets/stateDiagram-v2-MP3YSRHH-DagWhIFJ.js +1 -0
- package/web/dist/assets/swimlanes-42K2YHIH-4J4jmMmO.js +1 -0
- package/web/dist/assets/swimlanesDiagram-VR7AAH4N-Dsi9x540.js +8 -0
- package/web/dist/assets/timeline-definition-24CTP7MA-ByqLfxqe.js +120 -0
- package/web/dist/assets/vennDiagram-4TSXK5OY-B_EqVW4R.js +34 -0
- package/web/dist/assets/wardleyDiagram-VM6X3IG4-Bu7sA6F8.js +78 -0
- package/web/dist/assets/xterm-D1D2FVe3.js +38 -0
- package/web/dist/assets/xychartDiagram-S5SC5T6Z-w1KPVGKk.js +7 -0
- package/web/dist/favicon.svg +8 -0
- package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-400-normal.woff2 +0 -0
- package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-500-normal.woff2 +0 -0
- package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-600-normal.woff2 +0 -0
- package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-400-normal.woff2 +0 -0
- package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-500-normal.woff2 +0 -0
- package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-600-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-400-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-500-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-600-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-700-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-800-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-ext-400-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-ext-500-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-ext-600-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-ext-700-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-ext-800-normal.woff2 +0 -0
- package/web/dist/icon.ico +0 -0
- package/web/dist/index.html +17 -0
- package/web/public/favicon.svg +8 -0
- package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-400-normal.woff2 +0 -0
- package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-500-normal.woff2 +0 -0
- package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-600-normal.woff2 +0 -0
- package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-400-normal.woff2 +0 -0
- package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-500-normal.woff2 +0 -0
- package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-600-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-400-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-500-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-600-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-700-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-800-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-ext-400-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-ext-500-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-ext-600-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-ext-700-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-ext-800-normal.woff2 +0 -0
- package/web/public/icon.ico +0 -0
|
@@ -0,0 +1,672 @@
|
|
|
1
|
+
import { countLines, decodeText, looksLikeText, sniffImageMime, } from "./text-sniff.js";
|
|
2
|
+
import { saveUpload, uploadsRoot } from "./uploads.js";
|
|
3
|
+
import { buildVisionBridgePrompt, findVisionModels, transcribeImages, } from "./vision-bridge.js";
|
|
4
|
+
/** 跨快照的视觉转写缓存:批次 hash(名称 + base64 头 + 提示词)→ 转写文本。
|
|
5
|
+
* 编辑重问重发相同图片不再重复耗视觉 token。进程级共享即可。 */
|
|
6
|
+
const visionBridgeCache = new Map();
|
|
7
|
+
/** "provider/id" 解析;非法格式返回 null。 */
|
|
8
|
+
export function parseModelSpec(spec) {
|
|
9
|
+
if (!spec)
|
|
10
|
+
return null;
|
|
11
|
+
const slash = spec.indexOf("/");
|
|
12
|
+
if (slash <= 0 || slash === spec.length - 1)
|
|
13
|
+
return null;
|
|
14
|
+
return { provider: spec.slice(0, slash), id: spec.slice(slash + 1), spec };
|
|
15
|
+
}
|
|
16
|
+
export async function buildAttachmentMessages(ctx, attachments) {
|
|
17
|
+
if (!attachments || attachments.length === 0)
|
|
18
|
+
return [];
|
|
19
|
+
const fs = await import("node:fs/promises");
|
|
20
|
+
const { resolve, sep, relative, extname, join, basename } = await import("node:path");
|
|
21
|
+
const root = resolve(ctx.cwd);
|
|
22
|
+
const MAX_ATTACHMENT_BYTES = 200 * 1024;
|
|
23
|
+
// Files at or below this size are inlined; larger files are referenced by
|
|
24
|
+
// path only (the model reads them on demand — saves tokens for small edits).
|
|
25
|
+
const MAX_INLINE_BYTES = Number(process.env.PI_WEB_INLINE_FILE_MAX ?? 12 * 1024);
|
|
26
|
+
const IMAGE_EXT = new Set([
|
|
27
|
+
".png",
|
|
28
|
+
".jpg",
|
|
29
|
+
".jpeg",
|
|
30
|
+
".gif",
|
|
31
|
+
".webp",
|
|
32
|
+
".bmp",
|
|
33
|
+
".svg",
|
|
34
|
+
]);
|
|
35
|
+
const MIME = {
|
|
36
|
+
".png": "image/png",
|
|
37
|
+
".jpg": "image/jpeg",
|
|
38
|
+
".jpeg": "image/jpeg",
|
|
39
|
+
".gif": "image/gif",
|
|
40
|
+
".webp": "image/webp",
|
|
41
|
+
".bmp": "image/bmp",
|
|
42
|
+
".svg": "image/svg+xml",
|
|
43
|
+
};
|
|
44
|
+
const out = [];
|
|
45
|
+
/** Push the aside for a raw uploaded file (fresh fileData or a restored
|
|
46
|
+
* uploadPath re-read from disk). Small text files are inlined so the
|
|
47
|
+
* model sees them immediately; everything else becomes a path reference.
|
|
48
|
+
* `upload: true` marks the card as a restorable upload — the browser
|
|
49
|
+
* re-sends it by path when editing & re-asking a question. */
|
|
50
|
+
const pushUploadAside = (name, wirePath, buf) => {
|
|
51
|
+
if (buf.length <= MAX_INLINE_BYTES && looksLikeText(buf)) {
|
|
52
|
+
const lines = countLines(buf);
|
|
53
|
+
out.push({
|
|
54
|
+
message: {
|
|
55
|
+
customType: "file",
|
|
56
|
+
content: [
|
|
57
|
+
{
|
|
58
|
+
type: "text",
|
|
59
|
+
text: `\n<file path="${wirePath}">\n\`\`\`\n${decodeText(buf)}\n\`\`\`\n</file>`,
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
display: true,
|
|
63
|
+
details: {
|
|
64
|
+
name,
|
|
65
|
+
path: wirePath,
|
|
66
|
+
mode: "inline",
|
|
67
|
+
size: buf.length,
|
|
68
|
+
lines,
|
|
69
|
+
upload: true,
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
out.push({
|
|
76
|
+
message: {
|
|
77
|
+
customType: "file",
|
|
78
|
+
content: [
|
|
79
|
+
{
|
|
80
|
+
type: "text",
|
|
81
|
+
text: `<file path="${wirePath}" size="${buf.length}" />`,
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
display: true,
|
|
85
|
+
details: {
|
|
86
|
+
name,
|
|
87
|
+
path: wirePath,
|
|
88
|
+
mode: "reference",
|
|
89
|
+
size: buf.length,
|
|
90
|
+
upload: true,
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
// -- Vision bridge ------------------------------------------------------
|
|
97
|
+
// When the active model can't accept images (DeepSeek, GLM, …), pasted
|
|
98
|
+
// images are transcribed by a configured vision model first and the
|
|
99
|
+
// transcript is fed to the text-only model as text evidence (see
|
|
100
|
+
// vision-bridge.ts — any model in models.json whose input includes
|
|
101
|
+
// "image" works, zero extra config). Vision-capable main models keep
|
|
102
|
+
// the raw image-content path untouched.
|
|
103
|
+
const mainModel = ctx.session.model;
|
|
104
|
+
const mainSupportsVision = mainModel?.input?.includes("image") ?? false;
|
|
105
|
+
const bridgedImages = [];
|
|
106
|
+
/** Raw image bytes for path-referenced image files (idx → info), pre-read
|
|
107
|
+
* so the loop below doesn't re-read them. SVG stays a plain text file —
|
|
108
|
+
* the model reads its source, far more useful than a rasterized blob. */
|
|
109
|
+
const pathImageData = new Map();
|
|
110
|
+
/** Cap for path images (fully read + base64'd); larger ones fall back to
|
|
111
|
+
* a plain path reference (the model can still attempt to read them). */
|
|
112
|
+
const MAX_PATH_IMAGE_BYTES = 5 * 1024 * 1024;
|
|
113
|
+
for (const [idx, att] of attachments.entries()) {
|
|
114
|
+
if (att.imageData) {
|
|
115
|
+
const raw = att.imageData.replace(/^data:[^;]*;base64,/, "");
|
|
116
|
+
const mimeType = att.mimeType?.startsWith("image/")
|
|
117
|
+
? att.mimeType
|
|
118
|
+
: "image/png";
|
|
119
|
+
const bytes = Buffer.byteLength(raw, "base64");
|
|
120
|
+
// Only images that would actually be sent (non-empty, under the cap).
|
|
121
|
+
if (bytes > 0 && bytes <= 2 * 1024 * 1024) {
|
|
122
|
+
if (!mainSupportsVision) {
|
|
123
|
+
bridgedImages.push({ idx, att, raw, mimeType, bytes });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (att.fileData || !att.path)
|
|
129
|
+
continue;
|
|
130
|
+
const ext = extname(att.path).toLowerCase();
|
|
131
|
+
if (!IMAGE_EXT.has(ext) || ext === ".svg")
|
|
132
|
+
continue;
|
|
133
|
+
const abs = resolve(root, att.path);
|
|
134
|
+
const rawRel = relative(root, abs);
|
|
135
|
+
if (rawRel.startsWith("..") || rawRel.includes(`${sep}..`))
|
|
136
|
+
continue;
|
|
137
|
+
let st;
|
|
138
|
+
try {
|
|
139
|
+
st = await fs.stat(abs);
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (!st.isFile() || st.size === 0 || st.size > MAX_PATH_IMAGE_BYTES) {
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const buf = await fs.readFile(abs);
|
|
148
|
+
const mime = sniffImageMime(buf, ext);
|
|
149
|
+
if (!mime)
|
|
150
|
+
continue;
|
|
151
|
+
const raw = buf.toString("base64");
|
|
152
|
+
pathImageData.set(idx, { raw, mimeType: mime, bytes: st.size });
|
|
153
|
+
if (!mainSupportsVision) {
|
|
154
|
+
bridgedImages.push({ idx, att, raw, mimeType: mime, bytes: st.size });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/** Transcript per attachment index (filled below, keyed by bridgedImages idx). */
|
|
158
|
+
const bridgeTranscripts = new Map();
|
|
159
|
+
if (bridgedImages.length > 0) {
|
|
160
|
+
if (!ctx.settings.visionBridgeEnabled) {
|
|
161
|
+
ctx.emit({
|
|
162
|
+
type: "notice",
|
|
163
|
+
level: "warning",
|
|
164
|
+
text: `当前模型(${mainModel?.name ?? mainModel?.id ?? "未知"})不支持识图,且视觉桥已在设置中关闭:图片将原样发送、可能被忽略。`,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
const visionModels = findVisionModels(ctx.session.modelRuntime);
|
|
169
|
+
// Preferred model from settings ("provider/id") — validated to exist
|
|
170
|
+
// and actually accept images; falls back to the first auto-detected.
|
|
171
|
+
let chosen = visionModels[0] ?? null;
|
|
172
|
+
const pref = ctx.settings.visionBridgeModel;
|
|
173
|
+
if (pref) {
|
|
174
|
+
const spec = parseModelSpec(pref);
|
|
175
|
+
if (spec) {
|
|
176
|
+
const pm = ctx.session.modelRuntime.getModel(spec.provider, spec.id);
|
|
177
|
+
if (pm?.input?.includes("image")) {
|
|
178
|
+
chosen = {
|
|
179
|
+
provider: spec.provider,
|
|
180
|
+
id: spec.id,
|
|
181
|
+
label: `${pm.name ?? pm.id} (${spec.provider})`,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (!chosen) {
|
|
187
|
+
ctx.emit({
|
|
188
|
+
type: "notice",
|
|
189
|
+
level: "warning",
|
|
190
|
+
text: `当前模型(${mainModel?.name ?? mainModel?.id ?? "未知"})不支持识图,且未找到可用的视觉模型:图片将原样发送、可能被忽略。在模型配置里添加任意支持图片的模型(如 qwen-vl、GLM-4V、Gemini)即可自动启用视觉桥转写。`,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
// Batch hash so re-sending identical images (edit & re-ask) reuses
|
|
195
|
+
// the transcript instead of re-burning tokens on the vision API.
|
|
196
|
+
// The active transcription prompt is part of the key: changing
|
|
197
|
+
// the custom prompt must invalidate cached transcripts made with
|
|
198
|
+
// the old prompt.
|
|
199
|
+
const batchHash = bridgedImages
|
|
200
|
+
.map((b) => `${b.att.name ?? "img"}:${b.raw.slice(0, 48)}`)
|
|
201
|
+
.join("|") +
|
|
202
|
+
"::" +
|
|
203
|
+
buildVisionBridgePrompt(ctx.settings.visionBridgePromptMode, ctx.settings.visionBridgePrompt);
|
|
204
|
+
let transcript = visionBridgeCache.get(batchHash);
|
|
205
|
+
if (transcript === undefined) {
|
|
206
|
+
ctx.emit({
|
|
207
|
+
type: "notice",
|
|
208
|
+
level: "info",
|
|
209
|
+
text: `当前模型不支持识图,正在用视觉桥(${chosen.label})转写 ${bridgedImages.length} 张图片…`,
|
|
210
|
+
});
|
|
211
|
+
try {
|
|
212
|
+
const chosenModel = ctx.session.modelRuntime.getModel(chosen.provider, chosen.id);
|
|
213
|
+
transcript = await transcribeImages(ctx.session.modelRuntime, bridgedImages.map((b) => ({
|
|
214
|
+
data: b.raw,
|
|
215
|
+
mimeType: b.mimeType,
|
|
216
|
+
name: b.att.name,
|
|
217
|
+
})), {
|
|
218
|
+
model: chosenModel ?? undefined,
|
|
219
|
+
systemPrompt: buildVisionBridgePrompt(ctx.settings.visionBridgePromptMode, ctx.settings.visionBridgePrompt),
|
|
220
|
+
});
|
|
221
|
+
visionBridgeCache.set(batchHash, transcript);
|
|
222
|
+
ctx.emit({
|
|
223
|
+
type: "notice",
|
|
224
|
+
level: "info",
|
|
225
|
+
text: `✅ 图片已由视觉桥转写完成(${chosen.label})`,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
catch (err) {
|
|
229
|
+
transcript = "";
|
|
230
|
+
ctx.emit({
|
|
231
|
+
type: "notice",
|
|
232
|
+
level: "error",
|
|
233
|
+
text: `图片转写失败(${chosen.label}):${err.message}。图片将原样发送、可能被忽略。`,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
for (const b of bridgedImages)
|
|
238
|
+
bridgeTranscripts.set(b.idx, transcript ?? "");
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
/** Cap for reading a file in "lines" mode (selected slice is inlined). */
|
|
243
|
+
const MAX_LINES_READ_BYTES = 2 * 1024 * 1024;
|
|
244
|
+
for (const [idx, att] of attachments.entries()) {
|
|
245
|
+
// Raw pasted/dropped/uploaded image — no workspace path involved (the
|
|
246
|
+
// browser downscales client-side; this guard only prevents abuse).
|
|
247
|
+
if (att.imageData) {
|
|
248
|
+
const raw = att.imageData.replace(/^data:[^;]*;base64,/, "");
|
|
249
|
+
const mimeType = att.mimeType?.startsWith("image/") ? att.mimeType : "image/png";
|
|
250
|
+
const bytes = Buffer.byteLength(raw, "base64");
|
|
251
|
+
const MAX_PASTED_IMAGE_BYTES = 2 * 1024 * 1024;
|
|
252
|
+
if (bytes === 0) {
|
|
253
|
+
ctx.emit({
|
|
254
|
+
type: "notice",
|
|
255
|
+
level: "error",
|
|
256
|
+
text: `图片数据为空,已跳过`,
|
|
257
|
+
});
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
if (bytes > MAX_PASTED_IMAGE_BYTES) {
|
|
261
|
+
ctx.emit({
|
|
262
|
+
type: "notice",
|
|
263
|
+
level: "warning",
|
|
264
|
+
text: `图片过大已跳过(>2MB):${att.name ?? "粘贴图片"}`,
|
|
265
|
+
});
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
const transcript = bridgeTranscripts.get(idx);
|
|
269
|
+
if (transcript) {
|
|
270
|
+
// Bridged: the text-only main model can't see images, so it gets the
|
|
271
|
+
// vision model's transcript as text evidence; the image block is
|
|
272
|
+
// kept so the card still shows the original thumbnail.
|
|
273
|
+
out.push({
|
|
274
|
+
message: {
|
|
275
|
+
customType: "file",
|
|
276
|
+
content: [
|
|
277
|
+
{
|
|
278
|
+
type: "text",
|
|
279
|
+
text: `\n<vision-bridge>\n${transcript}\n</vision-bridge>`,
|
|
280
|
+
},
|
|
281
|
+
{ type: "image", data: raw, mimeType },
|
|
282
|
+
],
|
|
283
|
+
display: true,
|
|
284
|
+
details: {
|
|
285
|
+
name: att.name ?? "image.png",
|
|
286
|
+
path: undefined,
|
|
287
|
+
mode: "bridged",
|
|
288
|
+
size: bytes,
|
|
289
|
+
},
|
|
290
|
+
},
|
|
291
|
+
});
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
out.push({
|
|
295
|
+
message: {
|
|
296
|
+
customType: "file",
|
|
297
|
+
content: [{ type: "image", data: raw, mimeType }],
|
|
298
|
+
display: true,
|
|
299
|
+
details: {
|
|
300
|
+
name: att.name ?? "image.png",
|
|
301
|
+
// No workspace path — the card renders without the path line.
|
|
302
|
+
path: undefined,
|
|
303
|
+
mode: "image",
|
|
304
|
+
size: bytes,
|
|
305
|
+
},
|
|
306
|
+
},
|
|
307
|
+
});
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
// Raw uploaded file (base64) — no workspace path involved. The bytes are
|
|
311
|
+
// persisted under <dataDir>/uploads/<clientId>/ so the model can read
|
|
312
|
+
// them on demand with its read tool (absolute path, no traversal guard
|
|
313
|
+
// needed — the path is server-generated). Small text uploads are inlined
|
|
314
|
+
// so the model sees them immediately; everything else becomes a path
|
|
315
|
+
// reference.
|
|
316
|
+
if (att.fileData) {
|
|
317
|
+
const buf = Buffer.from(att.fileData, "base64");
|
|
318
|
+
const MAX_UPLOAD_BYTES = 20 * 1024 * 1024;
|
|
319
|
+
if (buf.length === 0) {
|
|
320
|
+
ctx.emit({
|
|
321
|
+
type: "notice",
|
|
322
|
+
level: "error",
|
|
323
|
+
text: `文件数据为空,已跳过`,
|
|
324
|
+
});
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
if (buf.length > MAX_UPLOAD_BYTES) {
|
|
328
|
+
ctx.emit({
|
|
329
|
+
type: "notice",
|
|
330
|
+
level: "warning",
|
|
331
|
+
text: `文件过大已跳过(>20MB):${att.name ?? "上传文件"}`,
|
|
332
|
+
});
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
// Uploaded files live in a GLOBAL per-user dir (not inside the project
|
|
336
|
+
// or the per-client session store) so browsing a repo never picks up
|
|
337
|
+
// uploaded junk: <dataDir>/uploads/<clientId>/(保留期自动清理,见 uploads.ts)。
|
|
338
|
+
const { abs, displayName: safeName } = saveUpload(ctx.clientId, att.name ?? "file", buf);
|
|
339
|
+
// Wire format: forward-slash absolute path (the read tool accepts
|
|
340
|
+
// absolute paths; Windows uses "C:/..." — safe inside the XML-ish tag).
|
|
341
|
+
const wirePath = abs.split(sep).join("/");
|
|
342
|
+
pushUploadAside(safeName, wirePath, buf);
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
// Restored upload from edit-and-re-ask: the browser re-sends the
|
|
346
|
+
// server-generated absolute path of a previously uploaded (fileData)
|
|
347
|
+
// file instead of the original base64 (the fork drops the original
|
|
348
|
+
// aside card, so the bytes must be re-read from the uploads dir).
|
|
349
|
+
// Validate the path stays inside THIS client's uploads/ folder, then
|
|
350
|
+
// re-read the persisted bytes and attach by the same path — no
|
|
351
|
+
// re-save (the file already exists; retention sweeping governs its
|
|
352
|
+
// lifetime, same as the original card).
|
|
353
|
+
if (att.uploadPath) {
|
|
354
|
+
const rootDir = uploadsRoot();
|
|
355
|
+
const abs = resolve(rootDir, att.uploadPath);
|
|
356
|
+
const relToRoot = relative(rootDir, abs);
|
|
357
|
+
const inClientDir = !relToRoot.startsWith("..") &&
|
|
358
|
+
!relToRoot.includes(`${sep}..`) &&
|
|
359
|
+
(relToRoot === ctx.clientId ||
|
|
360
|
+
relToRoot.startsWith(`${ctx.clientId}${sep}`));
|
|
361
|
+
if (!inClientDir) {
|
|
362
|
+
ctx.emit({
|
|
363
|
+
type: "notice",
|
|
364
|
+
level: "warning",
|
|
365
|
+
text: `无法恢复已上传文件(路径不在本客户端上传目录):${att.name ?? att.uploadPath}`,
|
|
366
|
+
});
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
let buf;
|
|
370
|
+
try {
|
|
371
|
+
buf = await fs.readFile(abs);
|
|
372
|
+
}
|
|
373
|
+
catch {
|
|
374
|
+
ctx.emit({
|
|
375
|
+
type: "notice",
|
|
376
|
+
level: "warning",
|
|
377
|
+
text: `无法恢复已上传文件(已被清理或不可读):${att.name ?? att.uploadPath}`,
|
|
378
|
+
});
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
if (buf.length === 0)
|
|
382
|
+
continue;
|
|
383
|
+
pushUploadAside(att.name ?? basename(abs), abs.split(sep).join("/"), buf);
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
const abs = resolve(root, att.path);
|
|
387
|
+
const rawRel = relative(root, abs);
|
|
388
|
+
if (rawRel.startsWith("..") || rawRel.includes(`${sep}..`)) {
|
|
389
|
+
ctx.emit({
|
|
390
|
+
type: "notice",
|
|
391
|
+
level: "warning",
|
|
392
|
+
text: `附件路径超出工作区:${att.path}`,
|
|
393
|
+
});
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
// Normalize to forward slashes (relative() returns "\\" on Windows);
|
|
397
|
+
// <file path> and details.path must use the wire format.
|
|
398
|
+
const rel = rawRel.split(sep).join("/");
|
|
399
|
+
let stat;
|
|
400
|
+
try {
|
|
401
|
+
stat = await fs.stat(abs);
|
|
402
|
+
}
|
|
403
|
+
catch {
|
|
404
|
+
ctx.emit({
|
|
405
|
+
type: "notice",
|
|
406
|
+
level: "error",
|
|
407
|
+
text: `附件不存在:${att.path}`,
|
|
408
|
+
});
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
const name = att.path.split(/[\\/]/).pop() ?? att.path;
|
|
412
|
+
// Folders can't be inlined — always a path reference the model browses
|
|
413
|
+
// on demand with its own tools (ls/read).
|
|
414
|
+
if (stat.isDirectory()) {
|
|
415
|
+
out.push({
|
|
416
|
+
message: {
|
|
417
|
+
customType: "file",
|
|
418
|
+
content: [{ type: "text", text: `<folder path="${rel}" />` }],
|
|
419
|
+
display: true,
|
|
420
|
+
details: {
|
|
421
|
+
name,
|
|
422
|
+
path: rel,
|
|
423
|
+
mode: "reference",
|
|
424
|
+
type: "folder",
|
|
425
|
+
},
|
|
426
|
+
},
|
|
427
|
+
});
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
if (!stat.isFile()) {
|
|
431
|
+
ctx.emit({
|
|
432
|
+
type: "notice",
|
|
433
|
+
level: "warning",
|
|
434
|
+
text: `跳过非文件附件:${att.path}`,
|
|
435
|
+
});
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
const ext = extname(att.path).toLowerCase();
|
|
439
|
+
if (IMAGE_EXT.has(ext) && ext !== ".svg") {
|
|
440
|
+
const pathImg = pathImageData.get(idx);
|
|
441
|
+
const transcript = bridgeTranscripts.get(idx);
|
|
442
|
+
if (transcript) {
|
|
443
|
+
// Text-only main model: the vision bridge transcribed this image —
|
|
444
|
+
// the model gets the transcript as text evidence (+ thumbnail).
|
|
445
|
+
out.push({
|
|
446
|
+
message: {
|
|
447
|
+
customType: "file",
|
|
448
|
+
content: [
|
|
449
|
+
{
|
|
450
|
+
type: "text",
|
|
451
|
+
text: `
|
|
452
|
+
<vision-bridge>
|
|
453
|
+
${transcript}
|
|
454
|
+
</vision-bridge>`,
|
|
455
|
+
},
|
|
456
|
+
...(pathImg
|
|
457
|
+
? ([{
|
|
458
|
+
type: "image",
|
|
459
|
+
data: pathImg.raw,
|
|
460
|
+
mimeType: pathImg.mimeType,
|
|
461
|
+
}])
|
|
462
|
+
: []),
|
|
463
|
+
],
|
|
464
|
+
display: true,
|
|
465
|
+
details: {
|
|
466
|
+
name,
|
|
467
|
+
path: rel,
|
|
468
|
+
mode: "bridged",
|
|
469
|
+
size: stat.size,
|
|
470
|
+
},
|
|
471
|
+
},
|
|
472
|
+
});
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
if (pathImg) {
|
|
476
|
+
// Vision-capable main model (or bridge failed): send the raw image
|
|
477
|
+
// content straight from the pre-read bytes.
|
|
478
|
+
out.push({
|
|
479
|
+
message: {
|
|
480
|
+
customType: "file",
|
|
481
|
+
content: [
|
|
482
|
+
{
|
|
483
|
+
type: "image",
|
|
484
|
+
data: pathImg.raw,
|
|
485
|
+
mimeType: pathImg.mimeType,
|
|
486
|
+
},
|
|
487
|
+
],
|
|
488
|
+
display: true,
|
|
489
|
+
details: { name, path: rel, mode: "image", size: stat.size },
|
|
490
|
+
},
|
|
491
|
+
});
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
// Pre-read failed (unsupported sniff / too large): fall back to the
|
|
495
|
+
// legacy inline-cap behavior.
|
|
496
|
+
if (stat.size > MAX_ATTACHMENT_BYTES) {
|
|
497
|
+
ctx.emit({
|
|
498
|
+
type: "notice",
|
|
499
|
+
level: "warning",
|
|
500
|
+
text: `图片附件过大已跳过(>200KB):${att.path}`,
|
|
501
|
+
});
|
|
502
|
+
continue;
|
|
503
|
+
}
|
|
504
|
+
const data = await fs.readFile(abs, "base64");
|
|
505
|
+
out.push({
|
|
506
|
+
message: {
|
|
507
|
+
customType: "file",
|
|
508
|
+
content: [
|
|
509
|
+
{ type: "image", data, mimeType: MIME[ext] ?? "image/png" },
|
|
510
|
+
],
|
|
511
|
+
display: true,
|
|
512
|
+
details: { name, path: rel, mode: "image", size: stat.size },
|
|
513
|
+
},
|
|
514
|
+
});
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
const makeReference = () => ({
|
|
518
|
+
message: {
|
|
519
|
+
customType: "file",
|
|
520
|
+
content: [
|
|
521
|
+
{
|
|
522
|
+
type: "text",
|
|
523
|
+
text: `<file path="${rel}" size="${stat.size}" />`,
|
|
524
|
+
},
|
|
525
|
+
],
|
|
526
|
+
display: true,
|
|
527
|
+
details: { name, path: rel, mode: "reference", size: stat.size },
|
|
528
|
+
},
|
|
529
|
+
});
|
|
530
|
+
const makeInline = (buf) => {
|
|
531
|
+
const lines = countLines(buf);
|
|
532
|
+
return {
|
|
533
|
+
message: {
|
|
534
|
+
customType: "file",
|
|
535
|
+
content: [
|
|
536
|
+
{
|
|
537
|
+
type: "text",
|
|
538
|
+
text: `\n<file path="${rel}">\n\`\`\`\n${decodeText(buf)}\n\`\`\`\n</file>`,
|
|
539
|
+
},
|
|
540
|
+
],
|
|
541
|
+
display: true,
|
|
542
|
+
details: {
|
|
543
|
+
name,
|
|
544
|
+
path: rel,
|
|
545
|
+
mode: "inline",
|
|
546
|
+
size: stat.size,
|
|
547
|
+
lines,
|
|
548
|
+
},
|
|
549
|
+
},
|
|
550
|
+
};
|
|
551
|
+
};
|
|
552
|
+
// Reference mode is always honored and never reads the file.
|
|
553
|
+
if (att.mode === "reference") {
|
|
554
|
+
out.push(makeReference());
|
|
555
|
+
continue;
|
|
556
|
+
}
|
|
557
|
+
// Line-range mode: inline only the selected 1-based inclusive range.
|
|
558
|
+
// Reading is capped so a huge file can't exhaust memory even though
|
|
559
|
+
// the selected slice is small.
|
|
560
|
+
if (att.mode === "lines") {
|
|
561
|
+
const range = att.lines;
|
|
562
|
+
if (!range || range.start < 1 || range.end < range.start) {
|
|
563
|
+
ctx.emit({
|
|
564
|
+
type: "notice",
|
|
565
|
+
level: "warning",
|
|
566
|
+
text: `行范围无效,已改为仅引用:${att.path}`,
|
|
567
|
+
});
|
|
568
|
+
out.push(makeReference());
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
if (stat.size > MAX_LINES_READ_BYTES) {
|
|
572
|
+
ctx.emit({
|
|
573
|
+
type: "notice",
|
|
574
|
+
level: "warning",
|
|
575
|
+
text: `文件过大,已改为仅引用:${att.path}`,
|
|
576
|
+
});
|
|
577
|
+
out.push(makeReference());
|
|
578
|
+
continue;
|
|
579
|
+
}
|
|
580
|
+
const buf = await fs.readFile(abs);
|
|
581
|
+
if (buf.includes(0)) {
|
|
582
|
+
ctx.emit({
|
|
583
|
+
type: "notice",
|
|
584
|
+
level: "warning",
|
|
585
|
+
text: `二进制文件已改为仅引用:${att.path}`,
|
|
586
|
+
});
|
|
587
|
+
out.push(makeReference());
|
|
588
|
+
continue;
|
|
589
|
+
}
|
|
590
|
+
const parts = decodeText(buf).split("\n");
|
|
591
|
+
// A trailing newline yields an empty phantom line — drop it so line
|
|
592
|
+
// numbers match the preview panel.
|
|
593
|
+
if (parts.length > 0 && parts[parts.length - 1] === "")
|
|
594
|
+
parts.pop();
|
|
595
|
+
const start = Math.min(range.start, parts.length);
|
|
596
|
+
const end = Math.min(range.end, parts.length);
|
|
597
|
+
if (start < 1 || end < start) {
|
|
598
|
+
ctx.emit({
|
|
599
|
+
type: "notice",
|
|
600
|
+
level: "warning",
|
|
601
|
+
text: `选中行超出文件范围,已改为仅引用:${att.path}`,
|
|
602
|
+
});
|
|
603
|
+
out.push(makeReference());
|
|
604
|
+
continue;
|
|
605
|
+
}
|
|
606
|
+
const selected = parts.slice(start - 1, end).join("\n");
|
|
607
|
+
out.push({
|
|
608
|
+
message: {
|
|
609
|
+
customType: "file",
|
|
610
|
+
content: [
|
|
611
|
+
{
|
|
612
|
+
type: "text",
|
|
613
|
+
text: `\n<file path="${rel}" lines="${start}-${end}">\n\`\`\`\n${selected}\n\`\`\`\n</file>`,
|
|
614
|
+
},
|
|
615
|
+
],
|
|
616
|
+
display: true,
|
|
617
|
+
details: {
|
|
618
|
+
name,
|
|
619
|
+
path: rel,
|
|
620
|
+
mode: "lines",
|
|
621
|
+
size: stat.size,
|
|
622
|
+
lines: end - start + 1,
|
|
623
|
+
startLine: start,
|
|
624
|
+
endLine: end,
|
|
625
|
+
},
|
|
626
|
+
},
|
|
627
|
+
});
|
|
628
|
+
continue;
|
|
629
|
+
}
|
|
630
|
+
// Forced inline has a hard cap to protect the model context.
|
|
631
|
+
if (att.mode === "inline") {
|
|
632
|
+
if (stat.size > MAX_INLINE_BYTES) {
|
|
633
|
+
ctx.emit({
|
|
634
|
+
type: "notice",
|
|
635
|
+
level: "warning",
|
|
636
|
+
text: `文件过大,已改为仅引用:${att.path}`,
|
|
637
|
+
});
|
|
638
|
+
out.push(makeReference());
|
|
639
|
+
continue;
|
|
640
|
+
}
|
|
641
|
+
const buf = await fs.readFile(abs);
|
|
642
|
+
if (buf.includes(0)) {
|
|
643
|
+
ctx.emit({
|
|
644
|
+
type: "notice",
|
|
645
|
+
level: "warning",
|
|
646
|
+
text: `二进制文件已改为仅引用:${att.path}`,
|
|
647
|
+
});
|
|
648
|
+
out.push(makeReference());
|
|
649
|
+
continue;
|
|
650
|
+
}
|
|
651
|
+
out.push(makeInline(buf));
|
|
652
|
+
continue;
|
|
653
|
+
}
|
|
654
|
+
// Auto: small files inline, large files reference by path.
|
|
655
|
+
if (stat.size > MAX_INLINE_BYTES) {
|
|
656
|
+
out.push(makeReference());
|
|
657
|
+
continue;
|
|
658
|
+
}
|
|
659
|
+
const buf = await fs.readFile(abs);
|
|
660
|
+
if (buf.includes(0)) {
|
|
661
|
+
ctx.emit({
|
|
662
|
+
type: "notice",
|
|
663
|
+
level: "warning",
|
|
664
|
+
text: `二进制文件已跳过(仅引用路径):${att.path}`,
|
|
665
|
+
});
|
|
666
|
+
out.push(makeReference());
|
|
667
|
+
continue;
|
|
668
|
+
}
|
|
669
|
+
out.push(makeInline(buf));
|
|
670
|
+
}
|
|
671
|
+
return out;
|
|
672
|
+
}
|