@ran-sh/dsh-crew 1.5.1 → 1.6.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.
@@ -1,686 +0,0 @@
1
- // Multimodal bridge: gives the (text-only) DeepSeek model eyes and a brush by
2
- // borrowing the vision / image-generation abilities of locally installed
3
- // subscription CLIs (claude -p, codex exec, agy). Tools are plain
4
- // ToolDefinition-shaped objects (see @deepseek-ai/dsh-tools defineTool output)
5
- // so the hub keeps its zero-@deepseek-imports realm discipline.
6
-
7
- import { spawn, execFileSync } from 'node:child_process';
8
- import { createHash } from 'node:crypto';
9
- import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync, readdirSync } from 'node:fs';
10
- import { join, isAbsolute, dirname } from 'node:path';
11
- import { homedir } from 'node:os';
12
- import { tr, setLang } from './i18n.mjs';
13
- import { withGeneratedImageOutput, downloadImageBytes } from './image-output.mjs';
14
-
15
- const CONFIG_DIR = join(homedir(), '.config', 'dsh-crew');
16
- const CACHE_FILE = join(CONFIG_DIR, 'vision-cache.json');
17
- const CACHE_CAP = 200;
18
-
19
- // A subscription CLI is often installed but not on PATH — Codex Desktop keeps
20
- // its binary under a versioned directory, and a GUI-launched Hub does not
21
- // inherit the shell's PATH. Spawning the bare name then fails with a bare
22
- // `spawn <name> ENOENT` even though the CLI is installed and working. Resolve
23
- // once per name, the same way the workspace code resolves git: ask the platform
24
- // locator first, then fall back to the known install locations.
25
- //
26
- // Callers that spawn a bare name pass it through `resolveCli` first. A name that
27
- // resolves to nothing is returned unchanged so the spawn still reports its own
28
- // ENOENT, and the error below says what to do about it.
29
- const CLI_FALLBACKS = [
30
- (name, env) => env.LOCALAPPDATA && join(env.LOCALAPPDATA, 'OpenAI', 'Codex', 'bin'),
31
- ];
32
- const resolvedCli = new Map();
33
-
34
- function expandVersionedDirs(dir, name, exists) {
35
- // <dir>/<hash>/<name>.exe — the layout Codex Desktop uses.
36
- let entries = [];
37
- try { entries = readdirSync(dir); } catch { return null; }
38
- for (const entry of entries.sort().reverse()) {
39
- for (const file of [`${name}.exe`, name]) {
40
- const candidate = join(dir, entry, file);
41
- if (exists(candidate)) return candidate;
42
- }
43
- }
44
- return null;
45
- }
46
-
47
- export function resolveCli(name, { env = process.env, exists = existsSync, locate = null } = {}) {
48
- if (resolvedCli.has(name)) return resolvedCli.get(name);
49
- let found = null;
50
- // 1) The platform's own locator, which is what a PATH lookup would use.
51
- try {
52
- const locator = locate ?? (process.platform === 'win32' ? 'where.exe' : 'which');
53
- // where.exe prints a localised "not found" notice on stderr; capture it so a
54
- // miss stays quiet and only the fallback path speaks. It can also list
55
- // several hits for one name — npm installs an extensionless shell shim next
56
- // to a .cmd — so prefer one this platform can actually execute.
57
- const out = execFileSync(locator, [name], { encoding: 'utf8', timeout: 8000, stdio: ['ignore', 'pipe', 'ignore'] });
58
- const candidates = String(out).split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
59
- found = pickExecutable(candidates);
60
- } catch { /* not on PATH; try the known locations */ }
61
- // 2) Known install locations.
62
- if (!found && process.platform === 'win32') {
63
- for (const dir of CLI_FALLBACKS.map((f) => f(name, env)).filter(Boolean)) {
64
- found = expandVersionedDirs(dir, name, exists) ?? (exists(join(dir, `${name}.exe`)) ? join(dir, `${name}.exe`) : null);
65
- if (found) break;
66
- }
67
- }
68
- const value = found ?? name;
69
- resolvedCli.set(name, value);
70
- return value;
71
- }
72
-
73
- // Rank locator hits by what spawn() can launch on this platform. On Windows an
74
- // extensionless npm shim is a shell script, and since Node 18.20/20.12 spawning a
75
- // .bat/.cmd is also refused without a shell — so a plain .exe is the only
76
- // unambiguous choice, and a batch file is launched through cmd.exe in run().
77
- function pickExecutable(candidates) {
78
- if (process.platform !== 'win32') return candidates[0] ?? null;
79
- const rank = (p) => (/\.exe$/i.test(p) ? 0 : /\.(cmd|bat)$/i.test(p) ? 1 : 2);
80
- return [...candidates].sort((a, b) => rank(a) - rank(b))[0] ?? null;
81
- }
82
-
83
- function run(cmd, args, { timeoutMs, cwd } = {}) {
84
- // spawn with stdin ignored: several agent CLIs (codex in particular) behave
85
- // differently when handed an open interactive stdin pipe.
86
- return new Promise((resolve, reject) => {
87
- const resolved = resolveCli(cmd);
88
- // A Windows batch shim cannot be spawned directly; go through cmd.exe with
89
- // argv (never a command string) so no argument is re-parsed by a shell.
90
- const viaCmd = process.platform === 'win32' && /\.(cmd|bat)$/i.test(resolved);
91
- const executable = viaCmd ? (process.env.ComSpec || 'cmd.exe') : resolved;
92
- const argv = viaCmd ? ['/d', '/s', '/c', resolved, ...args] : args;
93
- const child = spawn(executable, argv, { cwd, env: process.env, stdio: ['ignore', 'pipe', 'pipe'] });
94
- let out = '', err = '';
95
- const timer = setTimeout(() => { child.kill('SIGKILL'); }, timeoutMs ?? 180_000);
96
- child.stdout.on('data', (c) => { out += c; });
97
- child.stderr.on('data', (c) => { err += c; });
98
- child.on('error', (e) => {
99
- clearTimeout(timer);
100
- // Say which CLI and what to do, rather than a bare ENOENT.
101
- const hint = e?.code === 'ENOENT'
102
- ? `\nThe "${cmd}" CLI was not found (checked PATH and the known install locations). Install it, put it on PATH, or pick a different provider in Settings → DSH Crew → Vision.`
103
- : '';
104
- reject(new Error(`${cmd} spawn failed: ${e.message}${hint}`));
105
- });
106
- child.on('close', (code, signal) => {
107
- clearTimeout(timer);
108
- if (signal) reject(new Error(`${cmd} killed by ${signal} (timeout ${timeoutMs ?? 180_000}ms)\n${err.slice(-400)}`));
109
- else if (code !== 0) reject(new Error(`${cmd} exited ${code}\n${(err || out).slice(-400)}`));
110
- else resolve(out + (out === '' ? err : ''));
111
- });
112
- });
113
- }
114
-
115
- function cacheKey(parts) { return createHash('sha256').update(parts.join('\0')).digest('hex'); }
116
- function readCache() { try { return JSON.parse(readFileSync(CACHE_FILE, 'utf8')); } catch { return {}; } }
117
- function writeCache(cache) {
118
- try {
119
- const keys = Object.keys(cache);
120
- if (keys.length > CACHE_CAP) for (const k of keys.slice(0, keys.length - CACHE_CAP)) delete cache[k];
121
- mkdirSync(CONFIG_DIR, { recursive: true });
122
- writeFileSync(CACHE_FILE, JSON.stringify(cache));
123
- } catch {}
124
- }
125
-
126
- const RESULT_SCHEMA = {
127
- type: 'object',
128
- properties: { ok: { type: 'boolean' }, tool: { type: 'string' }, text: { type: 'string' } },
129
- required: ['ok', 'tool', 'text'],
130
- additionalProperties: false,
131
- };
132
-
133
- async function describeViaClaude(model, imagePath, question) {
134
- const prompt = `Use your Read tool to view the image file at ${imagePath}, then answer concisely: ${question}. Reply with the answer only — no preamble.`;
135
- const { dirname } = await import('node:path');
136
- // NOTE: --allowedTools is variadic and would swallow a trailing prompt, so
137
- // the prompt rides directly on -p.
138
- return (await run('claude', ['-p', prompt, '--model', model, '--allowedTools', 'Read'], {
139
- timeoutMs: 180_000, cwd: dirname(imagePath),
140
- })).trim();
141
- }
142
-
143
- async function describeViaCodex(imagePath, question, model) {
144
- const prompt = `Look at the attached image and answer concisely: ${question}. Reply with the answer only.`;
145
- const modelArgs = model && model !== 'default' ? ['-c', `model="${model}"`] : [];
146
- // `-i/--image` is variadic: `-i file.png "prompt"` makes the CLI read the
147
- // prompt as a second image and then fail with "no prompt provided". The
148
- // prompt goes first and the image is bound with `=` so it takes one value.
149
- const out = await run('codex', ['exec', '--skip-git-repo-check', ...modelArgs, prompt, `--image=${imagePath}`], { timeoutMs: 180_000 });
150
- const lines = out.trim().split('\n').filter((l) => l.trim() !== '' && !l.startsWith('[20') && !/^(tokens used|codex$|user$|mcp:)/.test(l.trim()));
151
- return lines.slice(-12).join('\n').trim();
152
- }
153
-
154
- async function describeViaGrok(imagePath, question, model) {
155
- const { dirname, basename } = await import('node:path');
156
- const prompt = `View the image file at ./${basename(imagePath)} and answer concisely: ${question}. Reply with the answer only — no preamble.`;
157
- const modelArgs = model && model !== 'default' ? ['-m', model] : [];
158
- const out = await run('grok', [...modelArgs, '-p', prompt], { timeoutMs: 240_000, cwd: dirname(imagePath) });
159
- return out.trim();
160
- }
161
-
162
- async function describeViaAgy(imagePath, question, model) {
163
- const prompt = `View the image file at ${imagePath} and answer concisely: ${question}. Reply with the answer only — no preamble.`;
164
- const modelArgs = model && model !== 'default' ? ['--model', model] : [];
165
- try {
166
- return (await run('agy', [...modelArgs, '-p', prompt], { timeoutMs: 240_000 })).trim();
167
- } catch (err) {
168
- const msg = String(err?.message ?? err);
169
- if (/permission/i.test(msg)) {
170
- throw new Error(tr(
171
- 'agy 需要 read_file 权限:在 Antigravity 的 settings.json 的 permissions.allow 里加入 read_file 规则,或改用其他视觉 provider。原始错误: ',
172
- 'agy needs read_file permission: add a read_file rule to permissions.allow in Antigravity\'s settings.json, or pick another vision provider. Original error: ',
173
- ) + msg.slice(0, 160));
174
- }
175
- throw err;
176
- }
177
- }
178
-
179
- /** Shell-quote a value for interpolation into a custom command template. */
180
- function shq(s) { return "'" + String(s).replace(/'/g, "'\\''") + "'"; }
181
-
182
- const BUILTIN_VISION = ['claude-code', 'codex', 'grok', 'agy'];
183
- const BUILTIN_IMAGEGEN = ['codex', 'agy', 'grok'];
184
-
185
- /** Look up a user-defined provider entry by id. */
186
- export function findCustomProvider(config, id) {
187
- return (config?.custom_providers ?? []).find((p) => p && p.id === id);
188
- }
189
-
190
- /** 'api' (OpenAI-compatible endpoint) or 'cli' (command template). Entries
191
- * created before the type field existed carry commands → cli. */
192
- function entryType(entry) {
193
- return entry.type ?? ((entry.vision_command || entry.imagegen_command) ? 'cli' : 'api');
194
- }
195
-
196
- function extMime(p) {
197
- const e = p.toLowerCase().split('.').pop();
198
- return e === 'jpg' || e === 'jpeg' ? 'image/jpeg' : e === 'webp' ? 'image/webp' : e === 'gif' ? 'image/gif' : 'image/png';
199
- }
200
-
201
- async function fetchJson(url, { apiKey, body, timeoutMs }) {
202
- const ctl = new AbortController();
203
- const t = setTimeout(() => ctl.abort(), timeoutMs);
204
- try {
205
- const res = await fetch(url, {
206
- method: 'POST',
207
- headers: { 'content-type': 'application/json', ...(apiKey ? { authorization: `Bearer ${apiKey}` } : {}) },
208
- body: JSON.stringify(body),
209
- signal: ctl.signal,
210
- });
211
- const text = await res.text();
212
- if (!res.ok) throw new Error(`${url} → HTTP ${res.status}: ${text.slice(0, 300)}`);
213
- return JSON.parse(text);
214
- } catch (err) {
215
- if (err?.name === 'AbortError') throw new Error(tr(`${url} 请求超时 (${timeoutMs}ms)`, `${url} timed out (${timeoutMs}ms)`));
216
- throw err;
217
- } finally { clearTimeout(t); }
218
- }
219
-
220
- function apiBase(entry) {
221
- const base = String(entry.base_url ?? '').trim().replace(/\/+$/, '');
222
- if (!base) throw new Error(tr(`自定义 provider "${entry.name ?? entry.id}" 未配置 Base URL`, `Custom provider "${entry.name ?? entry.id}" has no Base URL`));
223
- return base;
224
- }
225
-
226
- async function describeViaApi(entry, imagePath, question, model) {
227
- const base = apiBase(entry);
228
- const m = model && model !== 'default' ? model : entry.models?.[0];
229
- if (!m) throw new Error(tr(`自定义 provider "${entry.name ?? entry.id}" 未配置模型列表,无法确定视觉模型`, `Custom provider "${entry.name ?? entry.id}" has no models configured`));
230
- const b64 = readFileSync(imagePath).toString('base64');
231
- const data = await fetchJson(`${base}/chat/completions`, {
232
- apiKey: entry.api_key,
233
- timeoutMs: 240_000,
234
- body: {
235
- model: m,
236
- messages: [{
237
- role: 'user',
238
- content: [
239
- { type: 'text', text: `${question}\nReply with the answer only — no preamble.` },
240
- { type: 'image_url', image_url: { url: `data:${extMime(imagePath)};base64,${b64}` } },
241
- ],
242
- }],
243
- },
244
- });
245
- const text = data?.choices?.[0]?.message?.content;
246
- if (typeof text !== 'string' || text.trim() === '') throw new Error(tr(`${entry.name ?? entry.id} API 返回为空`, `${entry.name ?? entry.id} API returned an empty answer`));
247
- return text.trim();
248
- }
249
-
250
- async function generateViaApi(entry, prompt, outputPath, size) {
251
- const base = apiBase(entry);
252
- // No fallback to the vision model list: a VL model would accept the call and
253
- // fail late (or bill for nothing). Image generation is opt-in per provider.
254
- const m = String(entry.imagegen_model ?? '').trim();
255
- if (!m) throw new Error(tr(
256
- `自定义 provider "${entry.name ?? entry.id}" 未配置生图模型(在设置 → DSH Crew → 自定义 Provider 的"生图模型"里填写)`,
257
- `Custom provider "${entry.name ?? entry.id}" has no image-gen model (fill in "Image-gen model" under Settings → DSH Crew → Custom providers)`));
258
- const body = { model: m, prompt, n: 1 };
259
- if (size && /^\d+x\d+$/.test(size)) body.size = size;
260
- const data = await fetchJson(`${base}/images/generations`, { apiKey: entry.api_key, timeoutMs: 480_000, body });
261
- const item = data?.data?.[0];
262
- let buf;
263
- if (item?.b64_json) buf = Buffer.from(item.b64_json, 'base64');
264
- else if (item?.url) {
265
- buf = await downloadImageBytes(item.url);
266
- } else {
267
- throw new Error(tr(
268
- `${entry.name ?? entry.id} API 未返回图像数据: ${JSON.stringify(data).slice(0, 200)}`,
269
- `${entry.name ?? entry.id} API returned no image data: ${JSON.stringify(data).slice(0, 200)}`));
270
- }
271
- writeFileSync(outputPath, buf);
272
- return outputPath;
273
- }
274
-
275
-
276
- // The custom CLI templates are POSIX shell snippets: shq() quotes POSIX-style
277
- // and the readiness probe uses `command -v`. Windows has no /bin/bash, and
278
- // cmd.exe would silently misquote the POSIX-style substitutions, so custom CLI
279
- // providers fail closed there with a precise, actionable error instead of a
280
- // cryptic ENOENT. No arbitrary shell search.
281
- export const CUSTOM_CLI_UNSUPPORTED = 'CUSTOM_CLI_UNSUPPORTED';
282
- export function customCliInvocation(cmd, { platform = process.platform } = {}) {
283
- if (platform === 'win32') {
284
- const err = new Error(tr(
285
- '\u81EA\u5B9A\u4E49 CLI provider \u9700\u8981 POSIX shell\uFF08/bin/bash\uFF09\uFF0C\u5F53\u524D Windows \u73AF\u5883\u4E0D\u53EF\u7528\uFF1B\u8BF7\u5728 WSL \u5185\u8FD0\u884C Harness\uFF0C\u6216\u6539\u7528\u5185\u7F6E provider\uFF08\u5982 codex\uFF09\u3002',
286
- 'Custom CLI providers require a POSIX shell (/bin/bash), which is unavailable on this Windows environment. Run the Harness inside WSL, or use a built-in provider (e.g. codex).'));
287
- err.code = CUSTOM_CLI_UNSUPPORTED;
288
- return { ok: false, error: err };
289
- }
290
- return { ok: true, file: '/bin/bash', args: ['-lc', cmd] };
291
- }
292
-
293
- async function describeViaCustom(entry, imagePath, question, model) {
294
- const template = entry.vision_command;
295
- if (!template || !template.includes('{image}')) {
296
- throw new Error(tr(
297
- `自定义 provider "${entry.name ?? entry.id}" 的视觉命令未配置或缺少 {image} 占位符(可用 {image} {question} {model},stdout 即答案)`,
298
- `Custom provider "${entry.name ?? entry.id}" has no vision command, or it lacks the {image} placeholder (available: {image} {question} {model}; stdout is the answer)`));
299
- }
300
- const cmd = template
301
- .replaceAll('{image}', shq(imagePath))
302
- .replaceAll('{question}', shq(question))
303
- .replaceAll('{model}', shq(model ?? 'default'));
304
- const inv = customCliInvocation(cmd);
305
- if (!inv.ok) throw inv.error;
306
- return (await run(inv.file, inv.args, { timeoutMs: 240_000, cwd: dirname(imagePath) })).trim();
307
- }
308
-
309
- async function generateViaCustom(entry, prompt, outputPath, size) {
310
- const template = entry.imagegen_command;
311
- if (!template || !template.includes('{prompt}') || !template.includes('{output}')) {
312
- throw new Error(tr(
313
- `自定义 provider "${entry.name ?? entry.id}" 的生图命令未配置或缺少 {prompt}/{output} 占位符(可用 {prompt} {output} {size},命令须把图片写到 {output})`,
314
- `Custom provider "${entry.name ?? entry.id}" has no image-gen command, or it lacks the {prompt}/{output} placeholders (available: {prompt} {output} {size}; the command must write the image to {output})`));
315
- }
316
- const cmd = template
317
- .replaceAll('{prompt}', shq(prompt))
318
- .replaceAll('{output}', shq(outputPath))
319
- .replaceAll('{size}', shq(size ?? ''));
320
- const inv = customCliInvocation(cmd);
321
- if (!inv.ok) throw inv.error;
322
- const out = await run(inv.file, inv.args, { timeoutMs: 480_000, cwd: dirname(outputPath) });
323
- if (!existsSync(outputPath)) {
324
- throw new Error(`custom command finished but ${outputPath} was not created. Tail:\n${out.trim().slice(-300)}`);
325
- }
326
- return outputPath;
327
- }
328
-
329
- async function generateViaCodex(prompt, outputPath, size) {
330
- // Run codex with cwd = the output directory and a relative save path, so the
331
- // write lands inside its sandbox's writable root regardless of policy.
332
- const { dirname, basename } = await import('node:path');
333
- const task = `$imagegen ${prompt}. Save exactly one image to ./${basename(outputPath)} in the current directory.${size ? ` Target size ${size}.` : ''}`;
334
- const out = await run('codex', ['exec', '--skip-git-repo-check', task], { timeoutMs: 480_000, cwd: dirname(outputPath) });
335
- if (!existsSync(outputPath)) {
336
- throw new Error(`codex finished but ${outputPath} was not created. Tail of output:\n${out.trim().slice(-400)}`);
337
- }
338
- return outputPath;
339
- }
340
-
341
- async function generateViaGrok(prompt, outputPath) {
342
- // Grok Build CLI: native generate_image tool, saves straight into cwd.
343
- const { dirname, basename } = await import('node:path');
344
- const p = `Use your generate_image tool to create: ${prompt}. Save it to ./${basename(outputPath)} in the current directory (convert to the requested format if needed). Reply with the saved path.`;
345
- const out = await run('grok', ['-p', p], { timeoutMs: 480_000, cwd: dirname(outputPath) });
346
- if (!existsSync(outputPath)) {
347
- throw new Error(`grok finished but ${outputPath} was not created. Tail:\n${out.trim().slice(-300)}`);
348
- }
349
- return outputPath;
350
- }
351
-
352
- export function findGeneratedImage(root, nonce, now = Date.now()) {
353
- if (typeof nonce !== 'string' || !nonce.trim()) return null;
354
- const matches = [];
355
- for (const file of readdirSync(root, { recursive: true })) {
356
- const name = String(file);
357
- if (!name.includes(nonce) || !/\.(png|jpe?g|webp)$/i.test(name)) continue;
358
- const path = join(root, name);
359
- const info = statSync(path);
360
- if (info.isFile() && info.size > 0 && info.mtimeMs <= now + 1000 && now - info.mtimeMs <= 8 * 60_000) matches.push(path);
361
- }
362
- // Multiple matching files are ambiguous; never guess which task output won.
363
- return matches.length === 1 ? matches[0] : null;
364
- }
365
-
366
- async function generateViaAgy(prompt, outputPath) {
367
- // Antigravity CLI: native generate_image (Nano Banana) needs no permission
368
- // bypass, but saves into ~/.gemini/antigravity-cli/{brain,scratch}/... with a
369
- // sanitized name. A nonce in the requested filename lets us find the output.
370
- const { randomBytes } = await import('node:crypto');
371
- const { copyFileSync } = await import('node:fs');
372
- const nonce = randomBytes(4).toString('hex');
373
- const p = `Use ONLY your native generate_image tool — do not run shell commands or any other tools. Generate: ${prompt}. Save the file with a name containing ${nonce}. Reply with the saved path.`;
374
- await run('agy', ['-p', p], { timeoutMs: 480_000 });
375
- const root = join(homedir(), '.gemini', 'antigravity-cli');
376
- const found = findGeneratedImage(root, nonce);
377
- if (!found) throw new Error('agy reported success but no unique image matched this request under ~/.gemini/antigravity-cli');
378
- copyFileSync(found, outputPath);
379
- return outputPath;
380
- }
381
-
382
- // --- connectivity test ------------------------------------------------------
383
-
384
- const CRC_TABLE = (() => {
385
- const t = new Int32Array(256);
386
- for (let n = 0; n < 256; n++) {
387
- let c = n;
388
- for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
389
- t[n] = c;
390
- }
391
- return t;
392
- })();
393
- function crc32(buf) {
394
- let c = -1;
395
- for (const b of buf) c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8);
396
- return (c ^ -1) >>> 0;
397
- }
398
- function pngChunk(type, data) {
399
- const len = Buffer.alloc(4);
400
- len.writeUInt32BE(data.length);
401
- const body = Buffer.concat([Buffer.from(type, 'ascii'), data]);
402
- const crc = Buffer.alloc(4);
403
- crc.writeUInt32BE(crc32(body));
404
- return Buffer.concat([len, body, crc]);
405
- }
406
- /** A small solid-colour PNG, built in memory so the test needs no fixture file. */
407
- async function testPng(size = 64, rgb = [220, 40, 40]) {
408
- const { deflateSync } = await import('node:zlib');
409
- const ihdr = Buffer.alloc(13);
410
- ihdr.writeUInt32BE(size, 0);
411
- ihdr.writeUInt32BE(size, 4);
412
- ihdr[8] = 8; ihdr[9] = 2; // 8-bit RGB
413
- const row = Buffer.concat([Buffer.from([0]), Buffer.concat(Array.from({ length: size }, () => Buffer.from(rgb)))]);
414
- const raw = Buffer.concat(Array.from({ length: size }, () => row));
415
- return Buffer.concat([
416
- Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
417
- pngChunk('IHDR', ihdr),
418
- pngChunk('IDAT', deflateSync(raw)),
419
- pngChunk('IEND', Buffer.alloc(0)),
420
- ]);
421
- }
422
-
423
- const TEST_QUESTION = 'What single colour fills this image? Answer with one word.';
424
-
425
-
426
- /**
427
- * Probe a custom provider entry (as edited in the panel, saved or not) and
428
- * report per-step results. Image generation is never actually run — it would
429
- * cost money and minutes; only reachability and configuration are checked.
430
- * @returns {Promise<{ok: boolean, steps: Array<{name: string, ok: boolean, detail: string}>}>}
431
- */
432
- export async function testProvider(entry, lang) {
433
- if (lang) setLang(lang);
434
- const steps = [];
435
- const push = (name, ok, detail) => { steps.push({ name, ok, detail: String(detail).slice(0, 400) }); return ok; };
436
- const type = entryType(entry ?? {});
437
- const tmp = join(CONFIG_DIR, 'test-image.png');
438
-
439
- if (type === 'api') {
440
- let base;
441
- try { base = apiBase(entry); } catch (err) { push(tr('配置', 'Configuration'), false, err.message); return { ok: false, steps }; }
442
- push(tr('配置', 'Configuration'), true, `${base}${entry.api_key ? tr(' · 带 API Key', ' · with API key') : tr(' · 无 Key(不发送 Authorization)', ' · no key (no Authorization header)')}`);
443
-
444
- // 1. Reachability + auth, via the optional /models endpoint.
445
- try {
446
- const ctl = new AbortController();
447
- const t = setTimeout(() => ctl.abort(), 20_000);
448
- const res = await fetch(`${base}/models`, {
449
- headers: entry.api_key ? { authorization: `Bearer ${entry.api_key}` } : {},
450
- signal: ctl.signal,
451
- }).finally(() => clearTimeout(t));
452
- const text = await res.text();
453
- if (res.ok) {
454
- let ids = [];
455
- try { ids = (JSON.parse(text).data ?? []).map((m) => m.id).filter(Boolean); } catch {}
456
- const missing = (entry.models ?? []).filter((m) => ids.length > 0 && !ids.includes(m));
457
- push(tr('连接 /models', 'Reach /models'), true, ids.length
458
- ? tr(`可达,返回 ${ids.length} 个模型${missing.length ? `;未列出: ${missing.join(', ')}` : ''}`,
459
- `reachable, ${ids.length} models returned${missing.length ? `; not listed: ${missing.join(', ')}` : ''}`)
460
- : tr('可达', 'reachable'));
461
- } else if (res.status === 401 || res.status === 403) {
462
- push(tr('连接 /models', 'Reach /models'), false, tr(`HTTP ${res.status}:鉴权失败,检查 API Key`, `HTTP ${res.status}: authentication failed, check the API key`));
463
- return { ok: false, steps };
464
- } else {
465
- push(tr('连接 /models', 'Reach /models'), true, tr(`HTTP ${res.status}(该端点可能未实现,继续实测视觉调用)`, `HTTP ${res.status} (endpoint may be unimplemented; continuing with a real vision call)`));
466
- }
467
- } catch (err) {
468
- push(tr('连接 /models', 'Reach /models'), false, err?.name === 'AbortError' ? tr('请求超时 (20s)', 'request timed out (20s)') : String(err.message ?? err));
469
- return { ok: false, steps };
470
- }
471
-
472
- // 2. Real vision round-trip on a tiny generated image.
473
- const model = entry.models?.[0];
474
- if (!model) { push(tr('视觉调用', 'Vision call'), false, tr('未配置模型列表', 'no models configured')); return { ok: false, steps }; }
475
- try {
476
- mkdirSync(CONFIG_DIR, { recursive: true });
477
- writeFileSync(tmp, await testPng());
478
- const answer = await describeViaApi(entry, tmp, TEST_QUESTION, model);
479
- push(tr('视觉调用', 'Vision call'), true, `${model} → “${answer.slice(0, 60)}”`);
480
- } catch (err) {
481
- push(tr('视觉调用', 'Vision call'), false, String(err.message ?? err));
482
- return { ok: false, steps };
483
- }
484
-
485
- if (String(entry.imagegen_model ?? '').trim()) {
486
- push(tr('生图配置', 'Image-gen setup'), true, tr(
487
- `模型 ${entry.imagegen_model.trim()}(未实际出图,避免产生费用与等待)`,
488
- `model ${entry.imagegen_model.trim()} (not actually run — that would cost money and time)`));
489
- } else {
490
- push(tr('生图配置', 'Image-gen setup'), true, tr(
491
- '未配置生图模型 → 该 provider 只提供视觉能力',
492
- 'no image-gen model → this provider offers vision only'));
493
- }
494
- return { ok: true, steps };
495
- }
496
-
497
- // CLI: check the executables exist, then actually run the vision command.
498
- const bin = (cmd) => String(cmd).trim().split(/\s+/)[0];
499
- const cliShell = customCliInvocation('');
500
- if (!cliShell.ok) {
501
- push(tr('CLI shell', 'CLI shell'), false, cliShell.error.message);
502
- return { ok: false, steps };
503
- }
504
- if (entry.vision_command) {
505
- try {
506
- const which = (await run('/bin/bash', ['-lc', `command -v ${shq(bin(entry.vision_command))}`], { timeoutMs: 15_000 })).trim();
507
- push(tr('视觉命令可执行', 'Vision command found'), true, which);
508
- } catch {
509
- push(tr('视觉命令可执行', 'Vision command found'), false, tr(`找不到可执行文件: ${bin(entry.vision_command)}`, `executable not found: ${bin(entry.vision_command)}`));
510
- return { ok: false, steps };
511
- }
512
- try {
513
- mkdirSync(CONFIG_DIR, { recursive: true });
514
- writeFileSync(tmp, await testPng());
515
- const answer = await describeViaCustom(entry, tmp, TEST_QUESTION, entry.models?.[0] ?? 'default');
516
- if (answer === '') { push(tr('视觉调用', 'Vision call'), false, tr('命令成功但没有输出', 'the command succeeded but printed nothing')); return { ok: false, steps }; }
517
- push(tr('视觉调用', 'Vision call'), true, `“${answer.slice(0, 60)}”`);
518
- } catch (err) {
519
- push(tr('视觉调用', 'Vision call'), false, String(err.message ?? err));
520
- return { ok: false, steps };
521
- }
522
- }
523
- if (entry.imagegen_command) {
524
- try {
525
- const which = (await run('/bin/bash', ['-lc', `command -v ${shq(bin(entry.imagegen_command))}`], { timeoutMs: 15_000 })).trim();
526
- push(tr('生图命令可执行', 'Image-gen command found'), true, tr(`${which}(未实际出图,避免长时间等待)`, `${which} (not actually run — that would take minutes)`));
527
- } catch {
528
- push(tr('生图命令可执行', 'Image-gen command found'), false, tr(`找不到可执行文件: ${bin(entry.imagegen_command)}`, `executable not found: ${bin(entry.imagegen_command)}`));
529
- return { ok: false, steps };
530
- }
531
- }
532
- if (steps.length === 0) push(tr('配置', 'Configuration'), false, tr('未填写任何命令', 'no command configured'));
533
- return { ok: steps.every((s) => s.ok), steps };
534
- }
535
-
536
- const modelListCache = new Map(); // provider -> { at, models }
537
-
538
- /** List selectable models for CLIs that expose a `models` subcommand. */
539
- export async function listVisionModels(provider, force = false, getConfig = null, lang = null) {
540
- if (lang) setLang(lang);
541
- if (!BUILTIN_VISION.includes(provider)) {
542
- const entry = getConfig ? findCustomProvider(getConfig(), provider) : null;
543
- const models = (entry?.models ?? []).map((m) => ({ value: String(m) }));
544
- // API providers must name a real model; only command-based ones have a
545
- // meaningful "let the CLI decide" option.
546
- return entryType(entry ?? {}) === 'api' ? models : [{ value: 'default', label: tr('default(CLI 默认)', 'default (CLI default)') }, ...models];
547
- }
548
- const cached = modelListCache.get(provider);
549
- if (!force && cached && Date.now() - cached.at < 60 * 60 * 1000) return cached.models;
550
- let models = [];
551
- if (provider === 'agy') {
552
- const out = await run('agy', ['models'], { timeoutMs: 60_000 });
553
- models = out.split('\n')
554
- .map((l) => l.split('\t'))
555
- .filter((parts) => parts.length >= 2 && /^[a-z0-9.-]+$/i.test(parts[0].trim()))
556
- .map((parts) => ({ value: parts[0].trim(), label: parts[1].trim() }));
557
- } else if (provider === 'grok') {
558
- const out = await run('grok', ['models'], { timeoutMs: 60_000 });
559
- models = out.split('\n')
560
- .map((l) => l.match(/^\s*[*-]\s+([a-z0-9.-]+)(\s+\(default\))?/i))
561
- .filter(Boolean)
562
- .map((m) => ({ value: m[1], label: m[1] + (m[2] ? tr('(默认)', ' (default)') : '') }));
563
- }
564
- const result = [{ value: 'default', label: tr('default(CLI 默认)', 'default (CLI default)') }, ...models];
565
- modelListCache.set(provider, { at: Date.now(), models: result });
566
- return result;
567
- }
568
-
569
- function envelope(tool, text) { return { ok: true, tool, text }; }
570
-
571
- /** Core vision call shared by the describe_image tool and the pre-step transcriber. */
572
- export async function describeFile(getConfig, imagePath, question) {
573
- const config = getConfig();
574
- const provider = config.vision_provider ?? 'claude-code';
575
- if (provider === 'off') throw new Error('vision bridge is disabled');
576
- const model = config.vision_model ?? 'haiku';
577
- const custom = BUILTIN_VISION.includes(provider) ? null : findCustomProvider(config, provider);
578
- if (!BUILTIN_VISION.includes(provider) && !custom) {
579
- throw new Error(tr(
580
- `未知视觉 provider: ${provider}(内置: ${BUILTIN_VISION.join(' / ')},或在设置 → DSH Crew → 自定义 Provider 里添加)`,
581
- `Unknown vision provider: ${provider} (built-in: ${BUILTIN_VISION.join(' / ')}, or add one under Settings → DSH Crew → Custom providers)`));
582
- }
583
- const stat = statSync(imagePath);
584
- const keyParts = ['v2', provider, model, imagePath, String(stat.size), String(stat.mtimeMs), question];
585
- if (custom) keyParts.push(entryType(custom), custom.vision_command ?? '', custom.base_url ?? '');
586
- const key = cacheKey(keyParts);
587
- const cache = readCache();
588
- if (cache[key]) return cache[key];
589
- const text = custom
590
- ? (entryType(custom) === 'cli'
591
- ? await describeViaCustom(custom, imagePath, question, model)
592
- : await describeViaApi(custom, imagePath, question, model))
593
- : provider === 'codex'
594
- ? await describeViaCodex(imagePath, question, model)
595
- : provider === 'grok'
596
- ? await describeViaGrok(imagePath, question, model)
597
- : provider === 'agy'
598
- ? await describeViaAgy(imagePath, question, model)
599
- : await describeViaClaude(model, imagePath, question);
600
- if (text === '') throw new Error(`${provider} returned an empty answer`);
601
- cache[key] = text;
602
- writeCache(cache);
603
- return text;
604
- }
605
-
606
- /**
607
- * @param getConfig () => global config (vision_provider, vision_model, imagegen_provider)
608
- */
609
- export function createMultimodalTools(getConfig) {
610
- const describeImage = {
611
- name: 'describe_image',
612
- description: 'See an image through a vision-capable model (Claude / GPT / Grok / Gemini / custom CLI) and get an answer about it. Use whenever you need to understand an image file: screenshots, design renders, photos, diagrams. Ask a specific question for the most useful answer. Results are cached per image+question.',
613
- parameters: {
614
- type: 'object',
615
- properties: {
616
- image_path: { type: 'string', description: 'Absolute path to the image file (png/jpg/webp/gif)' },
617
- question: { type: 'string', description: 'What you want to know about the image. Default: a detailed description.' },
618
- },
619
- required: ['image_path'],
620
- additionalProperties: false,
621
- },
622
- output: {
623
- schema: RESULT_SCHEMA,
624
- render: (_args, value) => [{ type: 'text', text: value.text }],
625
- },
626
- async execute(args) {
627
- const config = getConfig();
628
- const provider = config.vision_provider ?? 'claude-code';
629
- if (provider === 'off') throw new Error(tr('视觉桥已关闭(设置 → DSH Crew → 视觉 provider)', 'the vision bridge is disabled (Settings → DSH Crew → Vision provider)'));
630
- const imagePath = String(args.image_path ?? '');
631
- if (!isAbsolute(imagePath) || !existsSync(imagePath)) throw new Error(`image_path must be an existing absolute path, got: ${imagePath}`);
632
- const question = typeof args.question === 'string' && args.question !== ''
633
- ? args.question
634
- : 'Describe this image in detail: layout, text content, notable elements.';
635
- return envelope('describe_image', await describeFile(getConfig, imagePath, question));
636
- },
637
- };
638
-
639
- const generateImage = {
640
- name: 'generate_image',
641
- description: 'Generate a raster image from a text prompt via a subscription image model (codex → gpt-image-2, agy → Nano Banana, grok → Grok Imagine, custom → user-configured CLI). Provide a full visual description and an absolute output path. Note: output is a flat raster (no layers); for layered, editable design output use the OpenPencil pipeline instead.',
642
- parameters: {
643
- type: 'object',
644
- properties: {
645
- prompt: { type: 'string', description: 'Full visual description: subject, style, colors, composition, constraints' },
646
- output_path: { type: 'string', description: 'Absolute path to save the image (e.g. /path/to/hero.png)' },
647
- size: { type: 'string', description: 'Optional size hint, e.g. "1024x1024", "16:9"' },
648
- provider: { type: 'string', description: 'Override the configured image provider: codex | agy | grok | a custom provider id' },
649
- },
650
- required: ['prompt', 'output_path'],
651
- additionalProperties: false,
652
- },
653
- output: {
654
- schema: RESULT_SCHEMA,
655
- render: (_args, value) => [{ type: 'text', text: value.text }],
656
- },
657
- async execute(args) {
658
- const config = getConfig();
659
- const provider = args.provider ?? config.imagegen_provider ?? 'codex';
660
- if (provider === 'off') throw new Error(tr('生图已关闭(设置 → DSH Crew → 生图 provider)', 'image generation is disabled (Settings → DSH Crew → Image-gen provider)'));
661
- const outputPath = String(args.output_path ?? '');
662
- if (!isAbsolute(outputPath)) throw new Error(`output_path must be absolute, got: ${outputPath}`);
663
- const prompt = String(args.prompt ?? '').trim();
664
- if (prompt === '') throw new Error('prompt is required');
665
- const custom = BUILTIN_IMAGEGEN.includes(provider) ? null : findCustomProvider(config, provider);
666
- if (!BUILTIN_IMAGEGEN.includes(provider) && !custom) {
667
- throw new Error(tr(
668
- `未知生图 provider: ${provider}(内置: ${BUILTIN_IMAGEGEN.join(' / ')},或在设置 → DSH Crew → 自定义 Provider 里添加)`,
669
- `Unknown image-gen provider: ${provider} (built-in: ${BUILTIN_IMAGEGEN.join(' / ')}, or add one under Settings → DSH Crew → Custom providers)`));
670
- }
671
- const saved = await withGeneratedImageOutput(outputPath, async (stagedPath) => custom
672
- ? (entryType(custom) === 'cli'
673
- ? await generateViaCustom(custom, prompt, stagedPath, args.size)
674
- : await generateViaApi(custom, prompt, stagedPath, args.size))
675
- : provider === 'agy'
676
- ? await generateViaAgy(prompt, stagedPath)
677
- : provider === 'grok'
678
- ? await generateViaGrok(prompt, stagedPath)
679
- : await generateViaCodex(prompt, stagedPath, args.size));
680
- const bytes = statSync(saved).size;
681
- return envelope('generate_image', `image saved: ${saved} (${Math.round(bytes / 1024)} KB, provider: ${provider})`);
682
- },
683
- };
684
-
685
- return [describeImage, generateImage];
686
- }