@xlight-oss/visionary-dsh 0.6.1 → 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,6 +14,7 @@
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
19
  import { statSync, readFileSync } from "node:fs";
19
20
  import { promises as fs } from "node:fs";
@@ -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,11 +47,17 @@ 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 -------------------------------------------------------
@@ -222,6 +232,56 @@ async function materializeImage(image) {
222
232
  return { arg: image, cleanup: null };
223
233
  }
224
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
+
225
285
  // --- version probe (apply-time, fire-and-forget) -----------------------------
226
286
 
227
287
  function parseMinor(v) {
@@ -232,18 +292,32 @@ function parseMinor(v) {
232
292
  // --- tools -------------------------------------------------------------------
233
293
 
234
294
  function apply(ctx, 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
+
235
309
  const loginSeconds = (() => {
236
310
  const raw = Number(process.env.DEEPSEEK_LOGIN_TIMEOUT);
237
311
  if (Number.isFinite(raw) && raw > 0) return raw;
238
- return config.loginTimeoutSeconds > 0 ? config.loginTimeoutSeconds : 600;
312
+ return runtime.loginTimeoutSeconds > 0 ? runtime.loginTimeoutSeconds : 600;
239
313
  })();
240
314
 
241
315
  // 版本探测:apply 时 fire-and-forget(仅用于结果附带版本警告)。
242
316
  // 注意:二进制路径【不在此缓存】——每次工具调用经 requireBinary()
243
- // 重新 resolveBinaryPath(config),用户修改 PATH / DEEPSEEK_VISIONARY_BIN
317
+ // 重新 resolveBinaryPath(runtime),用户修改 PATH / DEEPSEEK_VISIONARY_BIN
244
318
  // 后无需重启 DSH 即生效(懒解析,成本为数次 statSync)。
245
319
  let versionInfo = { known: false, compatible: true, version: "" };
246
- const probeBinary = resolveBinaryPath(config);
320
+ const probeBinary = resolveBinaryPath(runtime);
247
321
  if (probeBinary) {
248
322
  runCli(probeBinary, ["--version"], { timeoutMs: 5000 })
249
323
  .then((r) => {
@@ -273,10 +347,12 @@ function apply(ctx, config) {
273
347
  "",
274
348
  "You have native vision tools backed by DeepSeek's web vision model (no API key):",
275
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",
276
351
  "- `deepseek_vision_status` — check login state",
277
352
  "- `deepseek_vision_login` — browser auto-login",
278
353
  "- `deepseek_vision_logout` — clear saved credentials",
279
354
  "",
355
+ "Use `deepseek_vision` for understanding an image; use `deepseek_ocr` when the user wants the text content of an image verbatim.",
280
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.",
281
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.",
282
358
  ].join("\n"),
@@ -285,95 +361,110 @@ function apply(ctx, config) {
285
361
 
286
362
  // 懒解析:每次工具调用重新定位二进制(PATH / 环境变量改动即时生效)。
287
363
  const requireBinary = () => {
288
- const binary = resolveBinaryPath(config);
364
+ const binary = resolveBinaryPath(runtime);
289
365
  if (!binary) throw new Error(binaryMissingHelp());
290
366
  return binary;
291
367
  };
292
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
+
293
422
  ctx.tools.register(
294
423
  defineTool({
295
424
  name: "deepseek_vision",
296
425
  description:
297
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.",
298
- parameters: {
299
- images: {
300
- type: "array",
301
- items: { type: "string" },
302
- description: "One or more images: local file paths, base64, or data URIs. The model analyzes all of them together.",
303
- },
304
- image: {
305
- type: "string",
306
- description: "Single image (local path, base64, or data URI) — convenience form of `images` with one entry.",
307
- },
308
- prompt: {
309
- type: "string",
310
- description: "Question about the image(s) (default: detailed description in Chinese).",
311
- },
312
- thinking: {
313
- type: "boolean",
314
- description: "Enable DeepThink deep reasoning.",
315
- },
316
- continue_conversation: {
317
- type: "boolean",
318
- description: "Continue the previous session (multi-image comparison across turns).",
319
- },
320
- session_id: {
321
- type: "string",
322
- description: "Reuse an explicit session thread (takes precedence over continue_conversation).",
323
- },
324
- },
325
- output: {
326
- schema: { type: "string" },
327
- 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
+ });
328
443
  },
329
- 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,
330
455
  async execute(args, exec) {
331
- const bin = requireBinary();
332
456
  const imageInputs = Array.isArray(args.images) && args.images.length > 0
333
457
  ? args.images
334
458
  : args.image
335
459
  ? [args.image]
336
460
  : [];
337
- if (imageInputs.length === 0) {
338
- throw new Error("deepseek_vision: at least one image is required (`images` or `image`)");
339
- }
340
- const materialized = [];
341
- try {
342
- for (const image of imageInputs) {
343
- materialized.push(await materializeImage(image));
344
- }
345
- const cliArgs = ["vision", ...materialized.map((m) => m.arg), "--json"];
346
- if (args.prompt) cliArgs.push(`--prompt=${args.prompt}`);
347
- if (args.thinking) cliArgs.push("--thinking");
348
- if (args.session_id) cliArgs.push(`--session-id=${args.session_id}`);
349
- else if (args.continue_conversation) cliArgs.push("--continue-conversation");
350
-
351
- const r = await runCli(bin, cliArgs, {
352
- timeoutMs: config.visionTimeoutMs,
353
- signal: exec.signal,
354
- });
355
- if (r.killed) throw new Error("deepseek_vision was aborted or timed out");
356
- let parsed = null;
357
- try {
358
- parsed = JSON.parse(r.stdout);
359
- } catch {
360
- parsed = null;
361
- }
362
- if (parsed && typeof parsed.error === "string") {
363
- throw new Error(parsed.error);
364
- }
365
- if (parsed && typeof parsed.text === "string") {
366
- const meta = [`session_id: ${parsed.session_id}`, `parent_message_id: ${parsed.parent_message_id}`]
367
- .filter((s) => !s.endsWith(": null") && !s.endsWith(": undefined"))
368
- .join(", ");
369
- return withVersionWarning(meta ? `${parsed.text}\n\n[${meta}]` : parsed.text);
370
- }
371
- throw new Error(
372
- `vision failed (exit ${r.code}): ${(r.stderr || r.stdout).trim() || "unknown error"}`
373
- );
374
- } finally {
375
- for (const m of materialized) if (m.cleanup) await m.cleanup();
376
- }
461
+ return runImageAnalysis({
462
+ subcommand: "ocr",
463
+ name: "deepseek_ocr",
464
+ imageInputs,
465
+ args,
466
+ exec,
467
+ });
377
468
  },
378
469
  })
379
470
  );
@@ -388,11 +479,11 @@ function apply(ctx, config) {
388
479
  schema: { type: "string" },
389
480
  render: (_args, value) => [{ type: "text", text: value }],
390
481
  },
391
- timeoutMs: config.statusTimeoutMs,
482
+ timeoutMs: runtime.statusTimeoutMs,
392
483
  async execute(_args, exec) {
393
484
  const bin = requireBinary();
394
485
  const r = await runCli(bin, ["status", "--json"], {
395
- timeoutMs: config.statusTimeoutMs,
486
+ timeoutMs: runtime.statusTimeoutMs,
396
487
  signal: exec.signal,
397
488
  });
398
489
  if (r.killed) throw new Error("deepseek_vision_status was aborted or timed out");
@@ -461,11 +552,11 @@ function apply(ctx, config) {
461
552
  schema: { type: "string" },
462
553
  render: (_args, value) => [{ type: "text", text: value }],
463
554
  },
464
- timeoutMs: config.statusTimeoutMs,
555
+ timeoutMs: runtime.statusTimeoutMs,
465
556
  async execute(_args, exec) {
466
557
  const bin = requireBinary();
467
558
  const r = await runCli(bin, ["logout"], {
468
- timeoutMs: config.statusTimeoutMs,
559
+ timeoutMs: runtime.statusTimeoutMs,
469
560
  signal: exec.signal,
470
561
  });
471
562
  if (r.killed) throw new Error("deepseek_vision_logout was aborted or timed out");
@@ -478,4 +569,19 @@ function apply(ctx, config) {
478
569
  );
479
570
  }
480
571
 
481
- export { name, inject, Config, apply, resolveBinaryPath, resolveFromNpmShim };
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 };