picturereader-zcode 1.0.3 → 2.0.1

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.
@@ -0,0 +1,256 @@
1
+ ---
2
+ name: vision-analyze
3
+ description: Unified image understanding tool that combines pixel scan, OCR, and optional VLM for complete image analysis. Use when you need one call to both verify what is in the image and get a natural-language interpretation.
4
+ whenToUse: 需要一次性获取图片的多种证据(像素扫描 + OCR + VLM 描述)时使用。
5
+ ---
6
+
7
+ # vision_analyze 统一识图工具
8
+
9
+ ## 工具概述
10
+
11
+ `vision_analyze` 是 picturereader 的统一识图入口,一次调用可获取:
12
+ - **像素扫描证据**(image_scan)
13
+ - **OCR 文字识别**(image_ocr)
14
+ - **VLM 语义描述**(可选,需配置 SEE_BASE)
15
+
16
+ ## 核心特性
17
+
18
+ ### 1. 低信息量拦截
19
+ 自动检测空白/简单图片,防止 VLM 幻觉:
20
+ - 颜色种类太少(≤8 种)
21
+ - 单一颜色占比过高(≥90%)
22
+ - 主导颜色且边缘稀少
23
+ - 亮度方差太小
24
+
25
+ ### 2. 证据交叉验证
26
+ - VLM 描述与像素/OCR 冲突时,以像素/OCR 实测为准
27
+ - 所有证据以文本形式返回,供主模型推理
28
+
29
+ ### 3. VLM 可选配置
30
+ - 默认不配置 VLM,只返回像素扫描和 OCR 证据
31
+ - 需要 VLM 时,设置 `SEE_BASE` 环境变量
32
+
33
+ ### 4. 智能调用策略
34
+ - **简单图片**:不调用外部 API,使用像素扫描 + OCR 即可
35
+ - **复杂/精密图片**:调用外部 API 获取语义理解
36
+ - **多次提问**:支持对同一张图进行多次不同角度的提问
37
+
38
+ ## 使用建议
39
+
40
+ ### 推荐的工作流程
41
+
42
+ 建议先用 `image_scan` 自己看,了解图片内容后再决定是否需要调用 VLM:
43
+
44
+ ```
45
+ # 先看图片内容
46
+ image_scan(file_path="C:/shot.png", size=32)
47
+
48
+ # 根据结果决定下一步
49
+ # - 简单图片 → 直接描述,不需要 VLM
50
+ # - 需要文字 → image_ocr
51
+ # - 复杂场景 → vision_analyze(含 VLM)
52
+ ```
53
+
54
+ ### 何时调用 VLM
55
+
56
+ 简单图片用像素扫描就够了,复杂场景可以调用 VLM 获取语义理解。具体由你根据图片内容判断。
57
+
58
+ ### 交叉验证
59
+
60
+ 主模型需要对 VLM 结果进行交叉验证:
61
+
62
+ 1. **像素证据优先**:VLM 描述与像素扫描冲突时,以像素证据为准
63
+ 2. **OCR 优先**:VLM 识别的文字与 OCR 冲突时,以 OCR 为准
64
+ 3. **逻辑验证**:VLM 描述不符合逻辑时(如"天空是绿色的"),标记为幻觉
65
+ 4. **多次提问验证**:对同一张图进行多次不同角度的提问,验证一致性
66
+
67
+ ### 多次提问策略
68
+
69
+ 对同一张图可以进行多次不同角度的提问,以获取更全面的理解:
70
+
71
+ ```
72
+ # 第一次:整体描述
73
+ vision_analyze(
74
+ file_path="C:/shot.png",
75
+ prompt="描述这个图片的整体内容",
76
+ include_scan=true,
77
+ include_ocr=true,
78
+ include_vlm=true
79
+ )
80
+
81
+ # 第二次:细节询问
82
+ vision_analyze(
83
+ file_path="C:/shot.png",
84
+ prompt="图片中有哪些文字?请详细列出",
85
+ include_scan=false,
86
+ include_ocr=false,
87
+ include_vlm=true
88
+ )
89
+
90
+ # 第三次:推理判断
91
+ vision_analyze(
92
+ file_path="C:/shot.png",
93
+ prompt="这个界面设计是否合理?有哪些问题?",
94
+ include_scan=false,
95
+ include_ocr=false,
96
+ include_vlm=true
97
+ )
98
+ ```
99
+
100
+ ## 使用方法
101
+
102
+ ### 基本用法(无 VLM)
103
+ ```
104
+ vision_analyze(
105
+ file_path="C:/shot.png",
106
+ include_scan=true,
107
+ include_ocr=false,
108
+ include_vlm=false
109
+ )
110
+ ```
111
+
112
+ ### 完整用法(含 VLM)
113
+ ```
114
+ vision_analyze(
115
+ file_path="C:/shot.png",
116
+ prompt="描述这个界面,有哪些元素?布局是否正常?",
117
+ include_scan=true,
118
+ include_ocr=true,
119
+ include_vlm=true,
120
+ allow_low_info=false,
121
+ stop_after=false
122
+ )
123
+ ```
124
+
125
+ ### 多次提问用法
126
+ ```
127
+ # 对同一张图进行多次不同角度的提问
128
+ vision_analyze(file_path="C:/shot.png", prompt="整体描述", include_vlm=true)
129
+ vision_analyze(file_path="C:/shot.png", prompt="有哪些文字?", include_vlm=true)
130
+ vision_analyze(file_path="C:/shot.png", prompt="设计是否合理?", include_vlm=true)
131
+ ```
132
+
133
+ ## 参数说明
134
+
135
+ | 参数 | 类型 | 默认值 | 说明 |
136
+ |------|------|--------|------|
137
+ | `file_path` | string | 必需 | 图片路径(PNG/JPEG/GIF/BMP) |
138
+ | `prompt` | string | "Describe this image in detail." | VLM 提示词 |
139
+ | `include_scan` | boolean | true | 是否包含像素扫描证据 |
140
+ | `include_ocr` | boolean | false | 是否包含 OCR 文字识别 |
141
+ | `ocr_engine` | string | "windows" | OCR 引擎:windows 或 paddle |
142
+ | `include_vlm` | boolean | true | 是否包含 VLM 描述(需配置 SEE_BASE) |
143
+ | `allow_low_info` | boolean | false | 是否允许低信息量图片调用 VLM |
144
+ | `stop_after` | boolean | false | 调用后是否停止本地 llama-server |
145
+
146
+ ## 输出格式
147
+
148
+ ```json
149
+ {
150
+ "path": "C:/shot.png",
151
+ "lowInformation": false,
152
+ "scan": "[scan]\nimage: C:/shot.png (1920x1080 -> 32x18 cells, ...)\n...",
153
+ "ocr": "[ocr]\nocr: C:/shot.png (1920x1080, region=full, engine=windows)\n...",
154
+ "vlm": "[vlm]\n这是一个桌面应用程序的截图,包含...",
155
+ "combined": "[scan]\n...\n\n---\n\n[ocr]\n...\n\n---\n\n[vlm]\n..."
156
+ }
157
+ ```
158
+
159
+ ## 使用场景
160
+
161
+ ### 1. UI/界面验证
162
+ ```
163
+ vision_analyze(
164
+ file_path="C:/ui_screenshot.png",
165
+ prompt="这个界面有哪些按钮?布局是否正常?有没有错位?",
166
+ include_scan=true,
167
+ include_ocr=true,
168
+ include_vlm=true
169
+ )
170
+ ```
171
+
172
+ ### 2. 游戏截图分析
173
+ ```
174
+ vision_analyze(
175
+ file_path="C:/game_screenshot.png",
176
+ prompt="这是什么游戏?画面中有什么角色/物体?",
177
+ include_scan=true,
178
+ include_ocr=true,
179
+ include_vlm=true
180
+ )
181
+ ```
182
+
183
+ ### 3. 文档/图片 OCR
184
+ ```
185
+ vision_analyze(
186
+ file_path="C:/document.png",
187
+ include_scan=false,
188
+ include_ocr=true,
189
+ include_vlm=false,
190
+ ocr_engine="paddle"
191
+ )
192
+ ```
193
+
194
+ ### 4. 长任务视觉验证
195
+ ```
196
+ # 循环验证流程
197
+ 1. 截图
198
+ 2. vision_analyze(file_path="C:/step1.png", include_scan=true, include_ocr=true)
199
+ 3. 与预期比对
200
+ 4. 不一致则修正
201
+ 5. 再截图验证
202
+ ```
203
+
204
+ ## 配置说明
205
+
206
+ ### PaddleOCR(可选)
207
+ ```bash
208
+ # 安装 PaddleOCR
209
+ node scripts/setup-ocr.mjs
210
+
211
+ # 环境变量
212
+ DSH_PADDLE_PYTHON=C:\Users\Administrator\paddle_venv\Scripts\python.exe
213
+ DSH_PADDLE_CACHE=<插件目录>\.paddlex-cache
214
+ ```
215
+
216
+ ### VLM(可选,默认不配置)
217
+ ```bash
218
+ # 本地 llama-server
219
+ SEE_BASE=http://127.0.0.1:8080/v1
220
+ SEE_MODEL=Huihui-Qwen3-VL-4B-Instruct-abliterated
221
+ SEE_SERVER_EXE=E:\llama\llama-server.exe
222
+ SEE_SERVER_MODEL=E:\llama\models\model.f16.gguf
223
+ SEE_SERVER_MMPROJ=E:\llama\models\mmproj-f16.gguf
224
+ SEE_SERVER_PORT=8080
225
+ SEE_SERVER_NGL=20
226
+ SEE_SERVER_CTX=16384
227
+
228
+ # 远程 API
229
+ SEE_BASE=https://api.openai.com/v1
230
+ SEE_MODEL=gpt-4-vision-preview
231
+ SEE_API_KEY=sk-xxx
232
+ ```
233
+
234
+ ## 注意事项
235
+
236
+ 1. **VLM 可选**:默认不配置 VLM,`vision_analyze` 会跳过 VLM 调用,只返回像素扫描和 OCR 证据
237
+ 2. **低信息量拦截**:空白/简单图片会自动拦截,不调用 VLM(防止幻觉)
238
+ 3. **证据优先级**:像素扫描 > OCR > VLM(冲突时以实测为准)
239
+ 4. **性能考虑**:VLM 调用需要 2-5 秒,建议在需要语义理解时才启用
240
+ 5. **WebP 不支持**:需要先转换为 PNG/JPEG
241
+
242
+ ## 与其他工具的关系
243
+
244
+ - **image_scan**:像素级扫描,返回详细的颜色/结构证据
245
+ - **image_ocr**:文字识别,返回 OCR 文本
246
+ - **image_sample**:材质/纹理取样
247
+ - **vision_analyze**:统一入口,组合以上证据 + 可选 VLM
248
+
249
+ **必须遵守的工作流程**:
250
+ 1. **先用 `image_scan` 自己看**(像素扫描)→ 了解图片内容和复杂度
251
+ 2. 根据扫描结果判断:
252
+ - 简单图片(颜色单一、结构简单)→ 不需要 VLM,直接描述
253
+ - 需要文字 → 用 `image_ocr`
254
+ - 复杂场景(多人物、多物体、复杂背景)→ 用 `vision_analyze`(含 VLM)
255
+ - 需要材质细节 → 用 `image_sample`
256
+ 3. **不要直接调用 VLM**,先自己看再决定是否需要外部 API
package/src/guard.js ADDED
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Low-information image guard.
3
+ *
4
+ * Small local VLMs tend to hallucinate on blank / very simple images. Before
5
+ * sending an image to a VLM we can cheaply measure color diversity, dominant
6
+ * color coverage, edge density and brightness variance, then decide whether
7
+ * the image is too empty to be worth a VLM call.
8
+ *
9
+ * @module picturereader/guard
10
+ */
11
+
12
+ const SAMPLE = 64;
13
+
14
+ /**
15
+ * Calculate luminance (Rec.601).
16
+ * @param {number} r - red channel 0..255.
17
+ * @param {number} g - green channel 0..255.
18
+ * @param {number} b - blue channel 0..255.
19
+ * @returns {number} luminance 0..255.
20
+ */
21
+ function gray(r, g, b) {
22
+ return 0.299 * r + 0.587 * g + 0.114 * b;
23
+ }
24
+
25
+ /**
26
+ * Detect low-information images (blank, very simple, or unrendered).
27
+ *
28
+ * The guard checks four heuristics:
29
+ * 1. Color diversity: unique color buckets <= 8
30
+ * 2. Dominant color coverage: top color >= 90%
31
+ * 3. Dominant color with low edge density: top >= 60% AND edges < 8%
32
+ * 4. Low brightness variance: standard deviation < 8
33
+ *
34
+ * @param {Uint8ClampedArray|Buffer} rgba - RGBA pixel data.
35
+ * @param {number} width - image width in pixels.
36
+ * @param {number} height - image height in pixels.
37
+ * @returns {boolean} true when the image looks blank / very low-information.
38
+ */
39
+ export function isLowInformationImage(rgba, width, height) {
40
+ if (width <= 0 || height <= 0 || rgba.length < 4) return true;
41
+
42
+ // Downsample to SAMPLE x SAMPLE (nearest neighbor is fine for a guard).
43
+ const cells = [];
44
+ const cellSizeX = Math.max(1, Math.floor(width / SAMPLE));
45
+ const cellSizeY = Math.max(1, Math.floor(height / SAMPLE));
46
+ const gridW = Math.min(SAMPLE, width);
47
+ const gridH = Math.min(SAMPLE, height);
48
+
49
+ for (let gy = 0; gy < gridH; gy++) {
50
+ for (let gx = 0; gx < gridW; gx++) {
51
+ const px = Math.min(width - 1, gx * cellSizeX + Math.floor(cellSizeX / 2));
52
+ const py = Math.min(height - 1, gy * cellSizeY + Math.floor(cellSizeY / 2));
53
+ const i = (py * width + px) * 4;
54
+ cells.push([rgba[i], rgba[i + 1], rgba[i + 2], rgba[i + 3]]);
55
+ }
56
+ }
57
+
58
+ const buckets = new Map();
59
+ let total = 0;
60
+ let sum = 0;
61
+ let sumSq = 0;
62
+ let edgeCount = 0;
63
+ let edgePairs = 0;
64
+
65
+ for (let y = 0; y < gridH; y++) {
66
+ for (let x = 0; x < gridW; x++) {
67
+ const [r, g, b] = cells[y * gridW + x];
68
+ // Quantize to 3 bits per channel for bucketing
69
+ const key = ((r & 0xe0) << 10) | ((g & 0xe0) << 5) | (b & 0xe0);
70
+ buckets.set(key, (buckets.get(key) ?? 0) + 1);
71
+
72
+ const lum = gray(r, g, b);
73
+ sum += lum;
74
+ sumSq += lum * lum;
75
+ total++;
76
+
77
+ // Check horizontal edge (luminance difference > 20)
78
+ if (x + 1 < gridW) {
79
+ const [r2, g2, b2] = cells[y * gridW + x + 1];
80
+ const lum2 = gray(r2, g2, b2);
81
+ if (Math.abs(lum - lum2) > 20) edgeCount++;
82
+ edgePairs++;
83
+ }
84
+ }
85
+ }
86
+
87
+ const unique = buckets.size;
88
+ const top = Math.max(...buckets.values());
89
+ const topRatio = total > 0 ? top / total : 1;
90
+ const edgeRatio = edgePairs > 0 ? edgeCount / edgePairs : 0;
91
+ const mean = total > 0 ? sum / total : 0;
92
+ const variance = total > 0 ? Math.max(0, sumSq / total - mean * mean) : 0;
93
+ const stdDev = Math.sqrt(variance);
94
+
95
+ return (
96
+ unique <= 8 ||
97
+ topRatio >= 0.9 ||
98
+ (topRatio >= 0.6 && edgeRatio < 0.08) ||
99
+ stdDev < 8
100
+ );
101
+ }
package/src/index.js CHANGED
@@ -15,6 +15,7 @@
15
15
  */
