@xlight-oss/visionary-dsh 0.6.0 → 0.7.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/lib/index.mjs CHANGED
@@ -14,8 +14,9 @@
14
14
 
15
15
  import { defineTool } from "@deepseek-ai/dsh-tools";
16
16
  import z from "@deepseek-ai/schemastery";
17
+ import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
17
18
  import { spawn } from "node:child_process";
18
- import { statSync } from "node:fs";
19
+ import { statSync, readFileSync } from "node:fs";
19
20
  import { promises as fs } from "node:fs";
20
21
  import os from "node:os";
21
22
  import path from "node:path";
@@ -23,9 +24,12 @@ import path from "node:path";
23
24
  const name = "visionary-vision";
24
25
  const inject = ["tools", "systemPrompt"];
25
26
 
27
+ /** Settings namespace(面板 / settings.yaml 双入口,规范:dsh-plugin 设置面板上传路径配置)。 */
28
+ const SETTINGS_NAMESPACE = settingsNamespace("visionary-vision");
29
+
26
30
  // Keep in lockstep with the Rust binary's minor version: tools rely on the
27
31
  // CLI's `--json` output shape. Bump when the binary's contract changes.
28
- const COMPAT_MINOR = "0.6";
32
+ const COMPAT_MINOR = "0.7";
29
33
 
