picturereader 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.
- package/LICENSE +21 -21
- package/README.md +76 -43
- package/cordis.patch.yml +4 -4
- package/package.json +6 -3
- package/scripts/preview.mjs +40 -40
- package/scripts/setup-ocr.mjs +96 -96
- package/skills/image-reading.md +119 -96
- package/skills/vision-analyze.md +256 -0
- package/src/guard.js +101 -0
- package/src/index.js +32 -30
- package/src/tool.js +548 -548
- package/src/vision-analyze.js +260 -0
- package/src/vlm.js +269 -0
|
@@ -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
|
+
}
|
package/src/vlm.js
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VLM (Vision Language Model) bridge for picturereader.
|
|
3
|
+
*
|
|
4
|
+
* Talks to any OpenAI-compatible chat-completions endpoint that accepts
|
|
5
|
+
* image_url data URIs. When the endpoint is a managed local llama-server
|
|
6
|
+
* and it is not healthy, this module can auto-start it with the configured
|
|
7
|
+
* multimodal model and (optionally) stop it after the request.
|
|
8
|
+
*
|
|
9
|
+
* @module picturereader/vlm
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { spawn } from 'node:child_process';
|
|
13
|
+
import { stat } from 'node:fs/promises';
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Configuration (all via environment variables, defaults are empty/disabled)
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
/** OpenAI-compatible VLM endpoint (empty = VLM disabled). */
|
|
20
|
+
export const DEFAULT_BASE = process.env.SEE_BASE ?? '';
|
|
21
|
+
/** VLM model name. */
|
|
22
|
+
export const DEFAULT_MODEL = process.env.SEE_MODEL ?? '';
|
|
23
|
+
/** Local llama-server executable path. */
|
|
24
|
+
export const DEFAULT_SERVER_EXE = process.env.SEE_SERVER_EXE ?? '';
|
|
25
|
+
/** Local model GGUF path. */
|
|
26
|
+
export const DEFAULT_SERVER_MODEL = process.env.SEE_SERVER_MODEL ?? '';
|
|
27
|
+
/** Vision projector path. */
|
|
28
|
+
export const DEFAULT_SERVER_MMPROJ = process.env.SEE_SERVER_MMPROJ ?? '';
|
|
29
|
+
/** Local server port. */
|
|
30
|
+
export const DEFAULT_PORT = Number(process.env.SEE_SERVER_PORT ?? 8080);
|
|
31
|
+
/** GPU layers for local server. */
|
|
32
|
+
export const DEFAULT_NGL = process.env.SEE_SERVER_NGL ?? '20';
|
|
33
|
+
/** Context size for local server. */
|
|
34
|
+
export const DEFAULT_CTX = Number(process.env.SEE_SERVER_CTX ?? 16384);
|
|
35
|
+
/** API key for remote endpoints. */
|
|
36
|
+
export const DEFAULT_API_KEY = process.env.SEE_API_KEY ?? '';
|
|
37
|
+
|
|
38
|
+
let serverStartPromise = null;
|
|
39
|
+
let serverChild = null;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Check if VLM is configured (has a base URL).
|
|
43
|
+
* @returns {boolean} true when VLM endpoint is configured.
|
|
44
|
+
*/
|
|
45
|
+
export function isVlmConfigured() {
|
|
46
|
+
return DEFAULT_BASE.length > 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Build health check URL from base URL.
|
|
51
|
+
* @param {string} baseURL - the VLM endpoint base URL.
|
|
52
|
+
* @returns {string} health check URL.
|
|
53
|
+
*/
|
|
54
|
+
export function healthUrlOf(baseURL) {
|
|
55
|
+
return baseURL.replace(/\/v1$/, '').replace(/\/+$/, '') + '/health';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Probe VLM endpoint health.
|
|
60
|
+
* @param {string} baseURL - the VLM endpoint base URL.
|
|
61
|
+
* @param {number} timeoutMs - timeout in milliseconds.
|
|
62
|
+
* @returns {Promise<boolean>} true when healthy.
|
|
63
|
+
*/
|
|
64
|
+
export async function probe(baseURL, timeoutMs = 3000) {
|
|
65
|
+
try {
|
|
66
|
+
const res = await fetch(healthUrlOf(baseURL), { signal: AbortSignal.timeout(timeoutMs) });
|
|
67
|
+
return res.ok;
|
|
68
|
+
} catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Check if the endpoint is a managed local server.
|
|
75
|
+
* @param {string} baseURL - the VLM endpoint base URL.
|
|
76
|
+
* @param {number} port - the expected port.
|
|
77
|
+
* @returns {boolean} true when it's a managed local endpoint.
|
|
78
|
+
*/
|
|
79
|
+
function isManagedEndpoint(baseURL, port) {
|
|
80
|
+
const u = baseURL.replace(/\/v1$/, '').replace(/\/+$/, '');
|
|
81
|
+
const m = u.match(/^http:\/\/(127\.0\.0\.1|localhost):(\d+)$/);
|
|
82
|
+
return m !== null && Number(m[2]) === port;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function sleep(ms) {
|
|
86
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Build llama-server command arguments.
|
|
91
|
+
* @param {object} config - VLM configuration.
|
|
92
|
+
* @returns {string[]} command arguments.
|
|
93
|
+
*/
|
|
94
|
+
export function buildServerArgs(config) {
|
|
95
|
+
return [
|
|
96
|
+
'-m', config.serverModel,
|
|
97
|
+
'--mmproj', config.serverMmproj,
|
|
98
|
+
'-ngl', String(config.ngl),
|
|
99
|
+
'--ctx-size', String(config.ctxSize),
|
|
100
|
+
'--parallel', '1',
|
|
101
|
+
'--load-mode', 'none',
|
|
102
|
+
'--threads', '16',
|
|
103
|
+
'--threads-batch', '32',
|
|
104
|
+
'--batch-size', '2048',
|
|
105
|
+
'--ubatch-size', '512',
|
|
106
|
+
'--cache-type-k', 'q8_0',
|
|
107
|
+
'--cache-type-v', 'q8_0',
|
|
108
|
+
'--flash-attn', 'on',
|
|
109
|
+
'--fit', 'off',
|
|
110
|
+
'--split-mode', 'none',
|
|
111
|
+
'--main-gpu', '0',
|
|
112
|
+
'--prio', '1',
|
|
113
|
+
'--jinja',
|
|
114
|
+
'--reasoning', 'on',
|
|
115
|
+
'--image-min-tokens', '1024',
|
|
116
|
+
'--alias', config.model,
|
|
117
|
+
'--host', '127.0.0.1',
|
|
118
|
+
'--port', String(config.serverPort),
|
|
119
|
+
];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Start local llama-server.
|
|
124
|
+
* @param {object} config - VLM configuration.
|
|
125
|
+
* @returns {Promise<ChildProcess>} the server process.
|
|
126
|
+
*/
|
|
127
|
+
async function startLocalServer(config) {
|
|
128
|
+
for (const p of [config.serverExe, config.serverModel, config.serverMmproj]) {
|
|
129
|
+
try {
|
|
130
|
+
await stat(p);
|
|
131
|
+
} catch {
|
|
132
|
+
throw new Error(`picturereader: local server file not found: ${p}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
process.env.GGML_CUDA_NO_PINNED = '1';
|
|
137
|
+
const child = spawn(config.serverExe, buildServerArgs(config), {
|
|
138
|
+
detached: true,
|
|
139
|
+
stdio: 'ignore',
|
|
140
|
+
windowsHide: true,
|
|
141
|
+
});
|
|
142
|
+
child.unref();
|
|
143
|
+
serverChild = child;
|
|
144
|
+
|
|
145
|
+
const deadline = Date.now() + config.healthTimeoutMs;
|
|
146
|
+
while (Date.now() < deadline) {
|
|
147
|
+
if (await probe(config.baseURL, 2000)) return child;
|
|
148
|
+
if (child.exitCode !== null || child.signalCode !== null) break;
|
|
149
|
+
await sleep(1000);
|
|
150
|
+
}
|
|
151
|
+
try {
|
|
152
|
+
child.kill('SIGKILL');
|
|
153
|
+
} catch {}
|
|
154
|
+
throw new Error(
|
|
155
|
+
`picturereader: local llama-server failed to become healthy at ${healthUrlOf(config.baseURL)} within ${config.healthTimeoutMs}ms`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Ensure local llama-server is running (auto-start if needed).
|
|
161
|
+
* @param {object} config - VLM configuration.
|
|
162
|
+
* @returns {Promise<ChildProcess|null>} the server process, or null if not managed.
|
|
163
|
+
*/
|
|
164
|
+
export async function ensureServer(config) {
|
|
165
|
+
if (!config.autoStart || !isManagedEndpoint(config.baseURL, config.serverPort)) {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
if (await probe(config.baseURL, 3000)) {
|
|
169
|
+
serverStartPromise = null;
|
|
170
|
+
return serverChild;
|
|
171
|
+
}
|
|
172
|
+
if (serverStartPromise !== null) {
|
|
173
|
+
try {
|
|
174
|
+
await serverStartPromise;
|
|
175
|
+
} catch {
|
|
176
|
+
serverStartPromise = null;
|
|
177
|
+
}
|
|
178
|
+
if (await probe(config.baseURL, 3000)) return serverChild;
|
|
179
|
+
}
|
|
180
|
+
serverStartPromise = startLocalServer(config).finally(() => {
|
|
181
|
+
serverStartPromise = null;
|
|
182
|
+
});
|
|
183
|
+
await serverStartPromise;
|
|
184
|
+
return serverChild;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Stop local llama-server if running.
|
|
189
|
+
*/
|
|
190
|
+
export async function stopServer() {
|
|
191
|
+
if (serverChild && serverChild.exitCode === null && serverChild.signalCode === null) {
|
|
192
|
+
try {
|
|
193
|
+
serverChild.kill('SIGKILL');
|
|
194
|
+
} catch {}
|
|
195
|
+
}
|
|
196
|
+
serverChild = null;
|
|
197
|
+
serverStartPromise = null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Send one image-only request to the VLM endpoint.
|
|
202
|
+
* @param {object} config - VLM configuration.
|
|
203
|
+
* @param {Array<{mime: string, base64: string}>} images - images to send.
|
|
204
|
+
* @param {string} prompt - the prompt text.
|
|
205
|
+
* @returns {Promise<string>} VLM response text.
|
|
206
|
+
*/
|
|
207
|
+
export async function sendVisionRequest(config, images, prompt) {
|
|
208
|
+
const content = [{ type: 'text', text: prompt }];
|
|
209
|
+
for (const img of images) {
|
|
210
|
+
content.push({ type: 'image_url', image_url: { url: `data:${img.mime};base64,${img.base64}` } });
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const body = {
|
|
214
|
+
model: config.model,
|
|
215
|
+
stream: false,
|
|
216
|
+
messages: [{ role: 'user', content }],
|
|
217
|
+
max_tokens: config.maxTokens,
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const headers = {
|
|
221
|
+
'content-type': 'application/json',
|
|
222
|
+
};
|
|
223
|
+
if (config.apiKey) {
|
|
224
|
+
headers.authorization = `Bearer ${config.apiKey}`;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const res = await fetch(`${config.baseURL}/chat/completions`, {
|
|
228
|
+
method: 'POST',
|
|
229
|
+
headers,
|
|
230
|
+
body: JSON.stringify(body),
|
|
231
|
+
signal: AbortSignal.timeout(config.requestTimeoutMs),
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
if (!res.ok) {
|
|
235
|
+
const text = await res.text().catch(() => '');
|
|
236
|
+
throw new Error(`picturereader: VLM HTTP ${res.status}: ${text.slice(0, 300)}`);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const json = await res.json();
|
|
240
|
+
const contentText = json?.choices?.[0]?.message?.content;
|
|
241
|
+
if (typeof contentText !== 'string' || contentText.length === 0) {
|
|
242
|
+
throw new Error('picturereader: VLM returned empty content');
|
|
243
|
+
}
|
|
244
|
+
return contentText;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Build default VLM configuration.
|
|
249
|
+
* @param {object} overrides - configuration overrides.
|
|
250
|
+
* @returns {object} VLM configuration.
|
|
251
|
+
*/
|
|
252
|
+
export function defaultVlmConfig(overrides = {}) {
|
|
253
|
+
return {
|
|
254
|
+
baseURL: DEFAULT_BASE,
|
|
255
|
+
apiKey: DEFAULT_API_KEY,
|
|
256
|
+
model: DEFAULT_MODEL,
|
|
257
|
+
serverExe: DEFAULT_SERVER_EXE,
|
|
258
|
+
serverModel: DEFAULT_SERVER_MODEL,
|
|
259
|
+
serverMmproj: DEFAULT_SERVER_MMPROJ,
|
|
260
|
+
serverPort: DEFAULT_PORT,
|
|
261
|
+
ngl: DEFAULT_NGL,
|
|
262
|
+
ctxSize: DEFAULT_CTX,
|
|
263
|
+
autoStart: true,
|
|
264
|
+
healthTimeoutMs: 120_000,
|
|
265
|
+
requestTimeoutMs: 300_000,
|
|
266
|
+
maxTokens: 8192,
|
|
267
|
+
...overrides,
|
|
268
|
+
};
|
|
269
|
+
}
|