16
16
 
17
17
  import { createImageScanTool, createImageOcrTool, createImageSampleTool } from './tool.js';
18
+ import { createVisionAnalyzeTool } from './vision-analyze.js';
18
19
 
19
20
  export const name = 'picturereader';
20
21
 
@@ -26,5 +27,6 @@ export function apply(ctx) {
26
27
  ctx.tools.register(createImageScanTool(ctx));
27
28
  ctx.tools.register(createImageOcrTool(ctx));
28
29
  ctx.tools.register(createImageSampleTool(ctx));
30
+ ctx.tools.register(createVisionAnalyzeTool(ctx));
29
31
  });
30
32
  }
@@ -0,0 +1,260 @@
1
+ /**
2
+ * vision_analyze — unified image understanding tool.
3
+ *
4
+ * Runs the full local vision pipeline:
5
+ * 1. decode + low-information guard
6
+ * 2. optional pixel scan (image_scan)
7
+ * 3. optional OCR (image_ocr)
8
+ * 4. optional local/remote VLM description
9
+ *
10
+ * All evidence is returned as text so a text-only model can reason about the
11
+ * image without trusting any single source blindly.
12
+ *
13
+ * Key features:
14
+ * - Smart API calling: simple images don't call external APIs
15
+ * - Cross-validation: main model verifies VLM results against pixel/OCR evidence
16
+ * - Multiple questions: support asking different questions about the same image
17
+ *
18
+ * @module picturereader/vision-analyze
19
+ */
20
+
21
+ import { extname } from 'node:path';
22
+ import { BYTE_CAP, MAX_PIXELS } from './tool.js';
23
+ import { isLowInformationImage } from './guard.js';
24
+ import { ensureServer, stopServer, sendVisionRequest, defaultVlmConfig, isVlmConfigured } from './vlm.js';
25
+
26
+ const CORE_URL = new URL('./core.js', import.meta.url).href;
27
+ let coreCache = { url: null, mtime: -1, module: null };
28
+
29
+ /**
30
+ * Load the latest core.js module with cache-busting.
31
+ * @returns {Promise<object>} the core module namespace.
32
+ */
33
+ async function importCore() {
34
+ const { stat } = await import('node:fs/promises');
35
+ const url = new URL(CORE_URL);
36
+ const info = await stat(url);
37
+ if (coreCache.module !== null && coreCache.url === CORE_URL && info.mtimeMs === coreCache.mtime) {
38
+ return coreCache.module;
39
+ }
40
+ const module = await import(`${url.href}?t=${info.mtimeMs}`);
41
+ coreCache = { url: CORE_URL, mtime: info.mtimeMs, module };
42
+ return module;
43
+ }
44
+
45
+ /**
46
+ * Parse a boolean argument with fallback.
47
+ * @param {any} value - the argument value.
48
+ * @param {boolean} fallback - default value.
49
+ * @returns {boolean} parsed boolean.
50
+ */
51
+ function boolArg(value, fallback = false) {
52
+ if (value === undefined || value === null) return fallback;
53
+ if (typeof value === 'boolean') return value;
54
+ return String(value) === 'true' || String(value) === '1';
55
+ }
56
+
57
+ /**
58
+ * Build the vision_analyze tool.
59
+ * @param {object} ctx - the Cordis context.
60
+ * @returns {object} the tool definition.
61
+ */
62
+ export function createVisionAnalyzeTool(ctx) {
63
+ return {
64
+ name: 'vision_analyze',
65
+ description: [
66
+ 'Unified image understanding: decode an image, run a low-information guard, optionally scan pixels, OCR text, and/or ask the VLM for a semantic description.',
67
+ 'Use this when you need one call to both verify what is in the image and get a natural-language interpretation.',
68
+ 'Returns evidence blocks: scan (pixel stats), ocr (real text), vlm (model description). If low-information guard triggers and allow_low_info is false, it will not call the VLM.',
69
+ 'Supported formats: PNG, JPEG, GIF (first frame), BMP. WebP is not supported yet.',
70
+ 'VLM is optional: if SEE_BASE is not configured, VLM calls are skipped automatically.',
71
+ 'Smart API calling: simple images (low color diversity, high dominant color coverage) skip VLM automatically.',
72
+ 'Multiple questions: call this tool multiple times with different prompts on the same image for comprehensive analysis.',
73
+ 'Cross-validation: main model should verify VLM results against pixel scan and OCR evidence.'
74
+ ].join(' '),
75
+ parameters: {
76
+ type: 'object',
77
+ additionalProperties: true,
78
+ properties: {
79
+ file_path: {
80
+ type: 'string',
81
+ description: 'Path to the image file (PNG/JPEG/GIF/BMP).'
82
+ },
83
+ prompt: {
84
+ type: 'string',
85
+ description: 'Question/instruction for the VLM, e.g. "Describe this UI" or "What is wrong with this map rendering?"'
86
+ },
87
+ include_scan: {
88
+ type: 'boolean',
89
+ description: 'Include pixel scan evidence (default true).'
90
+ },
91
+ include_ocr: {
92
+ type: 'boolean',
93
+ description: 'Include OCR text evidence (default false; set true when text matters).'
94
+ },
95
+ ocr_engine: {
96
+ type: 'string',
97
+ enum: ['windows', 'paddle'],
98
+ description: 'OCR engine: windows (default) or paddle (better for glowing/curved/game text).'
99
+ },
100
+ include_vlm: {
101
+ type: 'boolean',
102
+ description: 'Include VLM description (default true, but skipped if SEE_BASE not configured).'
103
+ },
104
+ allow_low_info: {
105
+ type: 'boolean',
106
+ description: 'Skip the low-information guard and force VLM even on blank/simple images (default false).'
107
+ },
108
+ stop_after: {
109
+ type: 'boolean',
110
+ description: 'Stop the local llama-server after this call if this plugin started it (default false).'
111
+ }
112
+ },
113
+ required: ['file_path']
114
+ },
115
+ output: {
116
+ schema: {
117
+ type: 'object',
118
+ additionalProperties: true,
119
+ properties: {
120
+ path: { type: 'string' },
121
+ lowInformation: { type: 'boolean' },
122
+ message: { type: 'string' },
123
+ scan: { type: 'string' },
124
+ ocr: { type: 'string' },
125
+ vlm: { type: 'string' },
126
+ combined: { type: 'string' }
127
+ },
128
+ required: ['path']
129
+ },
130
+ render: (_args, value) => {
131
+ const text = value.combined ?? value.message ?? JSON.stringify(value);
132
+ return [{ type: 'text', text }];
133
+ }
134
+ },
135
+ isConcurrencySafe: () => true,
136
+ async execute(args, exec) {
137
+ if (exec.signal?.aborted) throw new Error('vision_analyze: cancelled');
138
+ const filePath = String(args.file_path ?? '').trim();
139
+ if (!filePath) throw new Error('vision_analyze: file_path must be a non-empty string');
140
+
141
+ const ext = extname(filePath).toLowerCase();
142
+ const core = await importCore();
143
+ if (core.UNSUPPORTED_EXTENSIONS.has(ext)) {
144
+ throw new Error('vision_analyze: WebP is not supported yet — convert to PNG or JPEG first');
145
+ }
146
+ if (!core.IMAGE_EXTENSIONS.has(ext)) {
147
+ throw new Error(`vision_analyze: unsupported image type "${ext}" (supported: PNG, JPEG, GIF, BMP)`);
148
+ }
149
+
150
+ const cwd = exec.agent?.session?.header?.cwd;
151
+ const target = await ctx.fs.resolve(filePath, {
152
+ ...(cwd !== undefined ? { cwd } : {}),
153
+ signal: exec.signal
154
+ });
155
+ const info = await ctx.fs.stat(target, exec.signal);
156
+ if (!info) {
157
+ throw new Error(`vision_analyze: cannot read "${target.displayPath}": file not found`);
158
+ }
159
+ if (info.type !== 'file') {
160
+ throw new Error(`vision_analyze: cannot read "${target.displayPath}": not a regular file`);
161
+ }
162
+ const data = await ctx.fs.readBytes(target, exec.signal, BYTE_CAP);
163
+ const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
164
+ const image = core.decodeImage(buf, ext);
165
+ if (image.width * image.height > MAX_PIXELS) {
166
+ throw new Error(
167
+ `vision_analyze: ${image.width}x${image.height} exceeds the ${MAX_PIXELS}-pixel decode limit — downscale or crop first`
168
+ );
169
+ }
170
+
171
+ const includeScan = args.include_scan === undefined ? true : boolArg(args.include_scan, true);
172
+ const includeOcr = args.include_ocr === undefined ? false : boolArg(args.include_ocr, false);
173
+ const includeVlm = args.include_vlm === undefined ? true : boolArg(args.include_vlm, true);
174
+ const allowLowInfo = boolArg(args.allow_low_info, false);
175
+ const stopAfter = boolArg(args.stop_after, false);
176
+ const prompt = args.prompt ?? 'Describe this image in detail.';
177
+
178
+ // Check if VLM is configured
179
+ const vlmAvailable = isVlmConfigured();
180
+ const shouldCallVlm = includeVlm && vlmAvailable;
181
+
182
+ const lowInfo = isLowInformationImage(image.data, image.width, image.height);
183
+ const blocks = [];
184
+ let ocrText = '';
185
+ let scanText = '';
186
+ let vlmText = '';
187
+
188
+ if (lowInfo && !allowLowInfo) {
189
+ const message =
190
+ '[vision_analyze] 低信息量拦截:图片空白或内容极少,为避免 VLM 幻觉,未调用 VLM。' +
191
+ '请检查截图是否空白/未渲染/窗口在屏幕外;如确需识别请设置 allow_low_info=true。';
192
+ ctx.emit('fs/observed', target, { kind: 'present', version: info.version }, exec);
193
+ return { path: target.displayPath, lowInformation: true, message, combined: message };
194
+ }
195
+
196
+ if (includeScan) {
197
+ const analysis = core.analyzeImage(image.data, image.width, image.height, {
198
+ size: 32,
199
+ mode: 'auto',
200
+ region: undefined,
201
+ palette: 'auto'
202
+ });
203
+ scanText = core.renderImageScan({
204
+ path: target.displayPath,
205
+ width: image.width,
206
+ height: image.height,
207
+ ...analysis
208
+ });
209
+ blocks.push(`[scan]\n${scanText}`);
210
+ }
211
+
212
+ if (includeOcr) {
213
+ const engine = args.ocr_engine ?? 'windows';
214
+ const ocr = await core.ocrImage(buf, ext, { engine });
215
+ ocrText = core.renderOcr({
216
+ path: target.displayPath,
217
+ width: ocr.width,
218
+ height: ocr.height,
219
+ region: 'full',
220
+ engine: ocr.engine,
221
+ lines: ocr.lines
222
+ });
223
+ blocks.push(`[ocr]\n${ocrText}`);
224
+ }
225
+
226
+ if (shouldCallVlm) {
227
+ const config = defaultVlmConfig();
228
+ let startedByUs = false;
229
+ try {
230
+ const child = await ensureServer(config);
231
+ startedByUs = child !== null;
232
+ const base64 = buf.toString('base64');
233
+ const mime = ext === '.jpg' || ext === '.jpeg' ? 'image/jpeg' : ext === '.png' ? 'image/png' : ext === '.gif' ? 'image/gif' : 'image/bmp';
234
+ const safePrompt =
235
+ prompt +
236
+ '\n\n重要:只描述图中明确可见的内容。如果图中没有明显物体/文字/界面元素,请直接回答:画面空白或内容极少。不要推测、不要脑补不存在的角色/场景/文字。';
237
+ vlmText = await sendVisionRequest(config, [{ mime, base64 }], safePrompt);
238
+ blocks.push(`[vlm]\n${vlmText}`);
239
+ } finally {
240
+ if (stopAfter && startedByUs) {
241
+ await stopServer();
242
+ }
243
+ }
244
+ } else if (includeVlm && !vlmAvailable) {
245
+ blocks.push('[vlm] VLM 未配置(SEE_BASE 环境变量为空)');
246
+ }
247
+
248
+ ctx.emit('fs/observed', target, { kind: 'present', version: info.version }, exec);
249
+ const combined = blocks.join('\n\n---\n\n');
250
+ return {
251
+ path: target.displayPath,
252
+ lowInformation: false,
253
+ ...(scanText ? { scan: scanText } : {}),
254
+ ...(ocrText ? { ocr: ocrText } : {}),
255
+ ...(vlmText ? { vlm: vlmText } : {}),
256
+ combined
257
+ };
258
+ }
259
+ };
260
+ }