dsh-tool-vision 0.1.1 → 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 +38 -14
- package/README.zh.md +51 -62
- package/client.js +274 -0
- package/index.js +165 -30
- package/package.json +17 -6
package/README.md
CHANGED
|
@@ -1,25 +1,40 @@
|
|
|
1
1
|
# dsh-tool-vision
|
|
2
2
|
|
|
3
|
+
[](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
|
|
6
|
-
|
|
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:
|
|
7
13
|
|
|
8
14
|
1. **`inspect_image` tool** — sends an image (local file, or http(s) URL) to
|
|
9
15
|
**any OpenAI-compatible** `/chat/completions` endpoint that supports
|
|
10
16
|
`image_url` content parts, and returns the vision model's textual answer
|
|
11
17
|
into the agent loop.
|
|
12
|
-
2. **Image bridge (v0.
|
|
13
|
-
|
|
14
|
-
waterfall
|
|
15
|
-
|
|
16
|
-
`
|
|
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.
|
|
17
27
|
|
|
18
28
|
- Zero dependencies beyond the dsh SDK — works with any compatible endpoint:
|
|
19
29
|
OpenAI GPT-4o, Qwen-VL (DashScope), GLM-4V (Zhipu), Moonshot, Gemini
|
|
20
30
|
compatible endpoints, local Ollama, etc.
|
|
21
31
|
- Registered on the **global tools layer**: every agent in the process can
|
|
22
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.
|
|
23
38
|
|
|
24
39
|
## Install
|
|
25
40
|
|
|
@@ -54,14 +69,14 @@ Or load it from a local path without npm:
|
|
|
54
69
|
| `timeoutMs` | `60000` | Per-request timeout. |
|
|
55
70
|
| `maxImageBytes` | `10MB` | Largest accepted local image. |
|
|
56
71
|
| `description` | default | Tool description shown to the model. |
|
|
57
|
-
| `bridgeTextOnly` | `true` | Bridge pasted images to text hints
|
|
72
|
+
| `bridgeTextOnly` | `true` | Bridge pasted images to text hints on models that cannot see images. |
|
|
58
73
|
| `bridgeExportDir` | temp | Export dir for bridged images (`os.tmpdir()/dsh-vision-bridge`). |
|
|
59
74
|
| `multimodalModels` | `[]` | Model ids that receive image blocks directly (e.g. `mimo-v2.5`). |
|
|
60
75
|
|
|
61
76
|
## Image bridge setup
|
|
62
77
|
|
|
63
|
-
1. In your model settings, declare image input on the
|
|
64
|
-
|
|
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):
|
|
65
80
|
```yaml
|
|
66
81
|
llm-pi-ai:
|
|
67
82
|
providers:
|
|
@@ -79,9 +94,17 @@ Or load it from a local path without npm:
|
|
|
79
94
|
multimodalModels: ['mimo-v2.5', 'grok-4.5']
|
|
80
95
|
```
|
|
81
96
|
|
|
82
|
-
Then pasting an image while on a text-only model
|
|
97
|
+
Then pasting an image while on a text-only model stores a hint like
|
|
83
98
|
`[User sent an image, exported to: <path>. Inspect it with the inspect_image tool...]`
|
|
84
|
-
|
|
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.
|
|
85
108
|
|
|
86
109
|
Key resolution order: `config.apiKey` → `process.env[apiKeyEnv]` →
|
|
87
110
|
`process.env.OPENAI_API_KEY`.
|
|
@@ -104,8 +127,9 @@ Example endpoints (`baseURL`):
|
|
|
104
127
|
|
|
105
128
|
## Limitations
|
|
106
129
|
|
|
107
|
-
-
|
|
108
|
-
pixels) — pixel-precise
|
|
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`.
|
|
109
133
|
- Images are base64-transferred; mind privacy and size limits.
|
|
110
134
|
- Independent of the dsh-llm routing/retry system; failures return clear
|
|
111
135
|
errors to the agent.
|
package/README.zh.md
CHANGED
|
@@ -1,67 +1,58 @@
|
|
|
1
1
|
# dsh-tool-vision
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
DeepSeek
|
|
6
|
-
block yet. This plugin bridges the gap in two ways:
|
|
5
|
+
给 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 外接**视觉模型**的插件。
|
|
7
6
|
|
|
8
|
-
|
|
9
|
-
**any OpenAI-compatible** `/chat/completions` endpoint that supports
|
|
10
|
-
`image_url` content parts, and returns the vision model's textual answer
|
|
11
|
-
into the agent loop.
|
|
12
|
-
2. **Image bridge (v0.1.1)** — when the active model is text-only, images
|
|
13
|
-
pasted into the conversation are intercepted on the `llm/stream`
|
|
14
|
-
waterfall, exported to a local file, and replaced with a text hint, so
|
|
15
|
-
the agent picks them up with `inspect_image`. Models listed in
|
|
16
|
-
`multimodalModels` receive image blocks directly instead.
|
|
7
|
+
DeepSeek 自家模型是纯文本的,而且 harness 的每次模型请求都**严格从会话日志推导**(`llm/stream` 请求必须与持久化推导一致,否则 agent-loop invariant 会报 `log-reconstruction desync`)。本插件用两条路径补上缺口:
|
|
17
8
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
compatible endpoints, local Ollama, etc.
|
|
21
|
-
- Registered on the **global tools layer**: every agent in the process can
|
|
22
|
-
call `inspect_image`.
|
|
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`)。
|
|
23
11
|
|
|
24
|
-
|
|
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。
|
|
25
15
|
|
|
26
|
-
|
|
16
|
+
## 安装
|
|
17
|
+
|
|
18
|
+
在 profile patch(`$DSH_HOME/profiles/<name>/cordis.patch.yml`)里挂载:
|
|
27
19
|
|
|
28
20
|
```yaml
|
|
29
21
|
- insert:
|
|
30
22
|
- id: tool-vision
|
|
31
|
-
name: 'dsh-tool-vision' #
|
|
23
|
+
name: 'dsh-tool-vision' # 前置:在 profile 里 pnpm add dsh-tool-vision
|
|
32
24
|
config:
|
|
33
25
|
baseURL: 'https://api.openai.com/v1'
|
|
34
26
|
apiKeyEnv: 'VISION_API_KEY'
|
|
35
27
|
model: 'gpt-4o-mini'
|
|
36
28
|
```
|
|
37
29
|
|
|
38
|
-
|
|
30
|
+
不装 npm 包、直接加载本地路径:
|
|
39
31
|
|
|
40
32
|
```yaml
|
|
41
33
|
- id: tool-vision
|
|
42
34
|
name: './plugins/dsh-tool-vision/index.js'
|
|
43
35
|
```
|
|
44
36
|
|
|
45
|
-
##
|
|
37
|
+
## 配置
|
|
46
38
|
|
|
47
|
-
|
|
|
39
|
+
| 字段 | 默认值 | 含义 |
|
|
48
40
|
|---|---|---|
|
|
49
|
-
| `baseURL` | `https://api.openai.com/v1` | OpenAI
|
|
50
|
-
| `apiKey` | `''` | API
|
|
51
|
-
| `apiKeyEnv` | `VISION_API_KEY` |
|
|
52
|
-
| `model` | `gpt-4o-mini` |
|
|
53
|
-
| `maxTokens` | `1024` |
|
|
54
|
-
| `timeoutMs` | `60000` |
|
|
55
|
-
| `maxImageBytes` | `10MB` |
|
|
56
|
-
| `description` |
|
|
57
|
-
| `bridgeTextOnly` | `true` |
|
|
58
|
-
| `bridgeExportDir` |
|
|
59
|
-
| `multimodalModels` | `[]` |
|
|
60
|
-
|
|
61
|
-
##
|
|
62
|
-
|
|
63
|
-
1.
|
|
64
|
-
(pi-ai style), so the harness lets image messages through:
|
|
41
|
+
| `baseURL` | `https://api.openai.com/v1` | OpenAI 兼容 API 基地址 |
|
|
42
|
+
| `apiKey` | `''` | API 密钥(优先于环境变量) |
|
|
43
|
+
| `apiKeyEnv` | `VISION_API_KEY` | 存放密钥的环境变量名 |
|
|
44
|
+
| `model` | `gpt-4o-mini` | 视觉模型 id |
|
|
45
|
+
| `maxTokens` | `1024` | 视觉调用最大输出 token |
|
|
46
|
+
| `timeoutMs` | `60000` | 单次请求超时 |
|
|
47
|
+
| `maxImageBytes` | `10MB` | 本地图片大小上限 |
|
|
48
|
+
| `description` | 默认描述 | 工具描述(模型可见) |
|
|
49
|
+
| `bridgeTextOnly` | `true` | 把粘贴图片转成文本指引(发给看不懂图片的模型时) |
|
|
50
|
+
| `bridgeExportDir` | 临时目录 | 桥接图片导出目录(`os.tmpdir()/dsh-vision-bridge`) |
|
|
51
|
+
| `multimodalModels` | `[]` | 直发图片块的模型 id(如 `mimo-v2.5`) |
|
|
52
|
+
|
|
53
|
+
## 图片桥配置
|
|
54
|
+
|
|
55
|
+
1. 在模型设置里给要贴图的模型声明图片输入(pi-ai 风格),让 harness 放行图片消息:
|
|
65
56
|
```yaml
|
|
66
57
|
llm-pi-ai:
|
|
67
58
|
providers:
|
|
@@ -70,8 +61,7 @@ Or load it from a local path without npm:
|
|
|
70
61
|
- id: deepseek-v4-flash
|
|
71
62
|
input: [text, image]
|
|
72
63
|
```
|
|
73
|
-
2.
|
|
74
|
-
image blocks untouched:
|
|
64
|
+
2. 在插件配置里列出真正多模态的模型,让它们直收图片块:
|
|
75
65
|
```yaml
|
|
76
66
|
- id: tool-vision
|
|
77
67
|
name: 'dsh-tool-vision'
|
|
@@ -79,36 +69,35 @@ Or load it from a local path without npm:
|
|
|
79
69
|
multimodalModels: ['mimo-v2.5', 'grok-4.5']
|
|
80
70
|
```
|
|
81
71
|
|
|
82
|
-
|
|
72
|
+
之后在文本模型下贴图,转录里会留下一条指引:
|
|
83
73
|
`[User sent an image, exported to: <path>. Inspect it with the inspect_image tool...]`
|
|
84
|
-
|
|
74
|
+
(该消息不再以像素图形式渲染),Agent 会调用视觉端点查看并把结果带回对话。
|
|
75
|
+
|
|
76
|
+
> 为什么不用 `llm/stream`?harness 会冻结每个请求,且 agent-loop invariant 会拒绝任何与会话日志推导不一致的请求;这个 cordis 版本的 waterfall `next()` 也无法替换请求参数。`agent/pre-step` 才是受支持的缝:它的决策消息**会成为**持久化日志,invariant 天然成立。
|
|
85
77
|
|
|
86
|
-
|
|
87
|
-
`process.env.OPENAI_API_KEY`.
|
|
78
|
+
密钥解析顺序:`config.apiKey` → `process.env[apiKeyEnv]` → `process.env.OPENAI_API_KEY`。
|
|
88
79
|
|
|
89
|
-
##
|
|
80
|
+
## 工具:`inspect_image`
|
|
90
81
|
|
|
91
|
-
|
|
|
82
|
+
| 参数 | 必填 | 含义 |
|
|
92
83
|
|---|---|---|
|
|
93
|
-
| `path` | ✅ |
|
|
94
|
-
| `question` | – |
|
|
95
|
-
| `detail` | – | `auto` / `low` / `high`
|
|
84
|
+
| `path` | ✅ | 图片路径(绝对路径,或相对当前工作区)或 http(s) URL |
|
|
85
|
+
| `question` | – | 可选的具体问题 |
|
|
86
|
+
| `detail` | – | `auto` / `low` / `high` 分辨率提示 |
|
|
96
87
|
|
|
97
|
-
|
|
88
|
+
示例端点(`baseURL`):
|
|
98
89
|
|
|
99
|
-
- **OpenAI
|
|
100
|
-
-
|
|
101
|
-
-
|
|
102
|
-
- **Moonshot
|
|
103
|
-
- **Ollama
|
|
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`(无需密钥)
|
|
104
95
|
|
|
105
|
-
##
|
|
96
|
+
## 限制
|
|
106
97
|
|
|
107
|
-
-
|
|
108
|
-
|
|
109
|
-
-
|
|
110
|
-
- Independent of the dsh-llm routing/retry system; failures return clear
|
|
111
|
-
errors to the agent.
|
|
98
|
+
- 被桥接的图片以文本指引进入对话(转录而非像素)——文本模型无法做像素级上下文推理;视觉模型的描述通过 `inspect_image` 回传。
|
|
99
|
+
- 图片以 base64 传输;注意隐私与大小限制。
|
|
100
|
+
- 独立于 dsh-llm 的路由/重试体系;失败会向 Agent 返回明确错误。
|
|
112
101
|
|
|
113
102
|
## License
|
|
114
103
|
|
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
|
@@ -7,23 +7,35 @@
|
|
|
7
7
|
* any OpenAI-compatible chat/completions endpoint that supports
|
|
8
8
|
* `image_url` content parts, and returns the vision model's text answer.
|
|
9
9
|
*
|
|
10
|
-
* 2. Image bridge —
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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.
|
|
16
25
|
*/
|
|
17
26
|
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
18
27
|
import { extname, isAbsolute, join, resolve as resolvePath } from "node:path";
|
|
19
28
|
import os from "node:os";
|
|
20
29
|
import z from "@deepseek-ai/schemastery";
|
|
21
30
|
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
31
|
+
import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
|
|
22
32
|
|
|
23
33
|
/** Cordis plugin name. */
|
|
24
34
|
const name = "tool-vision";
|
|
25
|
-
/** The tool registry, the llm seam (
|
|
35
|
+
/** The tool registry, the llm seam (model capability lookup), and the attachment store. */
|
|
26
36
|
const inject = ["tools", "llm", "attachments"];
|
|
37
|
+
/** Settings namespace owned by this plugin (Web UI settings section). */
|
|
38
|
+
const NS = settingsNamespace("tool-vision");
|
|
27
39
|
|
|
28
40
|
const DEFAULT_DESCRIPTION =
|
|
29
41
|
"Analyze an image using an external vision-capable model through an OpenAI-compatible API. " +
|
|
@@ -36,21 +48,21 @@ const DEFAULT_DESCRIPTION =
|
|
|
36
48
|
const Config = z.object({
|
|
37
49
|
/** Base URL of an OpenAI-compatible API, e.g. https://api.openai.com/v1 or https://dashscope.aliyuncs.com/compatible-mode/v1 */
|
|
38
50
|
baseURL: z.string().default("https://api.openai.com/v1"),
|
|
39
|
-
/** API key; takes precedence over apiKeyEnv. */
|
|
40
|
-
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"),
|
|
41
53
|
/** Environment variable holding the API key. */
|
|
42
54
|
apiKeyEnv: z.string().default("VISION_API_KEY"),
|
|
43
55
|
/** Vision model id served by the endpoint. */
|
|
44
56
|
model: z.string().default("gpt-4o-mini"),
|
|
45
57
|
/** Max output tokens for the vision call. */
|
|
46
|
-
maxTokens: z.number().default(
|
|
58
|
+
maxTokens: z.number().default(4096),
|
|
47
59
|
/** Per-request timeout in milliseconds. */
|
|
48
60
|
timeoutMs: z.number().default(60000),
|
|
49
61
|
/** Largest local image accepted, in bytes. */
|
|
50
62
|
maxImageBytes: z.number().default(10 * 1024 * 1024),
|
|
51
63
|
/** Tool description shown to the model; overrides the default. */
|
|
52
64
|
description: z.string().default(DEFAULT_DESCRIPTION),
|
|
53
|
-
/** Bridge pasted images to text hints
|
|
65
|
+
/** Bridge pasted images to text hints on models that cannot see images. */
|
|
54
66
|
bridgeTextOnly: z.boolean().default(true),
|
|
55
67
|
/** Export directory for bridged images; empty = system temp. */
|
|
56
68
|
bridgeExportDir: z.string().default(""),
|
|
@@ -84,6 +96,17 @@ function hasImageBlock(messages) {
|
|
|
84
96
|
);
|
|
85
97
|
}
|
|
86
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
|
+
|
|
87
110
|
/** Export one attachment to disk; returns the file path (cached per process). */
|
|
88
111
|
const exportedPaths = new Map();
|
|
89
112
|
async function exportImage(attachment, ctx, dir) {
|
|
@@ -107,6 +130,8 @@ async function exportImage(attachment, ctx, dir) {
|
|
|
107
130
|
|
|
108
131
|
/**
|
|
109
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.
|
|
110
135
|
* Exported for unit testing; `ctx` only needs `attachments`.
|
|
111
136
|
*/
|
|
112
137
|
async function bridgeMessages(messages, ctx, dir) {
|
|
@@ -132,11 +157,104 @@ async function bridgeMessages(messages, ctx, dir) {
|
|
|
132
157
|
`Inspect it with the inspect_image tool to see its content.]`,
|
|
133
158
|
});
|
|
134
159
|
}
|
|
135
|
-
next.push({ ...message, content: blocks });
|
|
160
|
+
next.push(deepFreeze({ ...message, content: blocks }));
|
|
136
161
|
}
|
|
137
162
|
return next;
|
|
138
163
|
}
|
|
139
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
|
+
|
|
140
258
|
function resolveApiKey(config) {
|
|
141
259
|
if (config.apiKey) return config.apiKey;
|
|
142
260
|
if (config.apiKeyEnv) {
|
|
@@ -212,7 +330,13 @@ async function callVision(config, imageUrl, question, detail, signal) {
|
|
|
212
330
|
`vision endpoint returned ${response.status}: ${detailText} (endpoint ${endpoint})`,
|
|
213
331
|
);
|
|
214
332
|
}
|
|
215
|
-
|
|
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 ?? "";
|
|
216
340
|
if (typeof answer !== "string" || !answer.trim()) {
|
|
217
341
|
throw new Error("vision endpoint returned an empty response");
|
|
218
342
|
}
|
|
@@ -224,26 +348,32 @@ async function callVision(config, imageUrl, question, detail, signal) {
|
|
|
224
348
|
}
|
|
225
349
|
|
|
226
350
|
function apply(ctx, config) {
|
|
227
|
-
// ──
|
|
228
|
-
|
|
229
|
-
|
|
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");
|
|
230
367
|
mkdir(exportDir, { recursive: true }).catch(() => {});
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
effective = { ...options, messages: await bridgeMessages(options.messages, ctx, exportDir) };
|
|
236
|
-
}
|
|
237
|
-
} catch (error) {
|
|
238
|
-
ctx.logger.warn(`[tool-vision] image bridge failed: ${String(error)}`);
|
|
239
|
-
}
|
|
240
|
-
yield* next(effective);
|
|
241
|
-
});
|
|
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);
|
|
242
372
|
}
|
|
243
373
|
|
|
244
374
|
ctx.tools.register(defineTool({
|
|
245
375
|
name: "inspect_image",
|
|
246
|
-
description:
|
|
376
|
+
description: getConfig().description,
|
|
247
377
|
parameters: {
|
|
248
378
|
path: {
|
|
249
379
|
type: "string",
|
|
@@ -265,9 +395,10 @@ function apply(ctx, config) {
|
|
|
265
395
|
render: (_args, value) => [{ type: "text", text: value }],
|
|
266
396
|
},
|
|
267
397
|
async execute(args, exec) {
|
|
398
|
+
const cfg = getConfig();
|
|
268
399
|
const cwd = exec.agent?.session?.header?.cwd ?? process.cwd();
|
|
269
|
-
const { url, note } = await toImageUrl(args.path, cwd,
|
|
270
|
-
const answer = await callVision(
|
|
400
|
+
const { url, note } = await toImageUrl(args.path, cwd, cfg);
|
|
401
|
+
const answer = await callVision(cfg, url, args.question, args.detail, exec.signal);
|
|
271
402
|
return note === url ? answer : `${answer}\n\n(image: ${note})`;
|
|
272
403
|
},
|
|
273
404
|
}));
|
|
@@ -278,9 +409,13 @@ export {
|
|
|
278
409
|
DEFAULT_DESCRIPTION,
|
|
279
410
|
EXT_BY_MEDIA,
|
|
280
411
|
apply,
|
|
412
|
+
attachPreStepBridge,
|
|
281
413
|
bridgeMessages,
|
|
414
|
+
currentModelAcceptsImage,
|
|
415
|
+
deepFreeze,
|
|
282
416
|
exportImage,
|
|
283
417
|
hasImageBlock,
|
|
284
418
|
inject,
|
|
285
419
|
name,
|
|
420
|
+
repairLoggedImages,
|
|
286
421
|
};
|
package/package.json
CHANGED
|
@@ -1,18 +1,27 @@
|
|
|
1
|
-
|
|
1
|
+
{
|
|
2
2
|
"name": "dsh-tool-vision",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
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,9 +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
|
-
|
|
45
|
-
|