30
34
  const Config = z.object({
31
35
  binaryPath: z
@@ -43,15 +47,58 @@ const Config = z.object({
43
47
  visionTimeoutMs: z
44
48
  .number()
45
49
  .default(300000)
46
- .description("Per deepseek_vision call timeout in ms."),
50
+ .description("Per deepseek_vision / deepseek_ocr call timeout in ms."),
47
51
  statusTimeoutMs: z
48
52
  .number()
49
53
  .default(60000)
50
54
  .description("Per deepseek_vision_status / deepseek_vision_logout timeout in ms."),
55
+ modelType: z
56
+ .union([z.const("vision"), z.const("ocr")])
57
+ .default("vision")
58
+ .description(
59
+ "上传管道:vision(默认,完整多模态图像理解,携带 x-model-type: vision)| ocr(文本提取,不携带 x-model-type,走服务端 OCR 管道)。deepseek_vision 按此上传;deepseek_ocr 恒为 ocr。覆盖 CLI 默认配置,修改后即时生效。"
60
+ ),
51
61
  });
52
62
 
53
63
  // --- binary resolution -------------------------------------------------------
54
64
 
65
+ // npm 全局安装(@xlight-oss/visionary-server)在 Windows 上只在 PATH 生成
66
+ // .cmd/.ps1 shim(node 包装脚本),真实 exe 在包内 node_modules/.bin_real/。
67
+ // shim 层用 spawnSync + stdio:"inherit" 转发——直接 spawn shim 会丢 stdout 管道、
68
+ // kill 链路断裂(孤儿进程)。故:解析 shim 文本定位 exe 真身,spawn 真身。
69
+ const NPM_PKG_SHIM_RE =
70
+ /node_modules[\\/]@xlight-oss[\\/]visionary-server[\\/]run-visionary-server\.js/;
71
+
72
+ // 从 npm shim(.cmd/.ps1)文本中解析出 exe 真身路径。
73
+ // shim 内容形如:... "%dp0%\node_modules\@xlight-oss\visionary-server\run-visionary-server.js" ...
74
+ // 包目录 = <shim目录>\node_modules\@xlight-oss\visionary-server
75
+ // 真身 = <包目录>\node_modules\.bin_real\visionary-server.exe
76
+ function resolveFromNpmShim(shimPath) {
77
+ let text;
78
+ try {
79
+ text = readFileSync(shimPath, "utf8");
80
+ } catch {
81
+ return null;
82
+ }
83
+ const m = NPM_PKG_SHIM_RE.exec(text);
84
+ if (!m) return null;
85
+ // shim 文本使用反斜杠分隔符(Windows 产物);手动切分保证在任意平台
86
+ // (包括测试跑的 macOS/Linux)都能解析,不依赖 path.dirname 的分隔符语义。
87
+ const pkgParts = m[0].split(/[\\/]+/).filter(Boolean);
88
+ if (pkgParts.length < 3) return null;
89
+ // node_modules\@xlight-oss\visionary-server\run-visionary-server.js → 去掉末段(文件名)
90
+ // npm shim 是 Windows 产物(.cmd/.ps1),包内 exe 恒为 visionary-server.exe,
91
+ // 与 process.platform 无关——固定扩展名保证任意平台测试/解析一致。
92
+ const pkgDir = path.join(path.dirname(shimPath), ...pkgParts.slice(0, -1));
93
+ const candidate = path.join(pkgDir, "node_modules", ".bin_real", "visionary-server.exe");
94
+ try {
95
+ if (statSync(candidate).isFile()) return candidate;
96
+ } catch {
97
+ // keep looking
98
+ }
99
+ return null;
100
+ }
101
+
55
102
  function resolveBinaryPath(config) {
56
103
  if (config.binaryPath) return config.binaryPath;
57
104
  const fromEnv = process.env.DEEPSEEK_VISIONARY_BIN;
@@ -65,17 +112,40 @@ function resolveBinaryPath(config) {
65
112
  // keep looking
66
113
  }
67
114
  }
115
+ // win32 追加:npm 全局包 shim(.cmd / .ps1)→ 解析 exe 真身
116
+ if (process.platform === "win32") {
117
+ for (const shimName of ["visionary-server.cmd", "visionary-server.ps1"]) {
118
+ for (const dir of (process.env.PATH || "").split(path.delimiter).filter(Boolean)) {
119
+ const shim = path.join(dir, shimName);
120
+ try {
121
+ if (statSync(shim).isFile()) {
122
+ const resolved = resolveFromNpmShim(shim);
123
+ if (resolved) return resolved;
124
+ }
125
+ } catch {
126
+ // keep looking
127
+ }
128
+ }
129
+ }
130
+ }
68
131
  return null;
69
132
  }
70
133
 
71
134
  const binaryMissingHelp = () =>
72
- [
73
- "visionary-server binary not found. Install it and retry:",
74
- " - One-liner: curl -LsSf https://github.com/xlight/deepseek-visionary/releases/latest/download/visionary-server-installer.sh | sh",
75
- " - Homebrew: brew install <tap>/visionary-server",
76
- " - npm: npm install -g @xlight-oss/visionary-server",
77
- "Or point the plugin at the binary via Config.binaryPath / DEEPSEEK_VISIONARY_BIN.",
78
- ].join("\n");
135
+ process.platform === "win32"
136
+ ? [
137
+ "visionary-server binary not found. Install it and retry:",
138
+ " - npm: npm install -g @xlight-oss/visionary-server (then restart DSH)",
139
+ " - or download from GitHub Releases: https://github.com/xlight/deepseek-visionary/releases/latest",
140
+ "Or point the plugin at the binary via Config.binaryPath / DEEPSEEK_VISIONARY_BIN.",
141
+ ].join("\n")
142
+ : [
143
+ "visionary-server binary not found. Install it and retry:",
144
+ " - One-liner: curl -LsSf https://github.com/xlight/deepseek-visionary/releases/latest/download/visionary-server-installer.sh | sh",
145
+ " - Homebrew: brew install <tap>/visionary-server",
146
+ " - npm: npm install -g @xlight-oss/visionary-server",
147
+ "Or point the plugin at the binary via Config.binaryPath / DEEPSEEK_VISIONARY_BIN.",
148
+ ].join("\n");
79
149
 
80
150
  // --- subprocess --------------------------------------------------------------
81
151
 
@@ -162,6 +232,56 @@ async function materializeImage(image) {
162
232
  return { arg: image, cleanup: null };
163
233
  }
164
234
 
235
+ // --- CLI 参数构建 --------------------------------------------------------------
236
+
237
+ // 构建 vision / ocr 子命令的 argv(纯函数,便于测试)。
238
+ // 选项一律等号传参(`--prompt=<v>`),避免 clap 把 `-` 开头的空格形式值当 flag 拒绝。
239
+ // `modelType` 仅对 vision 子命令生效:ocr 时追加 `--model-type=ocr`(其余情况不追加,
240
+ // CLI 默认即 vision);`ocr` 子命令不暴露 `--model-type`(恒为 ocr)。
241
+ function buildImageCliArgs(subcommand, images, opts) {
242
+ const cliArgs = [subcommand, ...images, "--json"];
243
+ if (opts?.prompt) cliArgs.push(`--prompt=${opts.prompt}`);
244
+ if (opts?.thinking) cliArgs.push("--thinking");
245
+ if (opts?.sessionId) cliArgs.push(`--session-id=${opts.sessionId}`);
246
+ else if (opts?.continueConversation) cliArgs.push("--continue-conversation");
247
+ if (subcommand === "vision" && opts?.modelType === "ocr") cliArgs.push("--model-type=ocr");
248
+ return cliArgs;
249
+ }
250
+
251
+ // deepseek_vision / deepseek_ocr 共用参数 schema(原生工具面,规范对齐对应 MCP 工具)。
252
+ const imageToolParams = {
253
+ images: {
254
+ type: "array",
255
+ items: { type: "string" },
256
+ description: "One or more images: local file paths, base64, or data URIs. The model analyzes all of them together.",
257
+ },
258
+ image: {
259
+ type: "string",
260
+ description: "Single image (local path, base64, or data URI) — convenience form of `images` with one entry.",
261
+ },
262
+ prompt: {
263
+ type: "string",
264
+ description: "Question about the image(s) (default: detailed description in Chinese).",
265
+ },
266
+ thinking: {
267
+ type: "boolean",
268
+ description: "Enable DeepThink deep reasoning.",
269
+ },
270
+ continue_conversation: {
271
+ type: "boolean",
272
+ description: "Continue the previous session (multi-image comparison across turns).",
273
+ },
274
+ session_id: {
275
+ type: "string",
276
+ description: "Reuse an explicit session thread (takes precedence over continue_conversation).",
277
+ },
278
+ };
279
+
280
+ const imageToolOutput = {
281
+ schema: { type: "string" },
282
+ render: (_args, value) => [{ type: "text", text: value }],
283
+ };
284
+
165
285
  // --- version probe (apply-time, fire-and-forget) -----------------------------
166
286
 
167
287
  function parseMinor(v) {
@@ -172,16 +292,34 @@ function parseMinor(v) {
172
292
  // --- tools -------------------------------------------------------------------
173
293
 
174
294
  function apply(ctx, config) {
175
- const binary = resolveBinaryPath(config);
295
+ // 运行态配置:settings 面板 / settings.yaml 写入经 source() 即时生效(热重载),
296
+ // 无 settings 服务时回退到插件行 entry config。所有工具调用都从 runtime 读取,
297
+ // 而不是冻结的 config —— 设置面板切换 modelType(vision|ocr)无需重启 DSH。
298
+ let runtime = { ...config };
299
+ let source = () => config;
300
+ installSettingsSection(ctx, SETTINGS_NAMESPACE, Config, config, {
301
+ setSource: (thunk) => {
302
+ source = thunk;
303
+ },
304
+ onChange: () => {
305
+ runtime = { ...source() };
306
+ },
307
+ });
308
+
176
309
  const loginSeconds = (() => {
177
310
  const raw = Number(process.env.DEEPSEEK_LOGIN_TIMEOUT);
178
311
  if (Number.isFinite(raw) && raw > 0) return raw;
179
- return config.loginTimeoutSeconds > 0 ? config.loginTimeoutSeconds : 600;
312
+ return runtime.loginTimeoutSeconds > 0 ? runtime.loginTimeoutSeconds : 600;
180
313
  })();
181
314
 
315
+ // 版本探测:apply 时 fire-and-forget(仅用于结果附带版本警告)。
316
+ // 注意:二进制路径【不在此缓存】——每次工具调用经 requireBinary()
317
+ // 重新 resolveBinaryPath(runtime),用户修改 PATH / DEEPSEEK_VISIONARY_BIN
318
+ // 后无需重启 DSH 即生效(懒解析,成本为数次 statSync)。
182
319
  let versionInfo = { known: false, compatible: true, version: "" };
183
- if (binary) {
184
- runCli(binary, ["--version"], { timeoutMs: 5000 })
320
+ const probeBinary = resolveBinaryPath(runtime);
321
+ if (probeBinary) {
322
+ runCli(probeBinary, ["--version"], { timeoutMs: 5000 })
185
323
  .then((r) => {
186
324
  const version = (r.stdout || r.stderr).trim();
187
325
  versionInfo = {
@@ -209,105 +347,124 @@ function apply(ctx, config) {
209
347
  "",
210
348
  "You have native vision tools backed by DeepSeek's web vision model (no API key):",
211
349
  "- `deepseek_vision` — analyze one or more images (local path / base64 / data URI; use `images` for multiple)",
350
+ "- `deepseek_ocr` — extract raw text from an image (text extraction, not interpretation): document/PDF/terminal screenshots, code, signs",
212
351
  "- `deepseek_vision_status` — check login state",
213
352
  "- `deepseek_vision_login` — browser auto-login",
214
353
  "- `deepseek_vision_logout` — clear saved credentials",
215
354
  "",
355
+ "Use `deepseek_vision` for understanding an image; use `deepseek_ocr` when the user wants the text content of an image verbatim.",
216
356
  "Prefer these native tools over invoking `visionary-server` through the shell: native tools run in the host process, so session continuation and login are not restricted by the bash sandbox.",
217
357
  "The `image`/`images` paths passed to `deepseek_vision` are read and uploaded to the DeepSeek service — only pass paths the user intends to share.",
218
358
  ].join("\n"),
219
359
  });
220
360
  }
221
361
 
362
+ // 懒解析:每次工具调用重新定位二进制(PATH / 环境变量改动即时生效)。
222
363
  const requireBinary = () => {
364
+ const binary = resolveBinaryPath(runtime);
223
365
  if (!binary) throw new Error(binaryMissingHelp());
224
366
  return binary;
225
367
  };
226
368
 
369
+ // deepseek_vision / deepseek_ocr 共享执行(materialize → spawn → 原子 JSON 解析)。
370
+ // `subcommand`:本轮 spawn 的 CLI 子命令;`modelType` 仅 vision 生效(见
371
+ // buildImageCliArgs)。ocr 工具恒为 ocr 子命令,不受 config.modelType 影响。
372
+ async function runImageAnalysis({ subcommand, name, imageInputs, args, exec }) {
373
+ const bin = requireBinary();
374
+ if (imageInputs.length === 0) {
375
+ throw new Error(`${name}: at least one image is required (\`images\` or \`image\`)`);
376
+ }
377
+ const materialized = [];
378
+ try {
379
+ for (const image of imageInputs) {
380
+ materialized.push(await materializeImage(image));
381
+ }
382
+ const cliArgs = buildImageCliArgs(
383
+ subcommand,
384
+ materialized.map((m) => m.arg),
385
+ {
386
+ prompt: args.prompt,
387
+ thinking: args.thinking,
388
+ sessionId: args.session_id,
389
+ continueConversation: args.continue_conversation,
390
+ modelType: subcommand === "vision" ? runtime.modelType : undefined,
391
+ },
392
+ );
393
+
394
+ const r = await runCli(bin, cliArgs, {
395
+ timeoutMs: runtime.visionTimeoutMs,
396
+ signal: exec.signal,
397
+ });
398
+ if (r.killed) throw new Error(`${name} was aborted or timed out`);
399
+ let parsed = null;
400
+ try {
401
+ parsed = JSON.parse(r.stdout);
402
+ } catch {
403
+ parsed = null;
404
+ }
405
+ if (parsed && typeof parsed.error === "string") {
406
+ throw new Error(parsed.error);
407
+ }
408
+ if (parsed && typeof parsed.text === "string") {
409
+ const meta = [`session_id: ${parsed.session_id}`, `parent_message_id: ${parsed.parent_message_id}`]
410
+ .filter((s) => !s.endsWith(": null") && !s.endsWith(": undefined"))
411
+ .join(", ");
412
+ return withVersionWarning(meta ? `${parsed.text}\n\n[${meta}]` : parsed.text);
413
+ }
414
+ throw new Error(
415
+ `${name} failed (exit ${r.code}): ${(r.stderr || r.stdout).trim() || "unknown error"}`
416
+ );
417
+ } finally {
418
+ for (const m of materialized) if (m.cleanup) await m.cleanup();
419
+ }
420
+ }
421
+
227
422
  ctx.tools.register(
228
423
  defineTool({
229
424
  name: "deepseek_vision",
230
425
  description:
231
426
  "Analyze one or more images with DeepSeek's web vision model (local path / base64 / data URI). Pass multiple images via `images` to have the model analyze them together in one call (like the DeepSeek website). Use for screenshots, photos, or documents with images. Supports multi-turn conversation via continue_conversation / session_id.",
232
- parameters: {
233
- images: {
234
- type: "array",
235
- items: { type: "string" },
236
- description: "One or more images: local file paths, base64, or data URIs. The model analyzes all of them together.",
237
- },
238
- image: {
239
- type: "string",
240
- description: "Single image (local path, base64, or data URI) — convenience form of `images` with one entry.",
241
- },
242
- prompt: {
243
- type: "string",
244
- description: "Question about the image(s) (default: detailed description in Chinese).",
245
- },
246
- thinking: {
247
- type: "boolean",
248
- description: "Enable DeepThink deep reasoning.",
249
- },
250
- continue_conversation: {
251
- type: "boolean",
252
- description: "Continue the previous session (multi-image comparison across turns).",
253
- },
254
- session_id: {
255
- type: "string",
256
- description: "Reuse an explicit session thread (takes precedence over continue_conversation).",
257
- },
258
- },
259
- output: {
260
- schema: { type: "string" },
261
- render: (_args, value) => [{ type: "text", text: value }],
427
+ parameters: imageToolParams,
428
+ output: imageToolOutput,
429
+ timeoutMs: runtime.visionTimeoutMs,
430
+ async execute(args, exec) {
431
+ const imageInputs = Array.isArray(args.images) && args.images.length > 0
432
+ ? args.images
433
+ : args.image
434
+ ? [args.image]
435
+ : [];
436
+ return runImageAnalysis({
437
+ subcommand: "vision",
438
+ name: "deepseek_vision",
439
+ imageInputs,
440
+ args,
441
+ exec,
442
+ });
262
443
  },
263
- timeoutMs: config.visionTimeoutMs,
444
+ })
445
+ );
446
+
447
+ ctx.tools.register(
448
+ defineTool({
449
+ name: "deepseek_ocr",
450
+ description:
451
+ "Extract raw text from one or more images with DeepSeek's OCR pipeline (local path / base64 / data URI), equivalent to `visionary-server ocr`. Use for the text content of a screenshot or document — document/PDF screenshots, code, signs, tables (not interpretation). Text is extracted verbatim by default. Supports multi-turn conversation via continue_conversation / session_id.",
452
+ parameters: imageToolParams,
453
+ output: imageToolOutput,
454
+ timeoutMs: runtime.visionTimeoutMs,
264
455
  async execute(args, exec) {
265
- const bin = requireBinary();
266
456
  const imageInputs = Array.isArray(args.images) && args.images.length > 0
267
457
  ? args.images
268
458
  : args.image
269
459
  ? [args.image]
270
460
  : [];
271
- if (imageInputs.length === 0) {
272
- throw new Error("deepseek_vision: at least one image is required (`images` or `image`)");
273
- }
274
- const materialized = [];
275
- try {
276
- for (const image of imageInputs) {
277
- materialized.push(await materializeImage(image));
278
- }
279
- const cliArgs = ["vision", ...materialized.map((m) => m.arg), "--json"];
280
- if (args.prompt) cliArgs.push(`--prompt=${args.prompt}`);
281
- if (args.thinking) cliArgs.push("--thinking");
282
- if (args.session_id) cliArgs.push(`--session-id=${args.session_id}`);
283
- else if (args.continue_conversation) cliArgs.push("--continue-conversation");
284
-
285
- const r = await runCli(bin, cliArgs, {
286
- timeoutMs: config.visionTimeoutMs,
287
- signal: exec.signal,
288
- });
289
- if (r.killed) throw new Error("deepseek_vision was aborted or timed out");
290
- let parsed = null;
291
- try {
292
- parsed = JSON.parse(r.stdout);
293
- } catch {
294
- parsed = null;
295
- }
296
- if (parsed && typeof parsed.error === "string") {
297
- throw new Error(parsed.error);
298
- }
299
- if (parsed && typeof parsed.text === "string") {
300
- const meta = [`session_id: ${parsed.session_id}`, `parent_message_id: ${parsed.parent_message_id}`]
301
- .filter((s) => !s.endsWith(": null") && !s.endsWith(": undefined"))
302
- .join(", ");
303
- return withVersionWarning(meta ? `${parsed.text}\n\n[${meta}]` : parsed.text);
304
- }
305
- throw new Error(
306
- `vision failed (exit ${r.code}): ${(r.stderr || r.stdout).trim() || "unknown error"}`
307
- );
308
- } finally {
309
- for (const m of materialized) if (m.cleanup) await m.cleanup();
310
- }
461
+ return runImageAnalysis({
462
+ subcommand: "ocr",
463
+ name: "deepseek_ocr",
464
+ imageInputs,
465
+ args,
466
+ exec,
467
+ });
311
468
  },
312
469
  })
313
470
  );
@@ -322,11 +479,11 @@ function apply(ctx, config) {
322
479
  schema: { type: "string" },
323
480
  render: (_args, value) => [{ type: "text", text: value }],
324
481
  },
325
- timeoutMs: config.statusTimeoutMs,
482
+ timeoutMs: runtime.statusTimeoutMs,
326
483
  async execute(_args, exec) {
327
484
  const bin = requireBinary();
328
485
  const r = await runCli(bin, ["status", "--json"], {
329
- timeoutMs: config.statusTimeoutMs,
486
+ timeoutMs: runtime.statusTimeoutMs,
330
487
  signal: exec.signal,
331
488
  });
332
489
  if (r.killed) throw new Error("deepseek_vision_status was aborted or timed out");
@@ -395,11 +552,11 @@ function apply(ctx, config) {
395
552
  schema: { type: "string" },
396
553
  render: (_args, value) => [{ type: "text", text: value }],
397
554
  },
398
- timeoutMs: config.statusTimeoutMs,
555
+ timeoutMs: runtime.statusTimeoutMs,
399
556
  async execute(_args, exec) {
400
557
  const bin = requireBinary();
401
558
  const r = await runCli(bin, ["logout"], {
402
- timeoutMs: config.statusTimeoutMs,
559
+ timeoutMs: runtime.statusTimeoutMs,
403
560
  signal: exec.signal,
404
561
  });
405
562
  if (r.killed) throw new Error("deepseek_vision_logout was aborted or timed out");
@@ -412,4 +569,19 @@ function apply(ctx, config) {
412
569
  );
413
570
  }
414
571
 
415
- export { name, inject, Config, apply };
572
+ export {
573
+ name,
574
+ inject,
575
+ Config,
576
+ apply,
577
+ SETTINGS_NAMESPACE,
578
+ buildImageCliArgs,
579
+ resolveBinaryPath,
580
+ resolveFromNpmShim,
581
+ };
582
+
583
+ // Internal reuse: the image-bridge plugin runs in the same package but is a
584
+ // separate plugin row; it needs the same binary resolution + subprocess
585
+ // plumbing to drive deterministic-mode analysis. Export the two pieces it
586
+ // reuses (not the public plugin surface).
587
+ export { runCli, binaryMissingHelp };