dsh-vision-fallback 0.9.0 → 0.10.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/CHANGELOG.md +23 -0
- package/README.md +33 -4
- package/README.zh.md +33 -4
- package/lib/index.js +54 -11
- package/package.json +19 -6
- package/test/vision-fallback.test.mjs +149 -25
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.10.0 - 2026-09-03
|
|
4
|
+
|
|
5
|
+
- Adapted Host listeners to the current Cordis effect lifecycle.
|
|
6
|
+
- Stopped mutating frozen `llm/stream` requests and rerouted compaction with a copied request.
|
|
7
|
+
- Added explicit DSH Store compatibility metadata for `0.1.2-alpha.3`, `0.1.2-alpha.4`, and `0.1.2-alpha.5`.
|
|
8
|
+
- Declared the supported Node.js and DSH ranges and expanded bundle manifest coverage.
|
|
9
|
+
|
|
10
|
+
## 0.9.1 - 2026-08-18
|
|
11
|
+
|
|
12
|
+
- Added the `dsh-plugin` npm keyword and a regression test for the installable DSH bundle manifest.
|
|
13
|
+
- Expanded the documentation for compaction reuse, tool-result images, persistent observations, and restart recovery.
|
|
14
|
+
|
|
15
|
+
## 0.9.0 - 2026-08-16
|
|
16
|
+
|
|
17
|
+
- Persisted successful observations to `observations.json` and reused them across restart, replay, and compaction.
|
|
18
|
+
- Scoped observations by session and message position so a newly posted copy of the same image can be analyzed again with fresh context.
|
|
19
|
+
- Recorded vision-call latency, status, image metadata, and token usage in `usage.jsonl`.
|
|
20
|
+
|
|
21
|
+
## 0.7.0 - 2026-08-14
|
|
22
|
+
|
|
23
|
+
- Kept the selected main model in control while silently routing image understanding to the configured vision model.
|
package/README.md
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
Silent vision enhancement for [DeepSeek Harness (dsh)](https://github.com/deepseek-ai/deepseek-harness): keep your real text-only main model (e.g. `deepseek-v4-flash`), and let chat images "just work" — every image you drop, paste, or reference in the chat box is automatically sent to a fixed vision model, converted into a factual text observation, and handed to your main model as hidden context. The UI keeps showing your original image; no model groups, no model switching, no extra tools.
|
|
6
6
|
|
|
7
|
+
Compatible with the current DSH Store window: `0.1.2-alpha.3`, `0.1.2-alpha.4`, and `0.1.2-alpha.5`. Durable MCP / ACP image attachments and nested images forwarded by PTC Mode use the same vision bridge. When enabled, the plugin intentionally keeps advertising image input; otherwise the chat composer rejects the image before it can reach the bridge.
|
|
8
|
+
|
|
7
9
|
## Why
|
|
8
10
|
|
|
9
11
|
- DeepSeek V4 Flash / Pro and other strong coding models are **text-only**: dropping an image into the chat box fails with "model does not support image input".
|
|
@@ -31,6 +33,29 @@ agent/pre-step ──► image + current question + recent context
|
|
|
31
33
|
4. A **model-only surface replacement** swaps the image for the vision observation before the request reaches the main model.
|
|
32
34
|
5. Switching the main model (DeepSeek, Kimi, MiniMax, ...) never changes the fixed vision model.
|
|
33
35
|
|
|
36
|
+
### MCP / ACP / PTC images
|
|
37
|
+
|
|
38
|
+
DSH `0.1.0-rc.7` persists images produced by MCP, ACP, and PTC as durable attachments, then exposes them as core `image` content blocks. This plugin recursively handles images in ordinary messages, `tool-result`, and nested `tool-result` content while preserving the original text/image order:
|
|
39
|
+
|
|
40
|
+
- MCP / ACP durable attachments are read by `attachmentId`, not temporary file paths;
|
|
41
|
+
- nested PTC subtool images are converted into vision observations;
|
|
42
|
+
- multiple images are sent to the vision model in their original order;
|
|
43
|
+
- only the model-visible surface is replaced, so the original UI image remains intact.
|
|
44
|
+
|
|
45
|
+
The image-capability override is an intentional entry-point compatibility layer. It does not claim that the main model has native visual reasoning; it lets the image reach DSH so the bridge can convert it into text context.
|
|
46
|
+
|
|
47
|
+
### DSH compatibility evidence
|
|
48
|
+
|
|
49
|
+
Verified on 2026-09-03 with Node.js `24.16.0` and a separate disposable `DSH_HOME` for each release:
|
|
50
|
+
|
|
51
|
+
| DSH release | Local-path install | `--dump-config` | Authenticated cold start | Uninstall |
|
|
52
|
+
| --- | --- | --- | --- | --- |
|
|
53
|
+
| `0.1.2-alpha.3` | passed | passed | HTTP 200 | passed |
|
|
54
|
+
| `0.1.2-alpha.4` | passed | passed | HTTP 200 | passed |
|
|
55
|
+
| `0.1.2-alpha.5` | passed | passed | HTTP 200 | passed |
|
|
56
|
+
|
|
57
|
+
The Profile operations use the official CLI with `plugin --profile web add -w <local-path>` and `remove -w dsh-vision-fallback`. The runtime fix does not mutate the deep-frozen `llm/stream` request introduced by current DSH builds; compaction creates a copied request and performs one guarded nested dispatch.
|
|
58
|
+
|
|
34
59
|
## Install
|
|
35
60
|
|
|
36
61
|
### From npm / local checkout
|
|
@@ -46,8 +71,8 @@ dsh plugin --profile web add dsh-vision-fallback
|
|
|
46
71
|
```sh
|
|
47
72
|
git clone https://github.com/1HelloMan1/dsh-vision-fallback.git
|
|
48
73
|
cd dsh-vision-fallback
|
|
49
|
-
pnpm install --config.minimumReleaseAge=0 #
|
|
50
|
-
pnpm test #
|
|
74
|
+
pnpm install --config.minimumReleaseAge=0 # pre-release peers may need the release-age flag bypassed
|
|
75
|
+
pnpm test # node --test test/*.test.mjs
|
|
51
76
|
dsh plugin --profile web add "$PWD"
|
|
52
77
|
```
|
|
53
78
|
|
|
@@ -74,7 +99,7 @@ Open **Settings → 视觉增强 / Vision Enhancement** in the DSH web UI. It ex
|
|
|
74
99
|
| Vision model | `mimo-v2.5` | OpenAI-compatible `model` |
|
|
75
100
|
| Base URL | `https://opencode.ai/zen/go/v1` | The plugin appends `/chat/completions` |
|
|
76
101
|
| Credential ref | `OPENCODE_GO_API_KEY` | Key resolved from the DSH credential store (never written to env files) |
|
|
77
|
-
| Max tokens / Timeout / Max bytes | `1536` / `60000` / `15MB` | Vision request limits |
|
|
102
|
+
| Max tokens / Timeout / Max bytes | `1536` / `60000` / `15MB` | Vision request limits; oversized images fail |
|
|
78
103
|
| Recent context | `includeRecentContext: true`, `contextMessages: 6`, `contextMaxChars: 6000` | How much recent chat to attach for the vision model |
|
|
79
104
|
| Prompt | (Chinese detailed-analysis prompt) | Analysis instruction; the user's question is appended automatically |
|
|
80
105
|
| Tag result | `true` | Prepend `【视觉观察:<model>】` to the observation |
|
|
@@ -137,6 +162,10 @@ Observations are keyed by **the image's occurrence in a session** (session id +
|
|
|
137
162
|
|
|
138
163
|
The cache is capped at 256 entries (LRU eviction); failed results are never cached.
|
|
139
164
|
|
|
165
|
+
### About image compression
|
|
166
|
+
|
|
167
|
+
In the current version, “compression” means **reusing an existing vision observation during conversation-context compaction**; it does not re-run vision analysis. The plugin does not yet resize, transcode, or quality-compress image files. Images above `maxBytes` fail explicitly instead of being silently changed. A future real image-compression feature should report the before/after byte counts and media type.
|
|
168
|
+
|
|
140
169
|
## Default vision route
|
|
141
170
|
|
|
142
171
|
- Model: `mimo-v2.5` · Endpoint: `https://opencode.ai/zen/go/v1/chat/completions` · Credential: `OPENCODE_GO_API_KEY`
|
|
@@ -150,7 +179,7 @@ The OpenCode community `opencode-see-image` hands a `filePath` + task `question`
|
|
|
150
179
|
## Development
|
|
151
180
|
|
|
152
181
|
```sh
|
|
153
|
-
pnpm test # node --test test/*.test.mjs
|
|
182
|
+
pnpm test # node --test test/*.test.mjs
|
|
154
183
|
```
|
|
155
184
|
|
|
156
185
|
Structure:
|
package/README.zh.md
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
为 [DeepSeek Harness (dsh)](https://github.com/deepseek-ai/deepseek-harness) 提供**全局静默视觉增强**:你继续在会话顶部直接选择真实主模型(如 `deepseek-v4-flash`),聊天框里拖入、粘贴、引用的图片会自动交给固定视觉模型,转成事实性文字观察后作为隐藏上下文交给主模型。界面始终显示你的原始图片——不新增"视觉回退"模型分组、不切换模型、不调用额外工具。
|
|
6
6
|
|
|
7
|
+
兼容当前 DSH STORE 版本窗口:`0.1.2-alpha.3`、`0.1.2-alpha.4`、`0.1.2-alpha.5`。MCP / ACP 的持久图片附件,以及 PTC Mode 转发的嵌套图片,都会沿用同一套视觉桥处理。插件启用时会保留图片能力声明;否则聊天框会在图片到达视觉桥之前直接提示“模型不支持图片输入”。
|
|
8
|
+
|
|
7
9
|
## 为什么需要
|
|
8
10
|
|
|
9
11
|
- DeepSeek V4 Flash/Pro 等强编码模型是**纯文本**的:聊天框发图会直接报"模型不支持图片输入"。
|
|
@@ -31,6 +33,29 @@ agent/pre-step ──► 图片 + 当前问题 + 最近上下文
|
|
|
31
33
|
4. **仅模型可见的 surface replacement** 把图片换成视觉观察后再交给主模型。
|
|
32
34
|
5. 切换主模型(DeepSeek、Kimi、MiniMax……)不会改变固定视觉模型。
|
|
33
35
|
|
|
36
|
+
### MCP / ACP / PTC 图片
|
|
37
|
+
|
|
38
|
+
DSH `0.1.0-rc.7` 会把 MCP、ACP 和 PTC 产生的图片保存为持久附件,再以核心 `image` 内容块交给后续链路。本插件会递归处理普通消息、`tool-result` 和多层嵌套 `tool-result` 中的图片,并保持图片与文字的原始顺序:
|
|
39
|
+
|
|
40
|
+
- MCP / ACP 持久附件:按 `attachmentId` 读取,不依赖临时文件路径;
|
|
41
|
+
- PTC 嵌套图片:子调用返回的图片也会转换为视觉观察;
|
|
42
|
+
- 多张图片:逐张调用视觉模型,观察结果对应原始出现顺序;
|
|
43
|
+
- 原始界面:只替换模型视图,聊天界面仍保留原图。
|
|
44
|
+
|
|
45
|
+
插件的图片能力声明是必要的入口兼容层。它不是说主模型真的具备视觉推理能力,而是允许图片先进入 DSH,再由视觉桥转成主模型可用的文字上下文。
|
|
46
|
+
|
|
47
|
+
### DSH 兼容性证据
|
|
48
|
+
|
|
49
|
+
2026-09-03 使用 Node.js `24.16.0` 验证,每个版本均使用独立的一次性 `DSH_HOME`:
|
|
50
|
+
|
|
51
|
+
| DSH 版本 | 本地路径安装 | `--dump-config` | 带认证冷启动 | 卸载 |
|
|
52
|
+
| --- | --- | --- | --- | --- |
|
|
53
|
+
| `0.1.2-alpha.3` | 通过 | 通过 | HTTP 200 | 通过 |
|
|
54
|
+
| `0.1.2-alpha.4` | 通过 | 通过 | HTTP 200 | 通过 |
|
|
55
|
+
| `0.1.2-alpha.5` | 通过 | 通过 | HTTP 200 | 通过 |
|
|
56
|
+
|
|
57
|
+
Profile 操作使用官方 CLI:`plugin --profile web add -w <local-path>` 和 `remove -w dsh-vision-fallback`。新版 DSH 会深冻结 `llm/stream` 请求,插件不再原地修改它;上下文压缩会复制请求并执行一次带重入保护的嵌套分发。
|
|
58
|
+
|
|
34
59
|
## 安装
|
|
35
60
|
|
|
36
61
|
### npm / 本地检出
|
|
@@ -46,8 +71,8 @@ dsh plugin --profile web add dsh-vision-fallback
|
|
|
46
71
|
```sh
|
|
47
72
|
git clone https://github.com/1HelloMan1/dsh-vision-fallback.git
|
|
48
73
|
cd dsh-vision-fallback
|
|
49
|
-
pnpm install --config.minimumReleaseAge=0 #
|
|
50
|
-
pnpm test #
|
|
74
|
+
pnpm install --config.minimumReleaseAge=0 # 预发布 peer 依赖可能需绕过发布年龄策略
|
|
75
|
+
pnpm test # node --test test/*.test.mjs
|
|
51
76
|
dsh plugin --profile web add "$PWD"
|
|
52
77
|
```
|
|
53
78
|
|
|
@@ -74,7 +99,7 @@ dsh --profile web --dump-config # 应出现 "# == dsh-vision-fallback" 层
|
|
|
74
99
|
| 视觉模型 | `mimo-v2.5` | OpenAI 兼容 `model` |
|
|
75
100
|
| API 地址 | `https://opencode.ai/zen/go/v1` | 插件自动拼 `/chat/completions` |
|
|
76
101
|
| 凭据引用 | `OPENCODE_GO_API_KEY` | 从 DSH 凭证系统解析(不写入环境变量) |
|
|
77
|
-
| 输出上限 / 超时 / 图片上限 | `1536` / `60000` / `15MB` |
|
|
102
|
+
| 输出上限 / 超时 / 图片上限 | `1536` / `60000` / `15MB` | 视觉请求限制;超过图片上限会失败 |
|
|
78
103
|
| 最近上下文 | `includeRecentContext: true`、`contextMessages: 6`、`contextMaxChars: 6000` | 附带多少最近对话给视觉模型 |
|
|
79
104
|
| 提示词 | (中文详细分析) | 分析指令;用户问题自动追加 |
|
|
80
105
|
| 结果标注 | `true` | 观察前加 `【视觉观察:<model>】` |
|
|
@@ -137,6 +162,10 @@ API key 通过 DSH **凭证系统**(`~/.dsh/.credentials.yaml`)解析,`pro
|
|
|
137
162
|
|
|
138
163
|
缓存上限 256 条(LRU 淘汰),失败结果不入缓存。
|
|
139
164
|
|
|
165
|
+
### 关于图片压缩
|
|
166
|
+
|
|
167
|
+
当前版本的“压缩”指**上下文压缩时复用已经得到的视觉观察**,不会重新识图;插件暂时不会对图片文件做缩放、转码或质量压缩。图片超过 `maxBytes` 时会直接报告失败,不会静默改变原图。后续如增加真正的图片压缩,会单独记录压缩前后字节数和媒体类型。
|
|
168
|
+
|
|
140
169
|
## 默认视觉路由
|
|
141
170
|
|
|
142
171
|
- 模型:`mimo-v2.5` · 端点:`https://opencode.ai/zen/go/v1/chat/completions` · 凭据:`OPENCODE_GO_API_KEY`
|
|
@@ -150,7 +179,7 @@ OpenCode 社区的 `opencode-see-image` 把 `filePath` 和针对当前任务的
|
|
|
150
179
|
## 开发
|
|
151
180
|
|
|
152
181
|
```sh
|
|
153
|
-
pnpm test # node --test test/*.test.mjs
|
|
182
|
+
pnpm test # node --test test/*.test.mjs
|
|
154
183
|
```
|
|
155
184
|
|
|
156
185
|
目录结构:
|
package/lib/index.js
CHANGED
|
@@ -12,12 +12,11 @@ import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
|
12
12
|
import { dirname, join } from "node:path";
|
|
13
13
|
import { resolveDshHome } from "@deepseek-ai/dsh-home-paths";
|
|
14
14
|
import { attributionHeaders } from "@deepseek-ai/dsh-llm";
|
|
15
|
-
import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
|
|
16
15
|
import z from "@deepseek-ai/schemastery";
|
|
17
16
|
|
|
18
17
|
const name = "vision-fallback";
|
|
19
18
|
const inject = ["llm"];
|
|
20
|
-
const NS =
|
|
19
|
+
const NS = "vision-fallback";
|
|
21
20
|
const CONFIG_ROUTE = "/plugins/dsh-vision-fallback/config";
|
|
22
21
|
const MAX_CONFIG_BODY_BYTES = 64 * 1024;
|
|
23
22
|
|
|
@@ -128,6 +127,32 @@ function installConfigRoute(ctx, current, settingsSource) {
|
|
|
128
127
|
});
|
|
129
128
|
}
|
|
130
129
|
|
|
130
|
+
function installSettingsSectionCompat(ctx, ns, schema, entry, hooks) {
|
|
131
|
+
ctx.inject(["settings"], (sctx) => {
|
|
132
|
+
if (typeof sctx.settings.installSection === "function") {
|
|
133
|
+
sctx.settings.installSection(ctx, ns, schema, entry, hooks);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const scope = sctx.settings.register(ns, schema, {
|
|
138
|
+
base: entry,
|
|
139
|
+
...(hooks.validate === undefined ? {} : { validate: hooks.validate })
|
|
140
|
+
});
|
|
141
|
+
const ownerIsUnloading = () => ctx.fiber !== undefined && (ctx.fiber.state === 4 || ctx.fiber.state === 5);
|
|
142
|
+
hooks.setSource(() => scope.get());
|
|
143
|
+
hooks.onChange();
|
|
144
|
+
const disposeWatch = scope.watch(() => {
|
|
145
|
+
if (!ownerIsUnloading()) hooks.onChange();
|
|
146
|
+
});
|
|
147
|
+
sctx.effect(() => disposeWatch, `vision-fallback: 停止监听 ${ns} 设置`);
|
|
148
|
+
sctx.effect(() => () => {
|
|
149
|
+
if (ownerIsUnloading()) return;
|
|
150
|
+
hooks.setSource(() => entry);
|
|
151
|
+
hooks.onChange();
|
|
152
|
+
}, `vision-fallback: 释放 ${ns} 设置源`);
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
131
156
|
function contentHasImage(content) {
|
|
132
157
|
return content.some(
|
|
133
158
|
(block) => block?.type === "image" ||
|
|
@@ -757,13 +782,17 @@ function apply(ctx, config) {
|
|
|
757
782
|
const controller = new VisionFallbackController(ctx, () => current());
|
|
758
783
|
const replacements = new ReplacementCoordinator(ctx.logger);
|
|
759
784
|
const restoreCapability = installCapabilityOverride(ctx, () => current());
|
|
785
|
+
const reroutedCompactionRequests = new WeakSet();
|
|
760
786
|
|
|
761
787
|
ctx.effect(() => () => {
|
|
762
788
|
replacements.dispose();
|
|
763
789
|
restoreCapability();
|
|
764
790
|
}, "vision-fallback: 恢复模型图片能力检查与消息投影");
|
|
765
|
-
ctx.
|
|
766
|
-
|
|
791
|
+
ctx.effect(
|
|
792
|
+
() => ctx.on("session/event", (session, event) => replacements.onSessionEvent(session, event)),
|
|
793
|
+
"vision-fallback: 监听会话图片事件"
|
|
794
|
+
);
|
|
795
|
+
ctx.effect(() => ctx.on("agent/pre-step", async ({ agent, signal }, next) => {
|
|
767
796
|
const decision = await next();
|
|
768
797
|
if (decision.kind === "reject" || signal.aborted) return decision;
|
|
769
798
|
const cfg = current();
|
|
@@ -780,10 +809,10 @@ function apply(ctx, config) {
|
|
|
780
809
|
replacements.stage(agent.session, modelMessages, rewritten);
|
|
781
810
|
replacements.flush(agent.session);
|
|
782
811
|
return decision;
|
|
783
|
-
});
|
|
812
|
+
}), "vision-fallback: 发送前生成视觉观察");
|
|
784
813
|
|
|
785
|
-
ctx.on("llm/stream", (options, next) => (async function* () {
|
|
786
|
-
if (options.purpose !== "compaction") {
|
|
814
|
+
ctx.effect(() => ctx.on("llm/stream", (options, next) => (async function* () {
|
|
815
|
+
if (reroutedCompactionRequests.has(options) || options.purpose !== "compaction") {
|
|
787
816
|
yield* next();
|
|
788
817
|
return;
|
|
789
818
|
}
|
|
@@ -805,11 +834,24 @@ function apply(ctx, config) {
|
|
|
805
834
|
resolveSession(ctx, options.sessionId),
|
|
806
835
|
replacements
|
|
807
836
|
);
|
|
808
|
-
|
|
837
|
+
const messages = projected.some((message) => contentHasImage(message.content ?? []))
|
|
809
838
|
? await replaceLeftoverCompactionImages(projected, controller, options.sessionId)
|
|
810
839
|
: projected;
|
|
811
|
-
|
|
812
|
-
|
|
840
|
+
if (messages === options.messages) {
|
|
841
|
+
yield* next();
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// DSH 0.1.2-alpha.5 起 LOOP 请求为深冻结只读对象。通过一次受控的
|
|
846
|
+
// 嵌套 stream 传入新请求,避免修改原对象;WeakSet 防止再次进入本桥。
|
|
847
|
+
const rerouted = { ...options, messages };
|
|
848
|
+
reroutedCompactionRequests.add(rerouted);
|
|
849
|
+
try {
|
|
850
|
+
yield* ctx.llm.stream(rerouted);
|
|
851
|
+
} finally {
|
|
852
|
+
reroutedCompactionRequests.delete(rerouted);
|
|
853
|
+
}
|
|
854
|
+
})(), { global: true, prepend: true }), "vision-fallback: 压缩时复用视觉观察");
|
|
813
855
|
|
|
814
856
|
ctx.inject(["settings"], (sctx) => {
|
|
815
857
|
settingsService = sctx.settings;
|
|
@@ -817,7 +859,7 @@ function apply(ctx, config) {
|
|
|
817
859
|
if (settingsService === sctx.settings) settingsService = undefined;
|
|
818
860
|
}, "vision-fallback: 释放 Settings 服务引用");
|
|
819
861
|
});
|
|
820
|
-
|
|
862
|
+
installSettingsSectionCompat(ctx, NS, Config, config, {
|
|
821
863
|
validate: assertVisionConfig,
|
|
822
864
|
setSource: (source) => {
|
|
823
865
|
current = source;
|
|
@@ -838,6 +880,7 @@ export {
|
|
|
838
880
|
inject,
|
|
839
881
|
installCapabilityOverride,
|
|
840
882
|
installConfigRoute,
|
|
883
|
+
installSettingsSectionCompat,
|
|
841
884
|
name,
|
|
842
885
|
rewriteMessages,
|
|
843
886
|
withImageCapability
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-vision-fallback",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "DSH 静默视觉增强:主模型照常选择,图片自动交给固定视觉模型后以隐藏上下文返回主模型。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"lib",
|
|
14
14
|
"test",
|
|
15
15
|
"cordis.patch.yml",
|
|
16
|
+
"CHANGELOG.md",
|
|
16
17
|
"README.md",
|
|
17
18
|
"README.zh.md"
|
|
18
19
|
],
|
|
@@ -33,6 +34,7 @@
|
|
|
33
34
|
},
|
|
34
35
|
"keywords": [
|
|
35
36
|
"dsh",
|
|
37
|
+
"dsh-plugin",
|
|
36
38
|
"deepseek-harness",
|
|
37
39
|
"vision",
|
|
38
40
|
"image",
|
|
@@ -44,6 +46,14 @@
|
|
|
44
46
|
"bundle": {
|
|
45
47
|
"patch": "./cordis.patch.yml"
|
|
46
48
|
},
|
|
49
|
+
"compatibility": {
|
|
50
|
+
"dsh": ">=0.1.1-rc.2 <0.2.0",
|
|
51
|
+
"dshReleases": {
|
|
52
|
+
"0.1.2-alpha.3": "compatible",
|
|
53
|
+
"0.1.2-alpha.4": "compatible",
|
|
54
|
+
"0.1.2-alpha.5": "compatible"
|
|
55
|
+
}
|
|
56
|
+
},
|
|
47
57
|
"client": {
|
|
48
58
|
"inject": [
|
|
49
59
|
"@deepseek-ai/dsh-client-connection",
|
|
@@ -53,13 +63,16 @@
|
|
|
53
63
|
"platform": "web"
|
|
54
64
|
}
|
|
55
65
|
},
|
|
66
|
+
"engines": {
|
|
67
|
+
"node": ">=22.13.0"
|
|
68
|
+
},
|
|
56
69
|
"peerDependencies": {
|
|
57
70
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
58
|
-
"@deepseek-ai/dsh-llm": "
|
|
59
|
-
"@deepseek-ai/dsh-attachment": "
|
|
60
|
-
"@deepseek-ai/dsh-settings": "
|
|
61
|
-
"@deepseek-ai/dsh-credentials": "
|
|
71
|
+
"@deepseek-ai/dsh-llm": ">=0.1.1-rc.2 <0.1.2 || >=0.1.2-alpha.3 <0.2.0",
|
|
72
|
+
"@deepseek-ai/dsh-attachment": ">=0.1.1-rc.2 <0.1.2 || >=0.1.2-alpha.3 <0.2.0",
|
|
73
|
+
"@deepseek-ai/dsh-settings": ">=0.1.1-rc.2 <0.1.2 || >=0.1.2-alpha.3 <0.2.0",
|
|
74
|
+
"@deepseek-ai/dsh-credentials": ">=0.1.1-rc.2 <0.1.2 || >=0.1.2-alpha.3 <0.2.0",
|
|
62
75
|
"@deepseek-ai/schemastery": "^3.18.1",
|
|
63
|
-
"@deepseek-ai/dsh-home-paths": "
|
|
76
|
+
"@deepseek-ai/dsh-home-paths": ">=0.1.1-rc.2 <0.1.2 || >=0.1.2-alpha.3 <0.2.0"
|
|
64
77
|
}
|
|
65
78
|
}
|
|
@@ -4,12 +4,14 @@ import { tmpdir } from "node:os";
|
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import test from "node:test";
|
|
6
6
|
import {
|
|
7
|
+
Config,
|
|
7
8
|
CONFIG_ROUTE,
|
|
8
9
|
ReplacementCoordinator,
|
|
9
10
|
VisionFallbackController,
|
|
10
11
|
apply,
|
|
11
12
|
buildVisionPrompt,
|
|
12
13
|
installCapabilityOverride,
|
|
14
|
+
installSettingsSectionCompat,
|
|
13
15
|
rewriteMessages,
|
|
14
16
|
withImageCapability
|
|
15
17
|
} from "../lib/index.js";
|
|
@@ -252,6 +254,64 @@ test("历史工具结果中的图片只以文字形式进入模型视图", async
|
|
|
252
254
|
}
|
|
253
255
|
});
|
|
254
256
|
|
|
257
|
+
test("rc.7 持久附件引用与 PTC 多层嵌套图片按顺序转换", async () => {
|
|
258
|
+
const originalFetch = globalThis.fetch;
|
|
259
|
+
const visionRequests = [];
|
|
260
|
+
globalThis.fetch = async (_url, options) => {
|
|
261
|
+
visionRequests.push(JSON.parse(options.body));
|
|
262
|
+
return {
|
|
263
|
+
ok: true,
|
|
264
|
+
json: async () => ({ choices: [{ message: { content: `视觉观察 ${visionRequests.length}` } }] })
|
|
265
|
+
};
|
|
266
|
+
};
|
|
267
|
+
const firstAttachment = {
|
|
268
|
+
attachmentId: "sha256:rc7-mcp-image",
|
|
269
|
+
mediaType: "image/png",
|
|
270
|
+
name: "mcp-result.png"
|
|
271
|
+
};
|
|
272
|
+
const nestedAttachment = {
|
|
273
|
+
attachmentId: "sha256:rc7-ptc-image",
|
|
274
|
+
mediaType: "image/jpeg",
|
|
275
|
+
name: "ptc-nested.jpg"
|
|
276
|
+
};
|
|
277
|
+
const original = userMessage("rc7-rich-result", [
|
|
278
|
+
{ type: "text", text: "工具返回了两张图:" },
|
|
279
|
+
{ type: "image", attachment: firstAttachment },
|
|
280
|
+
{
|
|
281
|
+
type: "tool-result",
|
|
282
|
+
toolCallId: "mcp-read-image",
|
|
283
|
+
content: [
|
|
284
|
+
{ type: "text", text: "PTC 子调用结果" },
|
|
285
|
+
{
|
|
286
|
+
type: "tool-result",
|
|
287
|
+
toolCallId: "ptc-read-image",
|
|
288
|
+
content: [{ type: "image", attachment: nestedAttachment }],
|
|
289
|
+
isError: false
|
|
290
|
+
}
|
|
291
|
+
],
|
|
292
|
+
isError: false
|
|
293
|
+
},
|
|
294
|
+
{ type: "text", text: "请按出现顺序说明。" }
|
|
295
|
+
]);
|
|
296
|
+
const controller = new VisionFallbackController(createServiceContext(), () => config);
|
|
297
|
+
|
|
298
|
+
try {
|
|
299
|
+
const [rewritten] = await controller.preprocess([original], [original], undefined, "rc7-session");
|
|
300
|
+
assert.equal(visionRequests.length, 2);
|
|
301
|
+
assert.equal(visionRequests[0].messages[1].content[1].image_url.url.startsWith("data:image/png;base64,"), true);
|
|
302
|
+
assert.equal(visionRequests[1].messages[1].content[1].image_url.url.startsWith("data:image/jpeg;base64,"), true);
|
|
303
|
+
assert.equal(rewritten.content[0].text, "工具返回了两张图:");
|
|
304
|
+
assert.match(rewritten.content[1].text, /视觉观察 1/);
|
|
305
|
+
assert.equal(rewritten.content[2].content[0].text, "PTC 子调用结果");
|
|
306
|
+
assert.match(rewritten.content[2].content[1].content[0].text, /视觉观察 2/);
|
|
307
|
+
assert.equal(JSON.stringify(rewritten).includes('"type":"image"'), false);
|
|
308
|
+
assert.equal(original.content[1].type, "image");
|
|
309
|
+
assert.equal(original.content[2].content[1].content[0].type, "image");
|
|
310
|
+
} finally {
|
|
311
|
+
globalThis.fetch = originalFetch;
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
|
|
255
315
|
test("图片能力覆盖可随开关变化并能恢复", async () => {
|
|
256
316
|
let current = config;
|
|
257
317
|
const original = async (provider, model) => ({
|
|
@@ -339,13 +399,41 @@ test("插件注册静默预处理钩子而不注册新模型 adapter", () => {
|
|
|
339
399
|
assert.equal(typeof cleanup, "function");
|
|
340
400
|
assert.equal(registeredSettings.namespace, "vision-fallback");
|
|
341
401
|
assert.equal(registeredSettings.options.base, config);
|
|
342
|
-
assert.equal(settingsEffects.length,
|
|
402
|
+
assert.equal(settingsEffects.length, 3);
|
|
343
403
|
assert.equal(webEffects.length, 1);
|
|
344
404
|
assert.equal(registeredRoute.kind, "exact");
|
|
345
405
|
assert.equal(registeredRoute.path, CONFIG_ROUTE);
|
|
346
406
|
assert.equal(replacements.length, 0);
|
|
347
407
|
});
|
|
348
408
|
|
|
409
|
+
test("alpha.5 使用 SettingsProvider.installSection 注册设置", () => {
|
|
410
|
+
let installed;
|
|
411
|
+
const ctx = {
|
|
412
|
+
inject(dependencies, callback) {
|
|
413
|
+
assert.deepEqual(dependencies, ["settings"]);
|
|
414
|
+
callback({
|
|
415
|
+
settings: {
|
|
416
|
+
installSection(...args) {
|
|
417
|
+
installed = args;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
};
|
|
423
|
+
const hooks = {
|
|
424
|
+
validate: assert.doesNotThrow,
|
|
425
|
+
setSource() {},
|
|
426
|
+
onChange() {}
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
installSettingsSectionCompat(ctx, "vision-fallback", Config, config, hooks);
|
|
430
|
+
assert.equal(installed[0], ctx);
|
|
431
|
+
assert.equal(installed[1], "vision-fallback");
|
|
432
|
+
assert.equal(installed[2], Config);
|
|
433
|
+
assert.equal(installed[3], config);
|
|
434
|
+
assert.equal(installed[4], hooks);
|
|
435
|
+
});
|
|
436
|
+
|
|
349
437
|
test("插件私有配置路由允许本机读取和保存", async () => {
|
|
350
438
|
let route;
|
|
351
439
|
let stored = config;
|
|
@@ -518,7 +606,7 @@ test("auto 模式按主模型真实视觉能力决定是否接管", async () =>
|
|
|
518
606
|
resolveModelInfo: async () => ({ provider: "opencode-go", id: "x", name: "x", inputModalities: realModalities })
|
|
519
607
|
},
|
|
520
608
|
logger: { error() {} },
|
|
521
|
-
effect() {},
|
|
609
|
+
effect(factory) { factory(); },
|
|
522
610
|
on(name, handler) {
|
|
523
611
|
if (name === "agent/pre-step") preStep = handler;
|
|
524
612
|
},
|
|
@@ -528,10 +616,10 @@ test("auto 模式按主模型真实视觉能力决定是否接管", async () =>
|
|
|
528
616
|
settings: {
|
|
529
617
|
register: (_ns, _schema, options) => ({ get: () => options.base, watch() {} })
|
|
530
618
|
},
|
|
531
|
-
effect() {}
|
|
619
|
+
effect(factory) { factory(); }
|
|
532
620
|
});
|
|
533
621
|
} else {
|
|
534
|
-
cb({ webServer: { register: () => () => {} }, effect() {} });
|
|
622
|
+
cb({ webServer: { register: () => () => {} }, effect(factory) { factory(); } });
|
|
535
623
|
}
|
|
536
624
|
},
|
|
537
625
|
get(name) {
|
|
@@ -706,12 +794,17 @@ test("compaction 请求不再重新识图,普通请求保持原样", async ()
|
|
|
706
794
|
|
|
707
795
|
const makeSignal = () => ({ aborted: false, addEventListener() {}, removeEventListener() {} });
|
|
708
796
|
let streamHandler;
|
|
797
|
+
const rerouted = [];
|
|
709
798
|
const ctx = {
|
|
710
799
|
llm: {
|
|
711
|
-
resolveModelInfo: async () => ({ provider: "opencode-go", id: "deepseek-v4-flash", name: "deepseek-v4-flash", inputModalities: ["text"] })
|
|
800
|
+
resolveModelInfo: async () => ({ provider: "opencode-go", id: "deepseek-v4-flash", name: "deepseek-v4-flash", inputModalities: ["text"] }),
|
|
801
|
+
stream(options) {
|
|
802
|
+
rerouted.push(options);
|
|
803
|
+
return emptyStream();
|
|
804
|
+
}
|
|
712
805
|
},
|
|
713
806
|
logger: { error() {} },
|
|
714
|
-
effect() {},
|
|
807
|
+
effect(factory) { factory(); },
|
|
715
808
|
on(name, handler) {
|
|
716
809
|
if (name === "llm/stream") streamHandler = handler;
|
|
717
810
|
},
|
|
@@ -721,10 +814,10 @@ test("compaction 请求不再重新识图,普通请求保持原样", async ()
|
|
|
721
814
|
settings: {
|
|
722
815
|
register: (_ns, _schema, options) => ({ get: () => options.base, watch() {} })
|
|
723
816
|
},
|
|
724
|
-
effect() {}
|
|
817
|
+
effect(factory) { factory(); }
|
|
725
818
|
});
|
|
726
819
|
} else {
|
|
727
|
-
cb({ webServer: { register: () => () => {} }, effect() {} });
|
|
820
|
+
cb({ webServer: { register: () => () => {} }, effect(factory) { factory(); } });
|
|
728
821
|
}
|
|
729
822
|
},
|
|
730
823
|
get(name) {
|
|
@@ -742,19 +835,21 @@ test("compaction 请求不再重新识图,普通请求保持原样", async ()
|
|
|
742
835
|
apply(ctx, { ...config, mode: "auto" });
|
|
743
836
|
assert.equal(typeof streamHandler, "function");
|
|
744
837
|
|
|
745
|
-
const compactionOptions = {
|
|
838
|
+
const compactionOptions = Object.freeze({
|
|
746
839
|
purpose: "compaction",
|
|
747
840
|
provider: "opencode-go",
|
|
748
841
|
model: "deepseek-v4-flash",
|
|
749
842
|
signal: makeSignal(),
|
|
750
|
-
messages: [userMessage("compact-1", [{ type: "text", text: "请压缩上下文" }, imageBlock])]
|
|
751
|
-
};
|
|
843
|
+
messages: Object.freeze([userMessage("compact-1", [{ type: "text", text: "请压缩上下文" }, imageBlock])])
|
|
844
|
+
});
|
|
752
845
|
const compactionStream = streamHandler(compactionOptions, emptyStream);
|
|
753
846
|
assert.equal(typeof compactionStream[Symbol.asyncIterator], "function");
|
|
754
847
|
await consumeStream(compactionStream);
|
|
755
848
|
assert.equal(visionRequests, 0);
|
|
756
|
-
assert.equal(JSON.stringify(compactionOptions.messages).includes('"type":"image"'),
|
|
757
|
-
assert.
|
|
849
|
+
assert.equal(JSON.stringify(compactionOptions.messages).includes('"type":"image"'), true);
|
|
850
|
+
assert.equal(rerouted.length, 1);
|
|
851
|
+
assert.equal(JSON.stringify(rerouted[0].messages).includes('"type":"image"'), false);
|
|
852
|
+
assert.match(rerouted[0].messages[0].content[1].text, /主模型已直接查看原图/);
|
|
758
853
|
|
|
759
854
|
const normalOptions = {
|
|
760
855
|
purpose: undefined,
|
|
@@ -767,6 +862,7 @@ test("compaction 请求不再重新识图,普通请求保持原样", async ()
|
|
|
767
862
|
await consumeStream(streamHandler(normalOptions, emptyStream));
|
|
768
863
|
assert.equal(JSON.stringify(normalOptions.messages), before);
|
|
769
864
|
assert.equal(visionRequests, 0);
|
|
865
|
+
assert.equal(rerouted.length, 1);
|
|
770
866
|
} finally {
|
|
771
867
|
globalThis.fetch = previousFetch;
|
|
772
868
|
}
|
|
@@ -784,6 +880,7 @@ test("压缩优先复用主模型已经拿到的视觉观察", async () => {
|
|
|
784
880
|
};
|
|
785
881
|
const makeSignal = () => ({ aborted: false, addEventListener() {}, removeEventListener() {} });
|
|
786
882
|
let streamHandler;
|
|
883
|
+
const rerouted = [];
|
|
787
884
|
const session = {
|
|
788
885
|
deriveMessages: () => [rewritten]
|
|
789
886
|
};
|
|
@@ -795,18 +892,22 @@ test("压缩优先复用主模型已经拿到的视觉观察", async () => {
|
|
|
795
892
|
}
|
|
796
893
|
},
|
|
797
894
|
llm: {
|
|
798
|
-
resolveModelInfo: async () => ({ provider: "opencode-go", id: "deepseek-v4-flash", name: "deepseek-v4-flash", inputModalities: ["text"] })
|
|
895
|
+
resolveModelInfo: async () => ({ provider: "opencode-go", id: "deepseek-v4-flash", name: "deepseek-v4-flash", inputModalities: ["text"] }),
|
|
896
|
+
stream(options) {
|
|
897
|
+
rerouted.push(options);
|
|
898
|
+
return emptyStream();
|
|
899
|
+
}
|
|
799
900
|
},
|
|
800
901
|
logger: { error() {} },
|
|
801
|
-
effect() {},
|
|
902
|
+
effect(factory) { factory(); },
|
|
802
903
|
on(name, handler) {
|
|
803
904
|
if (name === "llm/stream") streamHandler = handler;
|
|
804
905
|
},
|
|
805
906
|
inject(deps, cb) {
|
|
806
907
|
if (deps[0] === "settings") {
|
|
807
|
-
cb({ settings: { register: (_ns, _schema, options) => ({ get: () => options.base, watch() {} }) }, effect() {} });
|
|
908
|
+
cb({ settings: { register: (_ns, _schema, options) => ({ get: () => options.base, watch() {} }) }, effect(factory) { factory(); } });
|
|
808
909
|
} else {
|
|
809
|
-
cb({ webServer: { register: () => () => {} }, effect() {} });
|
|
910
|
+
cb({ webServer: { register: () => () => {} }, effect(factory) { factory(); } });
|
|
810
911
|
}
|
|
811
912
|
},
|
|
812
913
|
get() {
|
|
@@ -816,18 +917,21 @@ test("压缩优先复用主模型已经拿到的视觉观察", async () => {
|
|
|
816
917
|
|
|
817
918
|
try {
|
|
818
919
|
apply(ctx, { ...config, mode: "auto" });
|
|
819
|
-
const options = {
|
|
920
|
+
const options = Object.freeze({
|
|
820
921
|
purpose: "compaction",
|
|
821
922
|
sessionId: "session-reuse",
|
|
822
923
|
provider: "opencode-go",
|
|
823
924
|
model: "deepseek-v4-flash",
|
|
824
925
|
signal: makeSignal(),
|
|
825
|
-
messages: [original]
|
|
826
|
-
};
|
|
926
|
+
messages: Object.freeze([original])
|
|
927
|
+
});
|
|
827
928
|
await consumeStream(streamHandler(options, emptyStream));
|
|
828
929
|
assert.equal(visionRequests, 0);
|
|
829
|
-
assert.equal(options.messages[0],
|
|
830
|
-
assert.equal(JSON.stringify(options.messages).includes('"type":"image"'),
|
|
930
|
+
assert.equal(options.messages[0], original);
|
|
931
|
+
assert.equal(JSON.stringify(options.messages).includes('"type":"image"'), true);
|
|
932
|
+
assert.equal(rerouted.length, 1);
|
|
933
|
+
assert.equal(rerouted[0].messages[0], rewritten);
|
|
934
|
+
assert.equal(JSON.stringify(rerouted[0].messages).includes('"type":"image"'), false);
|
|
831
935
|
} finally {
|
|
832
936
|
globalThis.fetch = previousFetch;
|
|
833
937
|
}
|
|
@@ -848,15 +952,15 @@ test("compaction 请求在 mode=never 时不拦截图片", async () => {
|
|
|
848
952
|
resolveModelInfo: async () => ({ provider: "opencode-go", id: "deepseek-v4-flash", name: "deepseek-v4-flash", inputModalities: ["text"] })
|
|
849
953
|
},
|
|
850
954
|
logger: { error() {} },
|
|
851
|
-
effect() {},
|
|
955
|
+
effect(factory) { factory(); },
|
|
852
956
|
on(name, handler) {
|
|
853
957
|
if (name === "llm/stream") streamHandler = handler;
|
|
854
958
|
},
|
|
855
959
|
inject(deps, cb) {
|
|
856
960
|
if (deps[0] === "settings") {
|
|
857
|
-
cb({ settings: { register: (_ns, _schema, options) => ({ get: () => options.base, watch() {} }) }, effect() {} });
|
|
961
|
+
cb({ settings: { register: (_ns, _schema, options) => ({ get: () => options.base, watch() {} }) }, effect(factory) { factory(); } });
|
|
858
962
|
} else {
|
|
859
|
-
cb({ webServer: { register: () => () => {} }, effect() {} });
|
|
963
|
+
cb({ webServer: { register: () => () => {} }, effect(factory) { factory(); } });
|
|
860
964
|
}
|
|
861
965
|
},
|
|
862
966
|
get() {
|
|
@@ -958,3 +1062,23 @@ test("usage 记录失败调用并带错误信息", async () => {
|
|
|
958
1062
|
await rm(dir, { recursive: true, force: true });
|
|
959
1063
|
}
|
|
960
1064
|
});
|
|
1065
|
+
|
|
1066
|
+
test("npm 包声明 DSH bundle、运行时范围和逐版本兼容状态", async () => {
|
|
1067
|
+
const manifest = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8"));
|
|
1068
|
+
const patch = await readFile(new URL("../cordis.patch.yml", import.meta.url), "utf8");
|
|
1069
|
+
|
|
1070
|
+
assert.equal(manifest.version, "0.10.0");
|
|
1071
|
+
assert.equal(manifest.dsh?.bundle?.patch, "./cordis.patch.yml");
|
|
1072
|
+
assert.equal(manifest.dsh?.compatibility?.dsh, ">=0.1.1-rc.2 <0.2.0");
|
|
1073
|
+
assert.deepEqual(manifest.dsh?.compatibility?.dshReleases, {
|
|
1074
|
+
"0.1.2-alpha.3": "compatible",
|
|
1075
|
+
"0.1.2-alpha.4": "compatible",
|
|
1076
|
+
"0.1.2-alpha.5": "compatible"
|
|
1077
|
+
});
|
|
1078
|
+
assert.equal(manifest.engines?.node, ">=22.13.0");
|
|
1079
|
+
assert.ok(manifest.files.includes("cordis.patch.yml"));
|
|
1080
|
+
assert.ok(manifest.files.includes("CHANGELOG.md"));
|
|
1081
|
+
assert.ok(manifest.keywords.includes("dsh-plugin"));
|
|
1082
|
+
assert.match(patch, /id:\s*vision-fallback/);
|
|
1083
|
+
assert.match(patch, /name:\s*['"]dsh-vision-fallback['"]/);
|
|
1084
|
+
});
|