dsh-tool-vision 0.1.0 → 0.3.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/README.md CHANGED
@@ -1,18 +1,40 @@
1
1
  # dsh-tool-vision
2
2
 
3
+ [![中文文档](https://img.shields.io/badge/%E4%B8%AD%E6%96%87%E6%96%87%E6%A1%A3-blue)](README.zh.md)
4
+
5
+ **GitHub**: [Scorp1o117/dsh-tool-vision](https://github.com/Scorp1o117/dsh-tool-vision) · **npm**: [dsh-tool-vision](https://www.npmjs.com/package/dsh-tool-vision)
6
+
3
7
  External vision model for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness).
4
8
 
5
- DeepSeek's own models are text-only, and dsh-llm has no multimodal content
6
- block yet. This plugin bridges the gap with a model-facing tool that sends an
7
- image (local file, or http(s) URL) to **any OpenAI-compatible**
8
- `/chat/completions` endpoint that supports `image_url` content parts, and
9
- returns the vision model's textual answer into the agent loop.
9
+ DeepSeek's own models are text-only, and the harness derives every model
10
+ request strictly from the session log (`llm/stream` requests must equal the
11
+ durable derivation — the agent-loop invariant). This plugin bridges the gap
12
+ in two ways:
13
+
14
+ 1. **`inspect_image` tool** — sends an image (local file, or http(s) URL) to
15
+ **any OpenAI-compatible** `/chat/completions` endpoint that supports
16
+ `image_url` content parts, and returns the vision model's textual answer
17
+ into the agent loop.
18
+ 2. **Image bridge (v0.2.1)** — pasted images are turned into `inspect_image`
19
+ hints *before they enter the durable log*, on the `agent/pre-step`
20
+ waterfall (the one seam where the harness lets a plugin replace the
21
+ messages of a proposed step). Images already logged by an older version
22
+ are repaired lazily with a surface `replace` on the session's first
23
+ pre-step. Only models listed in `multimodalModels` receive image blocks
24
+ directly; a model's declared `inputModalities` are never consulted,
25
+ because profiles routinely declare `input: [text, image]` on text-only
26
+ models just to pass the harness's prompt-admission check.
10
27
 
11
28
  - Zero dependencies beyond the dsh SDK — works with any compatible endpoint:
12
29
  OpenAI GPT-4o, Qwen-VL (DashScope), GLM-4V (Zhipu), Moonshot, Gemini
13
30
  compatible endpoints, local Ollama, etc.
14
31
  - Registered on the **global tools layer**: every agent in the process can
15
32
  call `inspect_image`.
33
+ - **Web UI settings section (v0.3.0)**: Settings → 视觉模型 edits the
34
+ `tool-vision` namespace (API endpoint, write-only key, model, bridge
35
+ options) in `settings.yaml`; changes hot-apply without a restart. The API
36
+ key lives in `settings.yaml`, not the profile patch. Mount by package name
37
+ (`name: 'dsh-tool-vision'`) so the web client bundle is discovered.
16
38
 
17
39
  ## Install
18
40
 
@@ -47,6 +69,42 @@ Or load it from a local path without npm:
47
69
  | `timeoutMs` | `60000` | Per-request timeout. |
48
70
  | `maxImageBytes` | `10MB` | Largest accepted local image. |
49
71
  | `description` | default | Tool description shown to the model. |
72
+ | `bridgeTextOnly` | `true` | Bridge pasted images to text hints on models that cannot see images. |
73
+ | `bridgeExportDir` | temp | Export dir for bridged images (`os.tmpdir()/dsh-vision-bridge`). |
74
+ | `multimodalModels` | `[]` | Model ids that receive image blocks directly (e.g. `mimo-v2.5`). |
75
+
76
+ ## Image bridge setup
77
+
78
+ 1. In your model settings, declare image input on the models you paste
79
+ images onto, so the harness admits image messages (pi-ai style):
80
+ ```yaml
81
+ llm-pi-ai:
82
+ providers:
83
+ your-provider:
84
+ models:
85
+ - id: deepseek-v4-flash
86
+ input: [text, image]
87
+ ```
88
+ 2. List genuinely multimodal models in the plugin config so they receive
89
+ image blocks untouched:
90
+ ```yaml
91
+ - id: tool-vision
92
+ name: 'dsh-tool-vision'
93
+ config:
94
+ multimodalModels: ['mimo-v2.5', 'grok-4.5']
95
+ ```
96
+
97
+ Then pasting an image while on a text-only model stores a hint like
98
+ `[User sent an image, exported to: <path>. Inspect it with the inspect_image tool...]`
99
+ in the transcript (the pasted image no longer renders as pixels in that
100
+ message), and the agent inspects it through the configured vision endpoint.
101
+
102
+ > Why not `llm/stream`? The harness freezes every request and the agent-loop
103
+ > invariant fails any request whose messages diverge from the session-log
104
+ > derivation (`log-reconstruction desync`), and this cordis waterfall's
105
+ > `next()` cannot replace request arguments. The `agent/pre-step` waterfall is
106
+ > the supported seam: its decision messages *become* the durable log, so the
107
+ > invariant stays satisfied.
50
108
 
51
109
  Key resolution order: `config.apiKey` → `process.env[apiKeyEnv]` →
52
110
  `process.env.OPENAI_API_KEY`.
@@ -69,8 +127,9 @@ Example endpoints (`baseURL`):
69
127
 
70
128
  ## Limitations
71
129
 
72
- - The image enters the conversation as a text description (a transcript, not
73
- pixels) — pixel-precise tasks may be inaccurate.
130
+ - A bridged image enters the conversation as a text hint (a transcript, not
131
+ pixels) — pixel-precise in-context reasoning is not available to text-only
132
+ models; the vision model's description comes back through `inspect_image`.
74
133
  - Images are base64-transferred; mind privacy and size limits.
75
134
  - Independent of the dsh-llm routing/retry system; failures return clear
76
135
  errors to the agent.
package/README.zh.md CHANGED
@@ -1,12 +1,38 @@
1
1
  # dsh-tool-vision
2
2
 
3
- 给 DeepSeek Harness 外接**视觉模型**的工具插件。DeepSeek 自家模型是纯文本的,而 dsh-llm 目前还没有多模态内容块类型——本插件用一条 OpenAI 兼容接口补上这个缺口:
3
+ **GitHub**: [Scorp1o117/dsh-tool-vision](https://github.com/Scorp1o117/dsh-tool-vision) · **npm**: [dsh-tool-vision](https://www.npmjs.com/package/dsh-tool-vision) · [English](README.md)
4
4
 
5
- Agent 调用工具 `inspect_image`,把本地图片(或 http(s) 图片 URL)发给任意兼容 `image_url` 内容块的 `/chat/completions` 端点,把视觉模型的文字回答带回对话。
5
+ 给 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 外接**视觉模型**的插件。
6
6
 
7
- - 注册在**全局工具层**:进程内所有 Agent 都能调用 `inspect_image`
8
- - 支持任意 OpenAI 兼容视觉端点:OpenAI GPT-4o / Qwen-VL (DashScope) / GLM-4V (智谱) / Moonshot / Gemini 兼容端点 / Ollama 本地等
9
- - 本地图片自动转 base64 data URL,检查格式与大小上限
7
+ DeepSeek 自家模型是纯文本的,而且 harness 的每次模型请求都**严格从会话日志推导**(`llm/stream` 请求必须与持久化推导一致,否则 agent-loop invariant 会报 `log-reconstruction desync`)。本插件用两条路径补上缺口:
8
+
9
+ 1. **`inspect_image` 工具** —— 把图片(本地文件或 http(s) URL)发给任意支持 `image_url` 内容块的 OpenAI 兼容 `/chat/completions` 端点,把视觉模型的文字回答带回对话。
10
+ 2. **图片桥(v0.2.1)** —— 粘贴的图片在**进入持久化日志之前**就被转换成 `inspect_image` 指引文本,拦截点是 `agent/pre-step` waterfall(这是 harness 唯一允许插件替换"进入某一步的消息"的缝;替换后的消息会**成为**持久化的 `user/message` 日志,所以请求重建 invariant 天然满足)。旧版本已经写进日志的图片消息,会在该会话下一次 pre-step 时用 surface `replace` 惰性修复。只有 `multimodalModels` 白名单内的模型直收图片块;**不参考模型的 `inputModalities` 声明**——因为很多配置为了通过 prompt 准入检查,会给纯文本模型声明 `input: [text, image]`(那只是声明,不代表上游真的能吃 `image_url`)。
11
+
12
+ - 除 dsh SDK 外零依赖 —— 兼容任意端点:OpenAI GPT-4o、Qwen-VL(DashScope)、GLM-4V(智谱)、Moonshot、Gemini 兼容端点、本地 Ollama 等。
13
+ - 注册在**全局工具层**:进程内所有 Agent 都能调用 `inspect_image`。
14
+ - **Web UI 设置栏(v0.3.0)**:设置 → 视觉模型 编辑 `tool-vision` 命名空间(API 地址、只写密钥、模型、桥接选项),写入 `settings.yaml`,**改动即时生效无需重启**。API 密钥存放在 `settings.yaml` 而非 profile patch;插件按包名挂载(`name: 'dsh-tool-vision'`)以便 web 端发现客户端 bundle。
15
+
16
+ ## 安装
17
+
18
+ 在 profile patch(`$DSH_HOME/profiles/<name>/cordis.patch.yml`)里挂载:
19
+
20
+ ```yaml
21
+ - insert:
22
+ - id: tool-vision
23
+ name: 'dsh-tool-vision' # 前置:在 profile 里 pnpm add dsh-tool-vision
24
+ config:
25
+ baseURL: 'https://api.openai.com/v1'
26
+ apiKeyEnv: 'VISION_API_KEY'
27
+ model: 'gpt-4o-mini'
28
+ ```
29
+
30
+ 不装 npm 包、直接加载本地路径:
31
+
32
+ ```yaml
33
+ - id: tool-vision
34
+ name: './plugins/dsh-tool-vision/index.js'
35
+ ```
10
36
 
11
37
  ## 配置
12
38
 
@@ -20,41 +46,59 @@ Agent 调用工具 `inspect_image`,把本地图片(或 http(s) 图片 URL)
20
46
  | `timeoutMs` | `60000` | 单次请求超时 |
21
47
  | `maxImageBytes` | `10MB` | 本地图片大小上限 |
22
48
  | `description` | 默认描述 | 工具描述(模型可见) |
49
+ | `bridgeTextOnly` | `true` | 把粘贴图片转成文本指引(发给看不懂图片的模型时) |
50
+ | `bridgeExportDir` | 临时目录 | 桥接图片导出目录(`os.tmpdir()/dsh-vision-bridge`) |
51
+ | `multimodalModels` | `[]` | 直发图片块的模型 id(如 `mimo-v2.5`) |
23
52
 
24
- 密钥解析顺序:`config.apiKey` → `process.env[apiKeyEnv]` → `process.env.OPENAI_API_KEY`。
53
+ ## 图片桥配置
25
54
 
26
- ## 挂载
55
+ 1. 在模型设置里给要贴图的模型声明图片输入(pi-ai 风格),让 harness 放行图片消息:
56
+ ```yaml
57
+ llm-pi-ai:
58
+ providers:
59
+ your-provider:
60
+ models:
61
+ - id: deepseek-v4-flash
62
+ input: [text, image]
63
+ ```
64
+ 2. 在插件配置里列出真正多模态的模型,让它们直收图片块:
65
+ ```yaml
66
+ - id: tool-vision
67
+ name: 'dsh-tool-vision'
68
+ config:
69
+ multimodalModels: ['mimo-v2.5', 'grok-4.5']
70
+ ```
27
71
 
28
- 在 profile 的 `cordis.patch.yml` 里 insert:
72
+ 之后在文本模型下贴图,转录里会留下一条指引:
73
+ `[User sent an image, exported to: <path>. Inspect it with the inspect_image tool...]`
74
+ (该消息不再以像素图形式渲染),Agent 会调用视觉端点查看并把结果带回对话。
29
75
 
30
- ```yaml
31
- - insert:
32
- - id: tool-vision
33
- name: './plugins/dsh-tool-vision/index.js'
34
- config:
35
- baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1'
36
- apiKeyEnv: 'DASHSCOPE_API_KEY'
37
- model: 'qwen-vl-plus'
38
- ```
76
+ > 为什么不用 `llm/stream`?harness 会冻结每个请求,且 agent-loop invariant 会拒绝任何与会话日志推导不一致的请求;这个 cordis 版本的 waterfall `next()` 也无法替换请求参数。`agent/pre-step` 才是受支持的缝:它的决策消息**会成为**持久化日志,invariant 天然成立。
77
+
78
+ 密钥解析顺序:`config.apiKey` → `process.env[apiKeyEnv]` → `process.env.OPENAI_API_KEY`。
39
79
 
40
80
  ## 工具:`inspect_image`
41
81
 
42
- | 参数 | 必填 | 说明 |
82
+ | 参数 | 必填 | 含义 |
43
83
  |---|---|---|
44
- | `path` | ✅ | 图片路径(绝对路径,或相对当前 workspace)或 http(s) URL |
45
- | `question` | – | 针对图片的具体问题;缺省为"详细描述" |
46
- | `detail` | – | `auto` / `low` / `high`,图片分辨率提示 |
84
+ | `path` | ✅ | 图片路径(绝对路径,或相对当前工作区)或 http(s) URL |
85
+ | `question` | – | 可选的具体问题 |
86
+ | `detail` | – | `auto` / `low` / `high` 分辨率提示 |
87
+
88
+ 示例端点(`baseURL`):
89
+
90
+ - **OpenAI**:`https://api.openai.com/v1` —— `gpt-4o`、`gpt-4o-mini`
91
+ - **阿里云 DashScope(Qwen-VL)**:`https://dashscope.aliyuncs.com/compatible-mode/v1` —— `qwen-vl-plus`、`qwen-vl-max`
92
+ - **智谱(GLM-4V)**:`https://open.bigmodel.cn/api/paas/v4` —— `glm-4v-flash`(免费档)、`glm-4v-plus`
93
+ - **Moonshot(Kimi)**:`https://api.moonshot.cn/v1` —— `moonshot-v1-8k-vision-preview`
94
+ - **Ollama 本地**:`http://localhost:11434/v1` —— `llama3.2-vision`(无需密钥)
47
95
 
48
- 示例端点(`baseURL` 写法):
96
+ ## 限制
49
97
 
50
- - **OpenAI**:`https://api.openai.com/v1`,模型 `gpt-4o` / `gpt-4o-mini`
51
- - **阿里云百炼 (Qwen-VL)**:`https://dashscope.aliyuncs.com/compatible-mode/v1`,模型 `qwen-vl-plus` / `qwen-vl-max`
52
- - **智谱 (GLM-4V)**:`https://open.bigmodel.cn/api/paas/v4`,模型 `glm-4v-flash`(免费档)/ `glm-4v-plus`
53
- - **Moonshot (Kimi)**:`https://api.moonshot.cn/v1`,模型 `moonshot-v1-8k-vision-preview`
54
- - **Ollama 本地**:`http://localhost:11434/v1`,模型 `llama3.2-vision`(无需 key)
98
+ - 被桥接的图片以文本指引进入对话(转录而非像素)——文本模型无法做像素级上下文推理;视觉模型的描述通过 `inspect_image` 回传。
99
+ - 图片以 base64 传输;注意隐私与大小限制。
100
+ - 独立于 dsh-llm 的路由/重试体系;失败会向 Agent 返回明确错误。
55
101
 
56
- ## 局限
102
+ ## License
57
103
 
58
- - 图片内容以文字描述进入对话,Agent 得到的是"转述"而非像素——精确到像素的任务(如测量)可能不够准
59
- - 图片会在请求中 base64 传输,注意隐私与大小
60
- - 不依赖 dsh-llm 服务,因此不走模型路由/重试体系;失败会以明确的错误信息返回给 Agent
104
+ MIT
package/client.js ADDED
@@ -0,0 +1,274 @@
1
+ /**
2
+ * dsh-tool-vision — browser half.
3
+ *
4
+ * A "视觉模型" section inside the Web UI settings page: edits the
5
+ * `tool-vision` settings namespace (API endpoint, key, model, bridge
6
+ * options) through the settings scope transport. Changes hot-apply via the
7
+ * host settings provider — no restart needed.
8
+ *
9
+ * Hand-written ModuleLoader bundle — no build step required.
10
+ */
11
+ window.__ModuleLoader__.load({
12
+ id: "dsh-tool-vision",
13
+ factory: (require) => {
14
+ var module = { exports: {} };
15
+ var exports = module.exports;
16
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
17
+ var react = require("react");
18
+ var h = react.createElement;
19
+
20
+ // ── CSS (theme tokens) ────────────────────────────────────────────────
21
+ var CSS = ".__tv_root{max-width:640px;display:flex;flex-direction:column;gap:10px}" +
22
+ ".__tv_field{display:flex;flex-direction:column;gap:4px}" +
23
+ ".__tv_label{font-size:12px;font-weight:600;color:var(--dsw-alias-label-primary);display:flex;align-items:center;gap:6px}" +
24
+ ".__tv_override{font-size:10px;color:var(--dsw-alias-state-business-primary);border:1px solid var(--dsw-alias-border-l2);border-radius:4px;padding:0 4px}" +
25
+ ".__tv_hint{font-size:11px;color:var(--dsw-alias-label-tertiary)}" +
26
+ ".__tv_input{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);font:inherit;color:var(--dsw-alias-label-primary);border-radius:8px;padding:6px 10px;font-size:13px;box-sizing:border-box;width:100%}" +
27
+ ".__tv_row{display:flex;align-items:center;gap:8px}" +
28
+ ".__tv_check{accent-color:var(--dsw-alias-state-business-primary)}" +
29
+ ".__tv_actions{display:flex;gap:8px;align-items:center;margin-top:4px}" +
30
+ ".__tv_btn{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);border-radius:8px;padding:6px 14px;font:inherit;font-size:13px;cursor:pointer}" +
31
+ ".__tv_btn:hover:not(:disabled){border-color:var(--dsw-alias-state-business-primary)}" +
32
+ ".__tv_btn:disabled{opacity:.5;cursor:default}" +
33
+ ".__tv_btnPrimary{border-color:var(--dsw-alias-state-business-primary);background:var(--dsw-alias-state-business-primary);color:var(--dsw-alias-label-on-accent)}" +
34
+ ".__tv_status{font-size:12px;color:var(--dsw-alias-label-tertiary)}" +
35
+ ".__tv_error{font-size:12px;color:var(--dsw-alias-state-error-primary)}" +
36
+ ".__tv_unavailable{font-size:13px;color:var(--dsw-alias-label-tertiary)}";
37
+ var tagId = "dsh-tool-vision/main.css";
38
+ if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
39
+ var tag = document.createElement("style");
40
+ tag.dataset.plugin = "dsh-tool-vision";
41
+ tag.dataset.pluginCss = tagId;
42
+ tag.textContent = CSS;
43
+ document.head.appendChild(tag);
44
+ }
45
+
46
+ // ── locale ────────────────────────────────────────────────────────────
47
+ var NS = "toolVision";
48
+ var inject = ["slots", "locale", "settingsScope"];
49
+ var zh = {
50
+ nav: "视觉模型",
51
+ intro: "外置视觉模型配置:Agent 通过 inspect_image 工具把图片发给该端点分析。修改后即时生效(settings.yaml 热重载)。",
52
+ apiKeyHint: "留空保持当前密钥。密钥只写不读,不会回显。",
53
+ maxTokens: "最大输出 Tokens",
54
+ timeoutMs: "请求超时(毫秒)",
55
+ maxImageBytes: "本地图片大小上限(字节)",
56
+ bridgeTextOnly: "图片桥接(文本模型贴图自动转 inspect_image 指引)",
57
+ bridgeExportDir: "桥接图片导出目录(空 = 系统临时目录)",
58
+ multimodalModels: "多模态白名单(逗号分隔,这些模型直收图片块)",
59
+ save: "保存",
60
+ reset: "恢复默认",
61
+ saved: "已保存",
62
+ saving: "保存中…",
63
+ error: "保存失败",
64
+ unavailable: "设置命名空间不可用(服务端未注册 tool-vision 命名空间?)",
65
+ overridden: "已覆盖",
66
+ loading: "加载中…"
67
+ };
68
+ var en = {
69
+ nav: "Vision Model",
70
+ intro: "External vision model config: the agent sends images to this endpoint via the inspect_image tool. Changes apply immediately (settings.yaml hot-reload).",
71
+ apiKeyHint: "Leave blank to keep the current key. The key is write-only and never echoed.",
72
+ maxTokens: "Max output tokens",
73
+ timeoutMs: "Request timeout (ms)",
74
+ maxImageBytes: "Max local image size (bytes)",
75
+ bridgeTextOnly: "Image bridge (pasted images on text-only models become inspect_image hints)",
76
+ bridgeExportDir: "Bridge export dir (empty = system temp)",
77
+ multimodalModels: "Multimodal whitelist (comma-separated; these models receive image blocks directly)",
78
+ save: "Save",
79
+ reset: "Reset",
80
+ saved: "Saved",
81
+ saving: "Saving…",
82
+ error: "Save failed",
83
+ unavailable: "Settings namespace unavailable (tool-vision namespace not registered server-side?)",
84
+ overridden: "overridden",
85
+ loading: "Loading…"
86
+ };
87
+
88
+ // ── field spec ────────────────────────────────────────────────────────
89
+ var FIELDS = [
90
+ { key: "baseURL", label: "API Base URL", type: "text", placeholder: "https://api.openai.com/v1" },
91
+ { key: "apiKey", label: "API Key", type: "password", secret: true },
92
+ { key: "apiKeyEnv", label: "API Key 环境变量(apiKey 为空时读取)", type: "text" },
93
+ { key: "model", label: "视觉模型", type: "text", placeholder: "gpt-4o-mini" },
94
+ { key: "maxTokens", label: "最大输出 Tokens", type: "number" },
95
+ { key: "timeoutMs", label: "请求超时(毫秒)", type: "number" },
96
+ { key: "maxImageBytes", label: "图片大小上限(字节)", type: "number" },
97
+ { key: "bridgeTextOnly", label: "图片桥接开关", type: "checkbox" },
98
+ { key: "bridgeExportDir", label: "桥接导出目录", type: "text" },
99
+ { key: "multimodalModels", label: "多模态白名单(逗号分隔)", type: "csv" }
100
+ ];
101
+ var ZH_HINTS = {
102
+ apiKey: "apiKeyHint",
103
+ maxTokens: "maxTokens",
104
+ timeoutMs: "timeoutMs",
105
+ maxImageBytes: "maxImageBytes",
106
+ bridgeTextOnly: "bridgeTextOnly",
107
+ bridgeExportDir: "bridgeExportDir",
108
+ multimodalModels: "multimodalModels"
109
+ };
110
+
111
+ function labelOf(f) {
112
+ return f.label;
113
+ }
114
+
115
+ // ── component ─────────────────────────────────────────────────────────
116
+ function VisionSection(props) {
117
+ var t = props.t;
118
+ var scope = props.scope;
119
+ var snapshot = react.useSyncExternalStore(
120
+ function (cb) { return scope.subscribe(cb); },
121
+ function () { return scope.getSnapshot(); }
122
+ );
123
+ var ready = snapshot.status === "ready" && snapshot.value !== void 0;
124
+ var [draft, setDraft] = react.useState({});
125
+ var [busy, setBusy] = react.useState(false);
126
+ var [notice, setNotice] = react.useState(null);
127
+ var [error, setError] = react.useState(null);
128
+
129
+ react.useEffect(function () {
130
+ scope.load();
131
+ return function () { scope.dispose(); };
132
+ }, [scope]);
133
+ // Re-seed the draft when the snapshot value changes (after a save or a host change).
134
+ react.useEffect(function () {
135
+ if (ready) setDraft(function (prev) { return Object.assign({}, prev, valueToDraft(snapshot.value)); });
136
+ // eslint-disable-next-line react-hooks/exhaustive-deps
137
+ }, [ready, snapshot.value]);
138
+
139
+ if (snapshot.status === "unavailable") {
140
+ return h("p", { className: "__tv_unavailable" }, t("unavailable"));
141
+ }
142
+ if (!ready) return h("p", { className: "__tv_status" }, t("loading"));
143
+
144
+ var value = snapshot.value;
145
+ var user = snapshot.user || {};
146
+
147
+ function fieldDraft(f) {
148
+ if (f.type === "csv") return draft[f.key] !== void 0 ? draft[f.key] : draftToCsv(value[f.key]);
149
+ if (f.type === "checkbox") return draft[f.key] !== void 0 ? draft[f.key] : Boolean(value[f.key]);
150
+ return draft[f.key] !== void 0 ? draft[f.key] : String(value[f.key] ?? "");
151
+ }
152
+ function setField(f, v) {
153
+ setDraft(function (prev) {
154
+ var next = Object.assign({}, prev);
155
+ next[f.key] = v;
156
+ return next;
157
+ });
158
+ setNotice(null);
159
+ setError(null);
160
+ }
161
+
162
+ function onSave() {
163
+ setBusy(true); setNotice(null); setError(null);
164
+ var writes = FIELDS.map(function (f) {
165
+ var d = fieldDraft(f);
166
+ if (f.type === "csv") {
167
+ var arr = String(d).split(",").map(function (s) { return s.trim(); }).filter(Boolean);
168
+ var cur = value[f.key] || [];
169
+ if (arr.length === cur.length && arr.every(function (x, i) { return x === cur[i]; })) return Promise.resolve();
170
+ return scope.set(f.key, arr);
171
+ }
172
+ if (f.type === "checkbox") {
173
+ if (Boolean(d) === Boolean(value[f.key])) return Promise.resolve();
174
+ return Boolean(d) ? scope.set(f.key, true) : scope.unset(f.key);
175
+ }
176
+ if (f.type === "password") {
177
+ if (!d) return Promise.resolve(); // blank keeps the current key
178
+ if (d === String(value[f.key] ?? "")) return Promise.resolve();
179
+ return scope.set(f.key, d);
180
+ }
181
+ if (String(d) === String(value[f.key] ?? "")) return Promise.resolve();
182
+ if (String(d).trim() === "" && !(f.key in user)) return Promise.resolve();
183
+ return String(d).trim() === "" ? scope.unset(f.key) : scope.set(f.key, f.type === "number" ? Number(d) : d);
184
+ });
185
+ Promise.all(writes).then(function () {
186
+ setBusy(false); setNotice(t("saved"));
187
+ }).catch(function (e) {
188
+ setBusy(false); setError(t("error") + ": " + String(e && e.message || e));
189
+ });
190
+ }
191
+
192
+ function onReset() {
193
+ setBusy(true); setNotice(null); setError(null);
194
+ Promise.all(FIELDS.map(function (f) { return scope.unset(f.key); })).then(function () {
195
+ setBusy(false); setNotice(t("saved"));
196
+ }).catch(function (e) {
197
+ setBusy(false); setError(t("error") + ": " + String(e && e.message || e));
198
+ });
199
+ }
200
+
201
+ return h("div", { className: "__tv_root" },
202
+ h("p", { className: "__tv_hint", style: { margin: "0 0 4px" } }, t("intro")),
203
+ FIELDS.map(function (f) {
204
+ var overridden = f.key in user;
205
+ if (f.type === "checkbox") {
206
+ return h("label", { key: f.key, className: "__tv_field" },
207
+ h("span", { className: "__tv_row" },
208
+ h("input", { className: "__tv_check", type: "checkbox", checked: Boolean(fieldDraft(f)), onChange: function (e) { setField(f, e.target.checked); } }),
209
+ h("span", { className: "__tv_label" }, labelOf(f)),
210
+ overridden ? h("span", { className: "__tv_override" }, t("overridden")) : null
211
+ ),
212
+ f.key in ZH_HINTS ? h("span", { className: "__tv_hint" }, t(ZH_HINTS[f.key])) : null
213
+ );
214
+ }
215
+ return h("label", { key: f.key, className: "__tv_field" },
216
+ h("span", { className: "__tv_label" },
217
+ labelOf(f),
218
+ overridden ? h("span", { className: "__tv_override" }, t("overridden")) : null
219
+ ),
220
+ h("input", {
221
+ className: "__tv_input",
222
+ type: f.type === "password" ? "password" : f.type === "number" ? "number" : "text",
223
+ value: fieldDraft(f),
224
+ placeholder: f.type === "password" ? (overridden ? "••••••••" : t("apiKeyHint")) : (f.placeholder || ""),
225
+ onChange: function (e) { setField(f, e.target.value); }
226
+ }),
227
+ f.key in ZH_HINTS ? h("span", { className: "__tv_hint" }, t(ZH_HINTS[f.key])) : null
228
+ );
229
+ }),
230
+ h("div", { className: "__tv_actions" },
231
+ h("button", { type: "button", className: "__tv_btn __tv_btnPrimary", onClick: onSave, disabled: busy || !snapshot.writable }, t("save")),
232
+ h("button", { type: "button", className: "__tv_btn", onClick: onReset, disabled: busy || !snapshot.writable }, t("reset")),
233
+ notice ? h("span", { className: "__tv_status" }, notice) : null,
234
+ busy ? h("span", { className: "__tv_status" }, t("saving")) : null,
235
+ error ? h("span", { className: "__tv_error" }, error) : null
236
+ )
237
+ );
238
+ }
239
+
240
+ function valueToDraft(value) {
241
+ var out = {};
242
+ for (var i = 0; i < FIELDS.length; i += 1) {
243
+ var f = FIELDS[i];
244
+ out[f.key] = f.type === "csv" ? draftToCsv(value[f.key]) : f.type === "checkbox" ? Boolean(value[f.key]) : String(value[f.key] ?? "");
245
+ }
246
+ return out;
247
+ }
248
+ function draftToCsv(arr) {
249
+ return Array.isArray(arr) ? arr.join(", ") : String(arr ?? "");
250
+ }
251
+
252
+ // ── plugin ────────────────────────────────────────────────────────────
253
+ function apply(ctx) {
254
+ var t = ctx.locale.bind(NS);
255
+ ctx.effect(function () { return ctx.locale.register(NS, { zh: zh, en: en }); }, "dsh-tool-vision: dictionaries");
256
+ var scope = ctx.settingsScope.bind({ namespace: "tool-vision" });
257
+ ctx.slots.inject("settings.section", function () {
258
+ return ctx.slots.register({
259
+ name: "settings.section",
260
+ id: "tool-vision",
261
+ order: 25,
262
+ label: function () { return t("nav"); },
263
+ locale: NS
264
+ }, function (props) {
265
+ return h(VisionSection, Object.assign({}, props, { scope: scope }));
266
+ });
267
+ });
268
+ }
269
+
270
+ exports.apply = apply;
271
+ exports.inject = inject;
272
+ return module.exports;
273
+ }
274
+ });
package/index.js CHANGED
@@ -1,24 +1,41 @@
1
1
  /**
2
2
  * dsh-tool-vision — external vision model for DeepSeek Harness.
3
3
  *
4
- * DeepSeek's own models are text-only, and dsh-llm has no multimodal content
5
- * block type yet. This plugin bridges the gap with a model-facing tool that
6
- * sends an image (local file, or http(s) URL) to any OpenAI-compatible
7
- * chat/completions endpoint that supports `image_url` content parts, and
8
- * returns the vision model's textual answer into the agent loop.
4
+ * Two capabilities:
9
5
  *
10
- * Registered on the GLOBAL tools layer, so every agent in the process can
11
- * call `inspect_image`.
6
+ * 1. `inspect_image` tool — sends an image (local file, or http(s) URL) to
7
+ * any OpenAI-compatible chat/completions endpoint that supports
8
+ * `image_url` content parts, and returns the vision model's text answer.
9
+ *
10
+ * 2. Image bridge — pasted images are bridged to text hints before they
11
+ * enter a text-only model's request:
12
+ *
13
+ * - New images are bridged on the `agent/pre-step` waterfall (the only
14
+ * seam where the harness lets a plugin replace the messages that enter
15
+ * a step — they become the durable `user/message` log, so the
16
+ * `llm/stream` request-reconstruction invariant stays satisfied).
17
+ * - Images already logged before the plugin was installed (or before a
18
+ * server restart) are repaired lazily with a surface `replace`, one
19
+ * event at a time, on the first pre-step of the session.
20
+ *
21
+ * The bridged hint points at an exported local copy of the image, which
22
+ * the agent hands to `inspect_image`. Models listed in
23
+ * `multimodalModels` (or whose resolved `inputModalities` include
24
+ * "image") receive image blocks directly and are never bridged.
12
25
  */
13
- import { readFile, stat } from "node:fs/promises";
14
- import { extname, isAbsolute, resolve as resolvePath } from "node:path";
26
+ import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
27
+ import { extname, isAbsolute, join, resolve as resolvePath } from "node:path";
28
+ import os from "node:os";
15
29
  import z from "@deepseek-ai/schemastery";
16
30
  import { defineTool } from "@deepseek-ai/dsh-tools";
31
+ import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
17
32
 
18
33
  /** Cordis plugin name. */
19
34
  const name = "tool-vision";
20
- /** The tool registry this row contributes to. */
21
- const inject = ["tools"];
35
+ /** The tool registry, the llm seam (model capability lookup), and the attachment store. */
36
+ const inject = ["tools", "llm", "attachments"];
37
+ /** Settings namespace owned by this plugin (Web UI settings section). */
38
+ const NS = settingsNamespace("tool-vision");
22
39
 
23
40
  const DEFAULT_DESCRIPTION =
24
41
  "Analyze an image using an external vision-capable model through an OpenAI-compatible API. " +
@@ -31,20 +48,26 @@ const DEFAULT_DESCRIPTION =
31
48
  const Config = z.object({
32
49
  /** Base URL of an OpenAI-compatible API, e.g. https://api.openai.com/v1 or https://dashscope.aliyuncs.com/compatible-mode/v1 */
33
50
  baseURL: z.string().default("https://api.openai.com/v1"),
34
- /** API key; takes precedence over apiKeyEnv. */
35
- apiKey: z.string().default(""),
51
+ /** API key; takes precedence over apiKeyEnv. Rendered as a write-only secret in the Web UI. */
52
+ apiKey: z.string().default("").role("secret"),
36
53
  /** Environment variable holding the API key. */
37
54
  apiKeyEnv: z.string().default("VISION_API_KEY"),
38
55
  /** Vision model id served by the endpoint. */
39
56
  model: z.string().default("gpt-4o-mini"),
40
57
  /** Max output tokens for the vision call. */
41
- maxTokens: z.number().default(1024),
58
+ maxTokens: z.number().default(4096),
42
59
  /** Per-request timeout in milliseconds. */
43
60
  timeoutMs: z.number().default(60000),
44
61
  /** Largest local image accepted, in bytes. */
45
62
  maxImageBytes: z.number().default(10 * 1024 * 1024),
46
63
  /** Tool description shown to the model; overrides the default. */
47
64
  description: z.string().default(DEFAULT_DESCRIPTION),
65
+ /** Bridge pasted images to text hints on models that cannot see images. */
66
+ bridgeTextOnly: z.boolean().default(true),
67
+ /** Export directory for bridged images; empty = system temp. */
68
+ bridgeExportDir: z.string().default(""),
69
+ /** Model ids that receive image blocks directly (never bridged). */
70
+ multimodalModels: z.array(z.string()).default([]),
48
71
  });
49
72
 
50
73
  const MIME_BY_EXT = {
@@ -59,6 +82,179 @@ const MIME_BY_EXT = {
59
82
  ".ico": "image/x-icon",
60
83
  };
61
84
 
85
+ const EXT_BY_MEDIA = {
86
+ "image/png": ".png",
87
+ "image/jpeg": ".jpg",
88
+ "image/webp": ".webp",
89
+ "image/gif": ".gif",
90
+ };
91
+
92
+ /** True when any message carries an image content block. */
93
+ function hasImageBlock(messages) {
94
+ return (messages ?? []).some((m) =>
95
+ Array.isArray(m?.content) && m.content.some((b) => b?.type === "image"),
96
+ );
97
+ }
98
+
99
+ /** Deep-freeze an acyclic JSON-safe value in place (the harness freezes every durable message). */
100
+ function deepFreeze(value) {
101
+ if (value === null || typeof value !== "object" || Object.isFrozen(value)) return value;
102
+ if (Array.isArray(value)) {
103
+ for (const item of value) deepFreeze(item);
104
+ return Object.freeze(value);
105
+ }
106
+ for (const key of Object.keys(value)) deepFreeze(value[key]);
107
+ return Object.freeze(value);
108
+ }
109
+
110
+ /** Export one attachment to disk; returns the file path (cached per process). */
111
+ const exportedPaths = new Map();
112
+ async function exportImage(attachment, ctx, dir) {
113
+ const cached = exportedPaths.get(attachment.attachmentId);
114
+ if (cached) return cached;
115
+ const { data } = await ctx.attachments.readImage(attachment);
116
+ const ext = EXT_BY_MEDIA[attachment.mediaType] ?? ".img";
117
+ const safeName = attachment.name
118
+ ? attachment.name
119
+ .replace(/\.[^.]+$/, "")
120
+ .replace(/[^\w\-]+/g, "_")
121
+ .replace(/^_+|_+$/g, "")
122
+ .slice(0, 40)
123
+ : "";
124
+ const base = (safeName ? `${safeName}_` : "") + attachment.attachmentId.slice(0, 12);
125
+ const path = join(dir, `${base}${ext}`);
126
+ await writeFile(path, data);
127
+ exportedPaths.set(attachment.attachmentId, path);
128
+ return path;
129
+ }
130
+
131
+ /**
132
+ * Replace image content blocks with text hints pointing at exported files.
133
+ * Non-image messages are returned as-is (same reference); bridged messages
134
+ * are fresh, deep-frozen objects with the original identity and source.
135
+ * Exported for unit testing; `ctx` only needs `attachments`.
136
+ */
137
+ async function bridgeMessages(messages, ctx, dir) {
138
+ const next = [];
139
+ for (const message of messages) {
140
+ const content = message?.content;
141
+ if (!Array.isArray(content) || !content.some((b) => b?.type === "image")) {
142
+ next.push(message);
143
+ continue;
144
+ }
145
+ const blocks = [];
146
+ for (const block of content) {
147
+ if (block?.type !== "image") {
148
+ blocks.push(block);
149
+ continue;
150
+ }
151
+ const path = await exportImage(block.attachment, ctx, dir);
152
+ const name = block.attachment.name ? ` (${block.attachment.name})` : "";
153
+ blocks.push({
154
+ type: "text",
155
+ text:
156
+ `[User sent an image${name}, exported to: ${path}. ` +
157
+ `Inspect it with the inspect_image tool to see its content.]`,
158
+ });
159
+ }
160
+ next.push(deepFreeze({ ...message, content: blocks }));
161
+ }
162
+ return next;
163
+ }
164
+
165
+ /**
166
+ * Whether the session's current model may receive image blocks directly.
167
+ * Uses the last logged request header first, then the agent's own options,
168
+ * and consults ONLY the `multimodalModels` whitelist — never the model's
169
+ * declared `inputModalities`, because profiles routinely declare
170
+ * `input: [text, image]` on text-only models to pass the harness's prompt
171
+ * admission check (that declaration says nothing about whether the upstream
172
+ * endpoint really accepts `image_url` parts).
173
+ * Returns true when bridging is disabled (nothing would be bridged anyway).
174
+ */
175
+ async function currentModelAcceptsImage(agent, config) {
176
+ if (!config.bridgeTextOnly) return true;
177
+ const header = agent?.session?.requestHeader?.();
178
+ const model = header?.config?.model ?? agent?.options?.model;
179
+ if (!model) return false;
180
+ return config.multimodalModels.includes(model);
181
+ }
182
+
183
+ /**
184
+ * Lazily bridge image blocks that are already part of the session log
185
+ * (pasted before the plugin was active, or before a restart). Each affected
186
+ * event is rewritten once with a surface `replace`, which swaps the durable
187
+ * derivation (and the transcript) to the text hint. Events that are no
188
+ * longer on the surface (already shadowed) are skipped and remembered.
189
+ * `repaired` tracks per-session state: a `Set` of handled seqs plus a
190
+ * monotonic scan cursor.
191
+ */
192
+ async function repairLoggedImages(ctx, session, exportDir, repaired) {
193
+ const events = session.events;
194
+ for (let index = repaired.cursor; index < events.length; index += 1) {
195
+ const event = events[index];
196
+ if (event.type !== "user/message" || repaired.set.has(event.seq)) {
197
+ repaired.set.add(event.seq);
198
+ continue;
199
+ }
200
+ const content = event.data?.content;
201
+ if (!Array.isArray(content) || !content.some((b) => b?.type === "image")) {
202
+ repaired.set.add(event.seq);
203
+ continue;
204
+ }
205
+ const [bridged] = await bridgeMessages([event.data], ctx, exportDir);
206
+ try {
207
+ session.append("user/message", bridged, {
208
+ surfaceOp: { op: "replace", start: event.seq, end: event.seq },
209
+ sourceEventSeqs: [event.seq],
210
+ });
211
+ ctx.logger.info(`[tool-vision] bridged logged image at seq ${event.seq} (${session.id})`);
212
+ } catch (error) {
213
+ ctx.logger.debug(`[tool-vision] skip repair of seq ${event.seq}: ${String(error)}`);
214
+ }
215
+ repaired.set.add(event.seq);
216
+ }
217
+ repaired.cursor = events.length;
218
+ }
219
+
220
+ /**
221
+ * Install the pre-step bridge at the root level. Agent-scoped waterfalls
222
+ * admit untagged (root) listeners, so one listener serves every agent —
223
+ * including sessions resumed after a server restart, which never re-fire
224
+ * `session/created` for per-agent attachments. Runs before every proposed
225
+ * step: new pasted images are bridged into the durable log, and stuck
226
+ * logged images are repaired, before the model request is derived from it.
227
+ */
228
+ function attachPreStepBridge(ctx, getConfig, exportDir) {
229
+ const repairedBySession = new Map();
230
+ ctx.on("agent/pre-step", async (payload, next) => {
231
+ const decision = await next();
232
+ if (!decision || decision.kind !== "enter") return decision;
233
+ const agent = payload?.agent;
234
+ if (!agent?.session) return decision;
235
+ try {
236
+ const acceptsImage = await currentModelAcceptsImage(agent, getConfig());
237
+ if (!acceptsImage) {
238
+ let repaired = repairedBySession.get(agent.session.id);
239
+ if (!repaired) {
240
+ repaired = { set: new Set(), cursor: 0 };
241
+ repairedBySession.set(agent.session.id, repaired);
242
+ }
243
+ await repairLoggedImages(ctx, agent.session, exportDir, repaired).catch((error) => {
244
+ ctx.logger.warn(`[tool-vision] logged-image repair failed: ${String(error)}`);
245
+ });
246
+ }
247
+ if (acceptsImage) return decision;
248
+ const messages = await bridgeMessages(decision.messages, ctx, exportDir);
249
+ if (messages.every((message, index) => message === decision.messages[index])) return decision;
250
+ return { ...decision, messages };
251
+ } catch (error) {
252
+ ctx.logger.warn(`[tool-vision] pre-step bridge failed: ${String(error)}`);
253
+ return decision;
254
+ }
255
+ });
256
+ }
257
+
62
258
  function resolveApiKey(config) {
63
259
  if (config.apiKey) return config.apiKey;
64
260
  if (config.apiKeyEnv) {
@@ -134,7 +330,13 @@ async function callVision(config, imageUrl, question, detail, signal) {
134
330
  `vision endpoint returned ${response.status}: ${detailText} (endpoint ${endpoint})`,
135
331
  );
136
332
  }
137
- const answer = body?.choices?.[0]?.message?.content;
333
+ // Reasoning models (mimo-v2.5, deepseek-r1, ...) spend the token budget on
334
+ // `reasoning_content` first; when the final `content` is empty or was cut
335
+ // off by max_tokens, fall back to the reasoning text so the answer is
336
+ // still useful.
337
+ const message = body?.choices?.[0]?.message;
338
+ let answer = message?.content ?? "";
339
+ if (!answer.trim()) answer = message?.reasoning_content ?? "";
138
340
  if (typeof answer !== "string" || !answer.trim()) {
139
341
  throw new Error("vision endpoint returned an empty response");
140
342
  }
@@ -146,9 +348,32 @@ async function callVision(config, imageUrl, question, detail, signal) {
146
348
  }
147
349
 
148
350
  function apply(ctx, config) {
351
+ // ── settings-backed configuration ─────────────────────────────────────────
352
+ // The composition entry stays the `base` layer; a registered `tool-vision`
353
+ // settings section (Web UI section, settings.yaml) overlays it live, so
354
+ // edits hot-apply without a restart. `current` is read at use time.
355
+ let current = config;
356
+ const getConfig = () => current;
357
+ installSettingsSection(ctx, NS, Config, config, {
358
+ setSource: (source) => {
359
+ current = source;
360
+ },
361
+ onChange: () => {},
362
+ });
363
+
364
+ // ── image bridge: pasted images become inspect_image hints on text-only models ──
365
+ if (getConfig().bridgeTextOnly) {
366
+ const exportDir = getConfig().bridgeExportDir || join(os.tmpdir(), "dsh-vision-bridge");
367
+ mkdir(exportDir, { recursive: true }).catch(() => {});
368
+ // Root-level listener: agent-scoped waterfalls admit untagged listeners,
369
+ // so one registration serves every agent (new and resumed alike) and the
370
+ // agent is read from the fused payload.
371
+ attachPreStepBridge(ctx, getConfig, exportDir);
372
+ }
373
+
149
374
  ctx.tools.register(defineTool({
150
375
  name: "inspect_image",
151
- description: config.description,
376
+ description: getConfig().description,
152
377
  parameters: {
153
378
  path: {
154
379
  type: "string",
@@ -170,12 +395,27 @@ function apply(ctx, config) {
170
395
  render: (_args, value) => [{ type: "text", text: value }],
171
396
  },
172
397
  async execute(args, exec) {
398
+ const cfg = getConfig();
173
399
  const cwd = exec.agent?.session?.header?.cwd ?? process.cwd();
174
- const { url, note } = await toImageUrl(args.path, cwd, config);
175
- const answer = await callVision(config, url, args.question, args.detail, exec.signal);
400
+ const { url, note } = await toImageUrl(args.path, cwd, cfg);
401
+ const answer = await callVision(cfg, url, args.question, args.detail, exec.signal);
176
402
  return note === url ? answer : `${answer}\n\n(image: ${note})`;
177
403
  },
178
404
  }));
179
405
  }
180
406
 
181
- export { Config, DEFAULT_DESCRIPTION, apply, inject, name };
407
+ export {
408
+ Config,
409
+ DEFAULT_DESCRIPTION,
410
+ EXT_BY_MEDIA,
411
+ apply,
412
+ attachPreStepBridge,
413
+ bridgeMessages,
414
+ currentModelAcceptsImage,
415
+ deepFreeze,
416
+ exportImage,
417
+ hasImageBlock,
418
+ inject,
419
+ name,
420
+ repairLoggedImages,
421
+ };
package/package.json CHANGED
@@ -1,18 +1,27 @@
1
- {
1
+ {
2
2
  "name": "dsh-tool-vision",
3
- "version": "0.1.0",
4
- "description": "External vision model for DeepSeek Harness: inspect_image sends local images or http(s) image URLs to any OpenAI-compatible chat/completions endpoint and returns the vision model's text answer.",
3
+ "version": "0.3.1",
4
+ "description": "DeepSeek Harness 外置视觉模型插件:inspect_image 把本地图片或 http(s) 图片 URL 发给任意 OpenAI 兼容端点,视觉模型看图的文字回答直接带回对话;附 Web UI 设置栏。",
5
5
  "type": "module",
6
6
  "main": "index.js",
7
7
  "exports": {
8
- ".": "./index.js"
8
+ ".": "./index.js",
9
+ "./client": "./client.js",
10
+ "./package.json": "./package.json"
11
+ },
12
+ "dsh": {
13
+ "client": {
14
+ "platform": "web"
15
+ }
9
16
  },
10
17
  "files": [
11
18
  "index.js",
19
+ "client.js",
12
20
  "README.md",
13
21
  "README.zh.md",
14
22
  "LICENSE"
15
23
  ],
24
+ "homepage": "https://github.com/Scorp1o117/dsh-tool-vision",
16
25
  "keywords": [
17
26
  "dsh",
18
27
  "dsh-plugin",
@@ -37,8 +46,11 @@
37
46
  "url": "https://github.com/Scorp1o117/dsh-tool-vision/issues"
38
47
  },
39
48
  "dependencies": {
49
+ "@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
40
50
  "@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
41
51
  "@deepseek-ai/schemastery": "^3.18.1"
52
+ },
53
+ "peerDependencies": {
54
+ "react": "^18.2.0"
42
55
  }
43
56
  }
44
-