picturereader-zcode 1.0.3 → 2.0.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/README.md +82 -68
- package/mcp/server.js +175 -6
- package/package.json +6 -3
- package/skills/image-reading.md +23 -0
- package/skills/vision-analyze.md +256 -0
- package/src/guard.js +101 -0
- package/src/index.js +2 -0
- package/src/vision-analyze.js +260 -0
- package/src/vlm.js +269 -0
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
|
+
}
|