dsh-vision-fallback 0.7.0 → 0.9.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/CHANGELOG.md +16 -0
- package/README.md +48 -2
- package/README.zh.md +48 -2
- package/lib/index.js +273 -19
- package/package.json +3 -1
- package/test/vision-fallback.test.mjs +393 -1
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.9.1 - 2026-08-18
|
|
4
|
+
|
|
5
|
+
- Added the `dsh-plugin` npm keyword and a regression test for the installable DSH bundle manifest.
|
|
6
|
+
- Expanded the documentation for compaction reuse, tool-result images, persistent observations, and restart recovery.
|
|
7
|
+
|
|
8
|
+
## 0.9.0 - 2026-08-16
|
|
9
|
+
|
|
10
|
+
- Persisted successful observations to `observations.json` and reused them across restart, replay, and compaction.
|
|
11
|
+
- Scoped observations by session and message position so a newly posted copy of the same image can be analyzed again with fresh context.
|
|
12
|
+
- Recorded vision-call latency, status, image metadata, and token usage in `usage.jsonl`.
|
|
13
|
+
|
|
14
|
+
## 0.7.0 - 2026-08-14
|
|
15
|
+
|
|
16
|
+
- Kept the selected main model in control while silently routing image understanding to the configured vision model.
|
package/README.md
CHANGED
|
@@ -31,6 +31,14 @@ agent/pre-step ──► image + current question + recent context
|
|
|
31
31
|
4. A **model-only surface replacement** swaps the image for the vision observation before the request reaches the main model.
|
|
32
32
|
5. Switching the main model (DeepSeek, Kimi, MiniMax, ...) never changes the fixed vision model.
|
|
33
33
|
|
|
34
|
+
### Complete call paths
|
|
35
|
+
|
|
36
|
+
- **Normal conversations**: `agent/pre-step` processes images before the main-model request and projects observations into the model view.
|
|
37
|
+
- **Context compaction**: the `llm/stream` path with `purpose: "compaction"` reuses existing observations first and only describes genuinely unseen images.
|
|
38
|
+
- **Tool-result images**: images inside `tool/result` events are projected for the model while the original tool result remains visible and traceable in the UI.
|
|
39
|
+
- **Reloads and restarts**: observations are persisted to `observations.json`, so the same session/message position does not trigger another vision call after restart.
|
|
40
|
+
- **Model capability detection**: in `auto` mode the plugin reads the main model's real `inputModalities`; it stays out of the way for image-capable models and bridges only text-only routes.
|
|
41
|
+
|
|
34
42
|
## Install
|
|
35
43
|
|
|
36
44
|
### From npm / local checkout
|
|
@@ -47,7 +55,7 @@ dsh plugin --profile web add dsh-vision-fallback
|
|
|
47
55
|
git clone https://github.com/1HelloMan1/dsh-vision-fallback.git
|
|
48
56
|
cd dsh-vision-fallback
|
|
49
57
|
pnpm install --config.minimumReleaseAge=0 # rc.6 peers need the release-age flag bypassed
|
|
50
|
-
pnpm test #
|
|
58
|
+
pnpm test # 22 unit tests
|
|
51
59
|
dsh plugin --profile web add "$PWD"
|
|
52
60
|
```
|
|
53
61
|
|
|
@@ -106,6 +114,44 @@ The API key is resolved through the DSH **credentials** system (`~/.dsh/.credent
|
|
|
106
114
|
- Observations are delivered as model-only surface replacements; your original image is never altered in the UI.
|
|
107
115
|
- Image reads go through the DSH attachment service (sandbox/observation-policy aware); the vision request carries the official `attributionHeaders()`.
|
|
108
116
|
|
|
117
|
+
## Usage records (usage.jsonl)
|
|
118
|
+
|
|
119
|
+
With `recordUsage` enabled, every real vision call (success or failure) appends one JSON line to
|
|
120
|
+
`<dshHome>/vision-fallback/usage.jsonl` (override via `usageLogPath` in the settings page), consumed by usage-dashboard.
|
|
121
|
+
Fields:
|
|
122
|
+
|
|
123
|
+
| Field | Meaning |
|
|
124
|
+
| --- | --- |
|
|
125
|
+
| `ts` | Call start time (epoch ms) |
|
|
126
|
+
| `durationMs` | Response latency of this call (ms) |
|
|
127
|
+
| `kind` | Always `"vision"` |
|
|
128
|
+
| `status` | `"ok"` success / `"error"` failure |
|
|
129
|
+
| `model` | Vision model name |
|
|
130
|
+
| `inputTokens` / `outputTokens` | Input / output tokens |
|
|
131
|
+
| `cacheReadTokens` | Cached input tokens served |
|
|
132
|
+
| `error` | Error message (failure entries only) |
|
|
133
|
+
| `imageName` / `mediaType` / `imageBytes` | Image filename / media type / byte size |
|
|
134
|
+
| `imageIndex` / `imageTotal` | This image's position / total images in the request |
|
|
135
|
+
|
|
136
|
+
Reusing a remembered observation (observations.json) does **not** append a line — this file counts real external vision calls only.
|
|
137
|
+
|
|
138
|
+
### Observation cache semantics
|
|
139
|
+
|
|
140
|
+
Observations are keyed by **the image's occurrence in a session** (session id + message id):
|
|
141
|
+
|
|
142
|
+
- The same image at the **same message position** processed again (restart recovery, replay, compaction) → reused, no re-recognition;
|
|
143
|
+
- The same image at a **new position in the session** (new turn, main model asking to "look again") → **re-recognized** with fresh context;
|
|
144
|
+
- Observations are **not** shared across sessions.
|
|
145
|
+
|
|
146
|
+
The cache is capped at 256 entries (LRU eviction); failed results are never cached.
|
|
147
|
+
|
|
148
|
+
Cache location: when `usageLogPath` is set, `observations.json` is written beside that log; otherwise it is stored at
|
|
149
|
+
`<dshHome>/vision-fallback/observations.json`. This lets compaction, event replay, and service restarts reuse successful observations.
|
|
150
|
+
|
|
151
|
+
### Compaction, tool results, and restarts
|
|
152
|
+
|
|
153
|
+
When the main model can already see images, the bridge does not call the vision model. If a later text-only compaction model encounters the original image event, the plugin first reuses the main model's projected observation and the persistent cache, then handles only images that truly have no observation. This prevents every compaction from re-describing the entire image history while still allowing the same image at a new message position to be re-read with fresh context.
|
|
154
|
+
|
|
109
155
|
## Default vision route
|
|
110
156
|
|
|
111
157
|
- Model: `mimo-v2.5` · Endpoint: `https://opencode.ai/zen/go/v1/chat/completions` · Credential: `OPENCODE_GO_API_KEY`
|
|
@@ -119,7 +165,7 @@ The OpenCode community `opencode-see-image` hands a `filePath` + task `question`
|
|
|
119
165
|
## Development
|
|
120
166
|
|
|
121
167
|
```sh
|
|
122
|
-
pnpm test # node --test test/*.test.mjs —
|
|
168
|
+
pnpm test # node --test test/*.test.mjs — 22 tests
|
|
123
169
|
```
|
|
124
170
|
|
|
125
171
|
Structure:
|
package/README.zh.md
CHANGED
|
@@ -31,6 +31,14 @@ agent/pre-step ──► 图片 + 当前问题 + 最近上下文
|
|
|
31
31
|
4. **仅模型可见的 surface replacement** 把图片换成视觉观察后再交给主模型。
|
|
32
32
|
5. 切换主模型(DeepSeek、Kimi、MiniMax……)不会改变固定视觉模型。
|
|
33
33
|
|
|
34
|
+
### 完整调用线路
|
|
35
|
+
|
|
36
|
+
- **普通对话**:`agent/pre-step` 在主模型请求前处理图片,并把视觉观察投影到模型视图。
|
|
37
|
+
- **上下文压缩**:`llm/stream` 的 `purpose: "compaction"` 线路会先复用已有观察;只有确实没有观察的图片才补一次识别。
|
|
38
|
+
- **工具结果图片**:`tool/result` 中的图片也会进入模型投影,界面仍保留原始图片,不把工具结果改成用户不可追溯的纯文本。
|
|
39
|
+
- **会话重载 / 重启**:观察结果持久化到 `observations.json`,重启后同一会话、同一消息位置不会再次调用视觉模型。
|
|
40
|
+
- **模型能力判断**:`auto` 模式会读取主模型的真实 `inputModalities`;主模型原生支持图片时不重复接管,纯文本主模型才走视觉桥。
|
|
41
|
+
|
|
34
42
|
## 安装
|
|
35
43
|
|
|
36
44
|
### npm / 本地检出
|
|
@@ -47,7 +55,7 @@ dsh plugin --profile web add dsh-vision-fallback
|
|
|
47
55
|
git clone https://github.com/1HelloMan1/dsh-vision-fallback.git
|
|
48
56
|
cd dsh-vision-fallback
|
|
49
57
|
pnpm install --config.minimumReleaseAge=0 # rc.6 peer 依赖需绕过发布年龄策略
|
|
50
|
-
pnpm test #
|
|
58
|
+
pnpm test # 22 个单元测试
|
|
51
59
|
dsh plugin --profile web add "$PWD"
|
|
52
60
|
```
|
|
53
61
|
|
|
@@ -106,6 +114,44 @@ API key 通过 DSH **凭证系统**(`~/.dsh/.credentials.yaml`)解析,`pro
|
|
|
106
114
|
- 观察结果以 model-only surface replacement 交付,界面中的原始图片永不被改写。
|
|
107
115
|
- 图片读取走 DSH 附件服务(遵守沙箱与观察策略);视觉请求携带官方 `attributionHeaders()`。
|
|
108
116
|
|
|
117
|
+
## 用量记录(usage.jsonl)
|
|
118
|
+
|
|
119
|
+
开启 `recordUsage` 后,每次真实视觉调用(成功或失败)追加一行 JSON 到
|
|
120
|
+
`<dshHome>/vision-fallback/usage.jsonl`(可在设置页改 `usageLogPath`),供 usage-dashboard 统计。
|
|
121
|
+
字段:
|
|
122
|
+
|
|
123
|
+
| 字段 | 含义 |
|
|
124
|
+
| --- | --- |
|
|
125
|
+
| `ts` | 调用发起时刻(毫秒时间戳) |
|
|
126
|
+
| `durationMs` | 本次调用响应耗时(毫秒) |
|
|
127
|
+
| `kind` | 固定 `"vision"` |
|
|
128
|
+
| `status` | `"ok"` 成功 / `"error"` 失败 |
|
|
129
|
+
| `model` | 视觉模型名 |
|
|
130
|
+
| `inputTokens` / `outputTokens` | 输入 / 输出 token 数 |
|
|
131
|
+
| `cacheReadTokens` | 命中缓存读取的 token 数 |
|
|
132
|
+
| `error` | 失败时的错误信息(仅失败条目) |
|
|
133
|
+
| `imageName` / `mediaType` / `imageBytes` | 图片文件名 / 媒体类型 / 字节数 |
|
|
134
|
+
| `imageIndex` / `imageTotal` | 该图在本次请求中的第几张 / 总张数 |
|
|
135
|
+
|
|
136
|
+
复用已记忆的观察结果(observations.json)不会产生新条目——该文件统计的是真实外部视觉调用次数。
|
|
137
|
+
|
|
138
|
+
### 观察缓存语义
|
|
139
|
+
|
|
140
|
+
观察结果按**图片在某次会话中的出现位置**(会话 id + 消息 id)记录:
|
|
141
|
+
|
|
142
|
+
- 同一图片在**同一消息位置**被重复处理(重启恢复、重放、压缩)→ 直接复用,不重新识别;
|
|
143
|
+
- 同一图片在**会话的新位置**再次出现(新轮次、主模型"再仔细看看")→ **重新识别**,生成贴合当前上下文的新观察;
|
|
144
|
+
- 不同会话之间不共享观察结果。
|
|
145
|
+
|
|
146
|
+
缓存上限 256 条(LRU 淘汰),失败结果不入缓存。
|
|
147
|
+
|
|
148
|
+
缓存文件位置:如果设置了 `usageLogPath`,缓存写在同一目录的 `observations.json`;否则使用
|
|
149
|
+
`<dshHome>/vision-fallback/observations.json`。因此压缩、事件回放和服务重启都可以复用已经成功得到的观察。
|
|
150
|
+
|
|
151
|
+
### 压缩、工具结果与重启
|
|
152
|
+
|
|
153
|
+
主模型能看图时,视觉桥不会重复调用视觉模型;但如果后续使用纯文本压缩模型,压缩线路可能再次遇到原始图片。插件会优先复用主模型已有的视觉投影和持久化观察,再处理真正没有结果的图片。这样可以避免“每次压缩重新识别整段历史图片”,同时保留新消息位置重新识别的能力。
|
|
154
|
+
|
|
109
155
|
## 默认视觉路由
|
|
110
156
|
|
|
111
157
|
- 模型:`mimo-v2.5` · 端点:`https://opencode.ai/zen/go/v1/chat/completions` · 凭据:`OPENCODE_GO_API_KEY`
|
|
@@ -119,7 +165,7 @@ OpenCode 社区的 `opencode-see-image` 把 `filePath` 和针对当前任务的
|
|
|
119
165
|
## 开发
|
|
120
166
|
|
|
121
167
|
```sh
|
|
122
|
-
pnpm test # node --test test/*.test.mjs —
|
|
168
|
+
pnpm test # node --test test/*.test.mjs — 22 个测试
|
|
123
169
|
```
|
|
124
170
|
|
|
125
171
|
目录结构:
|
package/lib/index.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* @module dsh-vision-fallback
|
|
9
9
|
*/
|
|
10
10
|
import { createHash } from "node:crypto";
|
|
11
|
-
import { appendFileSync, mkdirSync } from "node:fs";
|
|
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";
|
|
@@ -161,6 +161,66 @@ function latestUserText(messages) {
|
|
|
161
161
|
return "(用户本轮只提供了图片,没有附加文字问题)";
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
+
/**
|
|
165
|
+
* 观察缓存的 key:图片在某次会话中的一次出现。
|
|
166
|
+
* 同一图片在同一会话的不同位置(不同消息)视为不同出现,需要重新识别;
|
|
167
|
+
* 同一位置被重复处理(重启恢复、重放、压缩)时才能复用。
|
|
168
|
+
* @param {object} attachmentRef 图片附件引用
|
|
169
|
+
* @param {object} cfg 插件配置
|
|
170
|
+
* @param {{sessionId?: string, messageId?: string}} [scope] 图片出现的会话位置
|
|
171
|
+
*/
|
|
172
|
+
function observationKey(attachmentRef, cfg, scope) {
|
|
173
|
+
const attachmentId = attachmentRef?.attachmentId ?? JSON.stringify(attachmentRef);
|
|
174
|
+
return `${attachmentId}\0${scope?.sessionId ?? ""}\0${scope?.messageId ?? ""}\0${cfg.baseURL}\0${cfg.model}`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const MAX_OBSERVATIONS = 256;
|
|
178
|
+
|
|
179
|
+
function rememberObservation(store, key, text) {
|
|
180
|
+
if (text.startsWith("【图片转换失败:")) return;
|
|
181
|
+
store.set(key, text);
|
|
182
|
+
if (store.size > MAX_OBSERVATIONS) store.delete(store.keys().next().value);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function observationStorePath(cfg) {
|
|
186
|
+
if (typeof cfg.usageLogPath === "string" && cfg.usageLogPath !== "") {
|
|
187
|
+
return join(dirname(cfg.usageLogPath), "observations.json");
|
|
188
|
+
}
|
|
189
|
+
if (cfg.recordUsage === true) {
|
|
190
|
+
return join(resolveDshHome(), "vision-fallback", "observations.json");
|
|
191
|
+
}
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function loadObservations(cfg) {
|
|
196
|
+
const store = new Map();
|
|
197
|
+
const path = observationStorePath(cfg);
|
|
198
|
+
if (path === undefined) return store;
|
|
199
|
+
try {
|
|
200
|
+
const raw = JSON.parse(readFileSync(path, "utf8"));
|
|
201
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return store;
|
|
202
|
+
for (const [key, text] of Object.entries(raw)) {
|
|
203
|
+
if (typeof text === "string" && text !== "" && !text.startsWith("【图片转换失败:")) {
|
|
204
|
+
store.set(key, text);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
} catch {
|
|
208
|
+
// 缓存文件不存在或损坏时按空缓存继续。
|
|
209
|
+
}
|
|
210
|
+
return store;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function persistObservations(cfg, store) {
|
|
214
|
+
const path = observationStorePath(cfg);
|
|
215
|
+
if (path === undefined) return;
|
|
216
|
+
try {
|
|
217
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
218
|
+
writeFileSync(path, `${JSON.stringify(Object.fromEntries(store))}\n`, "utf8");
|
|
219
|
+
} catch (error) {
|
|
220
|
+
console.warn(`vision-fallback: 保存视觉观察缓存失败:${errorText(error)}`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
164
224
|
function recentConversation(messages, cfg) {
|
|
165
225
|
if (!cfg.includeRecentContext || cfg.contextMessages === 0 || cfg.contextMaxChars === 0) return "";
|
|
166
226
|
const rows = [];
|
|
@@ -205,9 +265,20 @@ function errorText(error) {
|
|
|
205
265
|
|
|
206
266
|
/**
|
|
207
267
|
* 记录一次视觉调用用量(追加 JSONL),供 usage-dashboard 统计。
|
|
208
|
-
*
|
|
268
|
+
* 成功与失败都会记录;失败仅告警,不影响主流程。
|
|
269
|
+
* @param {object} cfg 插件配置
|
|
270
|
+
* @param {object} record 记录内容
|
|
271
|
+
* @param {number} [record.startedAt] 调用发起时刻(毫秒时间戳),作为 ts
|
|
272
|
+
* @param {object} [record.usage] OpenAI 风格用量对象(prompt_tokens 等)
|
|
273
|
+
* @param {"ok"|"error"} [record.status] 调用结果状态
|
|
274
|
+
* @param {string} [record.error] 失败时的错误信息(截断 500 字符)
|
|
275
|
+
* @param {string} [record.imageName] 图片文件名
|
|
276
|
+
* @param {string} [record.mediaType] 图片媒体类型(如 image/png)
|
|
277
|
+
* @param {number} [record.imageBytes] 图片字节数
|
|
278
|
+
* @param {number} [record.imageIndex] 本次请求中第几张图(从 1 开始)
|
|
279
|
+
* @param {number} [record.imageTotal] 本次请求图片总数
|
|
209
280
|
*/
|
|
210
|
-
function recordVisionUsage(cfg, usage,
|
|
281
|
+
function recordVisionUsage(cfg, { startedAt, usage, status = "ok", error, imageName, mediaType, imageBytes, imageIndex, imageTotal }) {
|
|
211
282
|
if (!cfg.recordUsage) return;
|
|
212
283
|
try {
|
|
213
284
|
const logPath = cfg.usageLogPath !== ""
|
|
@@ -216,12 +287,21 @@ function recordVisionUsage(cfg, usage, startedAt) {
|
|
|
216
287
|
const u = usage ?? {};
|
|
217
288
|
const entry = {
|
|
218
289
|
ts: startedAt ?? Date.now(),
|
|
290
|
+
durationMs: Math.max(0, Date.now() - (startedAt ?? Date.now())),
|
|
291
|
+
kind: "vision",
|
|
292
|
+
status,
|
|
219
293
|
model: cfg.model,
|
|
220
294
|
inputTokens: u.prompt_tokens ?? 0,
|
|
221
295
|
outputTokens: u.completion_tokens ?? 0,
|
|
222
296
|
cacheReadTokens: u.prompt_tokens_details?.cached_tokens ?? 0,
|
|
223
297
|
cacheWriteTokens: 0
|
|
224
298
|
};
|
|
299
|
+
if (error !== undefined) entry.error = String(error).slice(0, 500);
|
|
300
|
+
if (imageName !== undefined) entry.imageName = imageName;
|
|
301
|
+
if (mediaType !== undefined) entry.mediaType = mediaType;
|
|
302
|
+
if (imageBytes !== undefined) entry.imageBytes = imageBytes;
|
|
303
|
+
if (imageIndex !== undefined) entry.imageIndex = imageIndex;
|
|
304
|
+
if (imageTotal !== undefined) entry.imageTotal = imageTotal;
|
|
225
305
|
mkdirSync(dirname(logPath), { recursive: true });
|
|
226
306
|
appendFileSync(logPath, `${JSON.stringify(entry)}\n`, "utf8");
|
|
227
307
|
} catch (error) {
|
|
@@ -257,7 +337,35 @@ async function resolveApiKey(credentials, ref) {
|
|
|
257
337
|
throw new Error(`未找到 API Key:请在 DSH 凭证中配置 ${ref}`);
|
|
258
338
|
}
|
|
259
339
|
|
|
260
|
-
async function describeImage(ctx, apiKey, cfg, attachmentRef, prompt, signal) {
|
|
340
|
+
async function describeImage(ctx, apiKey, cfg, attachmentRef, prompt, signal, meta) {
|
|
341
|
+
const startedAt = Date.now();
|
|
342
|
+
const metaInfo = {
|
|
343
|
+
imageName: attachmentRef?.name,
|
|
344
|
+
imageIndex: meta?.imageIndex,
|
|
345
|
+
imageTotal: meta?.imageTotal
|
|
346
|
+
};
|
|
347
|
+
try {
|
|
348
|
+
const result = await performDescribeImage(ctx, apiKey, cfg, attachmentRef, prompt, signal);
|
|
349
|
+
recordVisionUsage(cfg, {
|
|
350
|
+
startedAt,
|
|
351
|
+
...metaInfo,
|
|
352
|
+
mediaType: result.mediaType,
|
|
353
|
+
imageBytes: result.imageBytes,
|
|
354
|
+
usage: result.usage
|
|
355
|
+
});
|
|
356
|
+
return result.text;
|
|
357
|
+
} catch (error) {
|
|
358
|
+
recordVisionUsage(cfg, {
|
|
359
|
+
startedAt,
|
|
360
|
+
...metaInfo,
|
|
361
|
+
status: "error",
|
|
362
|
+
error: errorText(error)
|
|
363
|
+
});
|
|
364
|
+
throw error;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async function performDescribeImage(ctx, apiKey, cfg, attachmentRef, prompt, signal) {
|
|
261
369
|
const attachments = ctx.get("attachments");
|
|
262
370
|
if (attachments === undefined || typeof attachments.readImage !== "function") {
|
|
263
371
|
throw new Error("attachments 服务不可用,无法读取聊天图片");
|
|
@@ -323,29 +431,33 @@ async function describeImage(ctx, apiKey, cfg, attachmentRef, prompt, signal) {
|
|
|
323
431
|
throw new Error(`视觉 API 返回 ${response.status} ${response.statusText}:${snippet}`);
|
|
324
432
|
}
|
|
325
433
|
const data = await response.json();
|
|
326
|
-
recordVisionUsage(cfg, data?.usage, Date.now());
|
|
327
434
|
const content = data?.choices?.[0]?.message?.content;
|
|
328
435
|
const text = Array.isArray(content)
|
|
329
436
|
? content.map((part) => typeof part?.text === "string" ? part.text : "").join("")
|
|
330
437
|
: typeof content === "string" ? content : "";
|
|
331
438
|
if (text.trim() === "") throw new Error("视觉 API 返回空文本");
|
|
332
|
-
return
|
|
333
|
-
|
|
334
|
-
|
|
439
|
+
return {
|
|
440
|
+
text: cfg.tagResult
|
|
441
|
+
? `【视觉观察:${cfg.model}${attachmentRef?.name ? ` · ${attachmentRef.name}` : ""}】\n${text.trim()}`
|
|
442
|
+
: text.trim(),
|
|
443
|
+
usage: data?.usage,
|
|
444
|
+
mediaType: stored.ref.mediaType,
|
|
445
|
+
imageBytes: bytes.length
|
|
446
|
+
};
|
|
335
447
|
}
|
|
336
448
|
|
|
337
|
-
async function rewriteContent(content, describe, counter) {
|
|
449
|
+
async function rewriteContent(content, describe, counter, messageId) {
|
|
338
450
|
const rewritten = [];
|
|
339
451
|
for (const block of content) {
|
|
340
452
|
if (block?.type === "image") {
|
|
341
453
|
counter.index += 1;
|
|
342
|
-
rewritten.push({ type: "text", text: await describe(block.attachment, counter.index) });
|
|
454
|
+
rewritten.push({ type: "text", text: await describe(block.attachment, counter.index, messageId) });
|
|
343
455
|
continue;
|
|
344
456
|
}
|
|
345
457
|
if (block?.type === "tool-result" && contentHasImage(block.content ?? [])) {
|
|
346
458
|
rewritten.push({
|
|
347
459
|
...block,
|
|
348
|
-
content: await rewriteContent(block.content ?? [], describe, counter)
|
|
460
|
+
content: await rewriteContent(block.content ?? [], describe, counter, messageId)
|
|
349
461
|
});
|
|
350
462
|
continue;
|
|
351
463
|
}
|
|
@@ -367,8 +479,9 @@ async function rewriteMessages(messages, describe) {
|
|
|
367
479
|
...message,
|
|
368
480
|
content: await rewriteContent(
|
|
369
481
|
message.content ?? [],
|
|
370
|
-
(attachmentRef, imageIndex) => describe(attachmentRef, imageIndex, imageTotal),
|
|
371
|
-
counter
|
|
482
|
+
(attachmentRef, imageIndex, messageId) => describe(attachmentRef, imageIndex, imageTotal, messageId),
|
|
483
|
+
counter,
|
|
484
|
+
message.id
|
|
372
485
|
)
|
|
373
486
|
});
|
|
374
487
|
}
|
|
@@ -393,31 +506,54 @@ class VisionFallbackController {
|
|
|
393
506
|
this.ctx = ctx;
|
|
394
507
|
this.current = current;
|
|
395
508
|
this.cache = new Map();
|
|
509
|
+
this.observations = loadObservations(current());
|
|
396
510
|
}
|
|
397
511
|
|
|
398
512
|
modelInfo(info) {
|
|
399
513
|
return withImageCapability(info, this.current());
|
|
400
514
|
}
|
|
401
515
|
|
|
402
|
-
|
|
516
|
+
hydrateObservations() {
|
|
517
|
+
for (const [key, text] of loadObservations(this.current())) {
|
|
518
|
+
if (!this.observations.has(key)) this.observations.set(key, text);
|
|
519
|
+
}
|
|
520
|
+
return this.observations;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
recallObservation(attachmentRef, scope) {
|
|
524
|
+
return this.hydrateObservations().get(observationKey(attachmentRef, this.current(), scope));
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
rememberObservation(attachmentRef, text, scope) {
|
|
528
|
+
const cfg = this.current();
|
|
529
|
+
rememberObservation(this.observations, observationKey(attachmentRef, cfg, scope), text);
|
|
530
|
+
persistObservations(cfg, this.observations);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
async preprocess(messages, contextMessages, signal, sessionId) {
|
|
403
534
|
const cfg = this.current();
|
|
404
535
|
assertVisionConfig(cfg);
|
|
405
536
|
if (!cfg.enabled || !messages.some((message) => contentHasImage(message.content ?? []))) return messages;
|
|
406
537
|
|
|
407
538
|
try {
|
|
408
539
|
const apiKey = await resolveApiKey(this.ctx.get("credentials"), cfg.apiKeyRef);
|
|
409
|
-
return await rewriteMessages(messages, async (attachmentRef, imageIndex, imageTotal) => {
|
|
540
|
+
return await rewriteMessages(messages, async (attachmentRef, imageIndex, imageTotal, messageId) => {
|
|
410
541
|
const prompt = buildVisionPrompt(contextMessages, attachmentRef, imageIndex, imageTotal, cfg);
|
|
411
542
|
const attachmentId = attachmentRef?.attachmentId ?? JSON.stringify(attachmentRef);
|
|
412
|
-
const
|
|
543
|
+
const scope = { sessionId, messageId };
|
|
544
|
+
const remembered = this.recallObservation(attachmentRef, scope);
|
|
545
|
+
if (remembered !== undefined) return remembered;
|
|
546
|
+
const cacheKey = createHash("sha256").update(`${attachmentId}\0${scope.sessionId ?? ""}\0${scope.messageId ?? ""}\0${prompt}\0${cfg.baseURL}\0${cfg.model}`).digest("hex");
|
|
413
547
|
let pending = this.cache.get(cacheKey);
|
|
414
548
|
if (pending === undefined) {
|
|
415
|
-
pending = describeImage(this.ctx, apiKey, cfg, attachmentRef, prompt, signal);
|
|
549
|
+
pending = describeImage(this.ctx, apiKey, cfg, attachmentRef, prompt, signal, { imageIndex, imageTotal });
|
|
416
550
|
this.cache.set(cacheKey, pending);
|
|
417
551
|
if (this.cache.size > 64) this.cache.delete(this.cache.keys().next().value);
|
|
418
552
|
}
|
|
419
553
|
try {
|
|
420
|
-
|
|
554
|
+
const text = await pending;
|
|
555
|
+
this.rememberObservation(attachmentRef, text, scope);
|
|
556
|
+
return text;
|
|
421
557
|
} catch (error) {
|
|
422
558
|
this.cache.delete(cacheKey);
|
|
423
559
|
return `【图片转换失败:${errorText(error)}】`;
|
|
@@ -468,6 +604,59 @@ class ReplacementCoordinator {
|
|
|
468
604
|
}
|
|
469
605
|
}
|
|
470
606
|
|
|
607
|
+
replacementFor(session, messageId) {
|
|
608
|
+
if (session === undefined || messageId === undefined) return undefined;
|
|
609
|
+
return this.pending.get(session)?.get(messageId);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
flush(session) {
|
|
613
|
+
const replacements = this.pending.get(session);
|
|
614
|
+
if (replacements === undefined || replacements.size === 0) return;
|
|
615
|
+
const events = session.events;
|
|
616
|
+
const nodes = session.surface?.nodes;
|
|
617
|
+
if (!Array.isArray(events) && (events === undefined || typeof events !== "object")) return;
|
|
618
|
+
if (!Array.isArray(nodes)) return;
|
|
619
|
+
for (const seq of [...nodes]) {
|
|
620
|
+
const event = events[seq];
|
|
621
|
+
if (event === undefined) continue;
|
|
622
|
+
const message = typeof session.deriveEventMessage === "function"
|
|
623
|
+
? session.deriveEventMessage(event)
|
|
624
|
+
: event.type === "user/message"
|
|
625
|
+
? event.data
|
|
626
|
+
: event.type === "tool/result"
|
|
627
|
+
? event.data?.message
|
|
628
|
+
: undefined;
|
|
629
|
+
if (message === undefined || message === null) continue;
|
|
630
|
+
const replacement = replacements.get(message.id);
|
|
631
|
+
if (replacement === undefined || !contentHasImage(message.content ?? [])) continue;
|
|
632
|
+
try {
|
|
633
|
+
if (event.type === "user/message") {
|
|
634
|
+
session.append("user/message", replacement, {
|
|
635
|
+
surfaceOp: { op: "replace", start: seq, end: seq },
|
|
636
|
+
sourceEventSeqs: [seq]
|
|
637
|
+
});
|
|
638
|
+
} else if (event.type === "tool/result") {
|
|
639
|
+
session.append("tool/result", {
|
|
640
|
+
...event.data,
|
|
641
|
+
message: {
|
|
642
|
+
...event.data.message,
|
|
643
|
+
content: replacement.content
|
|
644
|
+
}
|
|
645
|
+
}, {
|
|
646
|
+
surfaceOp: { op: "replace", start: seq, end: seq },
|
|
647
|
+
sourceEventSeqs: [seq]
|
|
648
|
+
});
|
|
649
|
+
} else {
|
|
650
|
+
continue;
|
|
651
|
+
}
|
|
652
|
+
replacements.delete(message.id);
|
|
653
|
+
} catch (error) {
|
|
654
|
+
this.logger.error("vision-fallback: 固化视觉观察失败");
|
|
655
|
+
this.logger.error(error);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
471
660
|
onSessionEvent(session, event) {
|
|
472
661
|
if (event.type !== "user/message" || event.surfaceOp !== "append") return;
|
|
473
662
|
const replacements = this.pending.get(session);
|
|
@@ -527,6 +716,41 @@ async function modelActuallySupportsImage(provider, model, signal) {
|
|
|
527
716
|
}
|
|
528
717
|
}
|
|
529
718
|
|
|
719
|
+
function resolveSession(ctx, sessionId) {
|
|
720
|
+
if (sessionId === undefined) return undefined;
|
|
721
|
+
return (ctx.sessions ?? ctx.get?.("sessions"))?.get(sessionId);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function reuseExistingVisionMessages(messages, session, coordinator) {
|
|
725
|
+
const liveById = new Map();
|
|
726
|
+
if (session !== undefined && typeof session.deriveMessages === "function") {
|
|
727
|
+
for (const message of session.deriveMessages()) {
|
|
728
|
+
if (typeof message?.id === "string") liveById.set(message.id, message);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
return messages.map((message) => {
|
|
732
|
+
const live = typeof message?.id === "string" ? liveById.get(message.id) : undefined;
|
|
733
|
+
if (live !== undefined && contentHasImage(message.content ?? []) && !contentHasImage(live.content ?? [])) {
|
|
734
|
+
return live;
|
|
735
|
+
}
|
|
736
|
+
return coordinator.replacementFor(session, message.id) ?? message;
|
|
737
|
+
});
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function compactionImagePlaceholder(attachmentRef) {
|
|
741
|
+
const name = attachmentRef?.name ? attachmentRef.name : "未命名图片";
|
|
742
|
+
return `【图片:${name}。主模型已直接查看原图,压缩时不再重新识别,请依据后续对话中的结论。】`;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function replaceLeftoverCompactionImages(messages, controller, sessionId) {
|
|
746
|
+
const cfg = controller.current();
|
|
747
|
+
controller.hydrateObservations();
|
|
748
|
+
return rewriteMessages(messages, async (attachmentRef, imageIndex, imageTotal, messageId) => (
|
|
749
|
+
controller.observations.get(observationKey(attachmentRef, cfg, { sessionId, messageId }))
|
|
750
|
+
?? compactionImagePlaceholder(attachmentRef)
|
|
751
|
+
));
|
|
752
|
+
}
|
|
753
|
+
|
|
530
754
|
function apply(ctx, config) {
|
|
531
755
|
let current = () => config;
|
|
532
756
|
let settingsService;
|
|
@@ -552,11 +776,41 @@ function apply(ctx, config) {
|
|
|
552
776
|
if (await modelActuallySupportsImage(provider, model, signal)) return decision;
|
|
553
777
|
}
|
|
554
778
|
const modelMessages = [...agent.session.deriveMessages(), ...decision.messages];
|
|
555
|
-
const rewritten = await controller.preprocess(modelMessages, modelMessages, signal);
|
|
779
|
+
const rewritten = await controller.preprocess(modelMessages, modelMessages, signal, agent.session.id);
|
|
556
780
|
replacements.stage(agent.session, modelMessages, rewritten);
|
|
781
|
+
replacements.flush(agent.session);
|
|
557
782
|
return decision;
|
|
558
783
|
});
|
|
559
784
|
|
|
785
|
+
ctx.on("llm/stream", (options, next) => (async function* () {
|
|
786
|
+
if (options.purpose !== "compaction") {
|
|
787
|
+
yield* next();
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
const cfg = current();
|
|
791
|
+
if (!cfg.enabled || cfg.mode === "never") {
|
|
792
|
+
yield* next();
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
if (!Array.isArray(options.messages) || !options.messages.some((message) => contentHasImage(message.content ?? []))) {
|
|
796
|
+
yield* next();
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
if (cfg.mode === "auto" && await modelActuallySupportsImage(options.provider, options.model, options.signal)) {
|
|
800
|
+
yield* next();
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
const projected = reuseExistingVisionMessages(
|
|
804
|
+
options.messages,
|
|
805
|
+
resolveSession(ctx, options.sessionId),
|
|
806
|
+
replacements
|
|
807
|
+
);
|
|
808
|
+
options.messages = projected.some((message) => contentHasImage(message.content ?? []))
|
|
809
|
+
? await replaceLeftoverCompactionImages(projected, controller, options.sessionId)
|
|
810
|
+
: projected;
|
|
811
|
+
yield* next();
|
|
812
|
+
})(), { global: true, prepend: true });
|
|
813
|
+
|
|
560
814
|
ctx.inject(["settings"], (sctx) => {
|
|
561
815
|
settingsService = sctx.settings;
|
|
562
816
|
sctx.effect(() => () => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-vision-fallback",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.1",
|
|
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",
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
3
5
|
import test from "node:test";
|
|
4
6
|
import {
|
|
5
7
|
CONFIG_ROUTE,
|
|
@@ -49,6 +51,12 @@ function assistantMessage(id, text) {
|
|
|
49
51
|
};
|
|
50
52
|
}
|
|
51
53
|
|
|
54
|
+
async function consumeStream(stream) {
|
|
55
|
+
for await (const _chunk of stream) {}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function* emptyStream() {}
|
|
59
|
+
|
|
52
60
|
function createServiceContext() {
|
|
53
61
|
return {
|
|
54
62
|
get(service) {
|
|
@@ -325,6 +333,7 @@ test("插件注册静默预处理钩子而不注册新模型 adapter", () => {
|
|
|
325
333
|
apply(ctx, config);
|
|
326
334
|
assert.equal(typeof hooks.get("agent/pre-step"), "function");
|
|
327
335
|
assert.equal(typeof hooks.get("session/event"), "function");
|
|
336
|
+
assert.equal(typeof hooks.get("llm/stream"), "function");
|
|
328
337
|
assert.equal("registerAdapter" in ctx.llm, false);
|
|
329
338
|
assert.equal("registerConfigurableProviders" in ctx.llm, false);
|
|
330
339
|
assert.equal(typeof cleanup, "function");
|
|
@@ -577,3 +586,386 @@ test("auto 模式按主模型真实视觉能力决定是否接管", async () =>
|
|
|
577
586
|
|
|
578
587
|
globalThis.fetch = previousFetch;
|
|
579
588
|
});
|
|
589
|
+
|
|
590
|
+
test("同一图片在同一消息位置重复处理时复用观察", async () => {
|
|
591
|
+
const originalFetch = globalThis.fetch;
|
|
592
|
+
let visionRequests = 0;
|
|
593
|
+
globalThis.fetch = async () => {
|
|
594
|
+
visionRequests += 1;
|
|
595
|
+
return {
|
|
596
|
+
ok: true,
|
|
597
|
+
json: async () => ({ choices: [{ message: { content: "按钮文案是保存" } }] })
|
|
598
|
+
};
|
|
599
|
+
};
|
|
600
|
+
const controller = new VisionFallbackController(createServiceContext(), () => config);
|
|
601
|
+
const first = userMessage("u1", [{ type: "text", text: "这个按钮是什么?" }, { type: "image", attachment }]);
|
|
602
|
+
|
|
603
|
+
try {
|
|
604
|
+
const [firstRewritten] = await controller.preprocess([first], [first], undefined, "session-A");
|
|
605
|
+
const [againRewritten] = await controller.preprocess([first], [first], undefined, "session-A");
|
|
606
|
+
assert.equal(visionRequests, 1);
|
|
607
|
+
assert.match(firstRewritten.content[1].text, /按钮文案是保存/);
|
|
608
|
+
assert.equal(againRewritten.content[1].text, firstRewritten.content[1].text);
|
|
609
|
+
} finally {
|
|
610
|
+
globalThis.fetch = originalFetch;
|
|
611
|
+
}
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
test("同一图片在会话不同位置再次出现时重新识别", async () => {
|
|
615
|
+
const originalFetch = globalThis.fetch;
|
|
616
|
+
let visionRequests = 0;
|
|
617
|
+
globalThis.fetch = async () => {
|
|
618
|
+
visionRequests += 1;
|
|
619
|
+
return {
|
|
620
|
+
ok: true,
|
|
621
|
+
json: async () => ({ choices: [{ message: { content: "按钮文案是保存" } }] })
|
|
622
|
+
};
|
|
623
|
+
};
|
|
624
|
+
const controller = new VisionFallbackController(createServiceContext(), () => config);
|
|
625
|
+
const first = userMessage("u1", [{ type: "text", text: "这个按钮是什么?" }, { type: "image", attachment }]);
|
|
626
|
+
const second = userMessage("u2", [{ type: "text", text: "请压缩更早的对话" }, { type: "image", attachment }]);
|
|
627
|
+
|
|
628
|
+
try {
|
|
629
|
+
const [firstRewritten] = await controller.preprocess([first], [first], undefined, "session-A");
|
|
630
|
+
const [secondRewritten] = await controller.preprocess([second], [second], undefined, "session-A");
|
|
631
|
+
assert.equal(visionRequests, 2, "同一会话的新消息位置应重新识别");
|
|
632
|
+
assert.match(secondRewritten.content[1].text, /按钮文案是保存/);
|
|
633
|
+
assert.equal(secondRewritten.content[1].text, firstRewritten.content[1].text);
|
|
634
|
+
} finally {
|
|
635
|
+
globalThis.fetch = originalFetch;
|
|
636
|
+
}
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
test("同一图片在不同会话的同一位置也重新识别", async () => {
|
|
640
|
+
const originalFetch = globalThis.fetch;
|
|
641
|
+
let visionRequests = 0;
|
|
642
|
+
globalThis.fetch = async () => {
|
|
643
|
+
visionRequests += 1;
|
|
644
|
+
return {
|
|
645
|
+
ok: true,
|
|
646
|
+
json: async () => ({ choices: [{ message: { content: "按钮文案是保存" } }] })
|
|
647
|
+
};
|
|
648
|
+
};
|
|
649
|
+
const controller = new VisionFallbackController(createServiceContext(), () => config);
|
|
650
|
+
const first = userMessage("u1", [{ type: "text", text: "这个按钮是什么?" }, { type: "image", attachment }]);
|
|
651
|
+
|
|
652
|
+
try {
|
|
653
|
+
await controller.preprocess([first], [first], undefined, "session-A");
|
|
654
|
+
await controller.preprocess([first], [first], undefined, "session-B");
|
|
655
|
+
assert.equal(visionRequests, 2, "不同会话应重新识别");
|
|
656
|
+
} finally {
|
|
657
|
+
globalThis.fetch = originalFetch;
|
|
658
|
+
}
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
test("重启后同一会话位置的图片复用持久化观察", async () => {
|
|
662
|
+
const originalFetch = globalThis.fetch;
|
|
663
|
+
let visionRequests = 0;
|
|
664
|
+
globalThis.fetch = async () => {
|
|
665
|
+
visionRequests += 1;
|
|
666
|
+
return {
|
|
667
|
+
ok: true,
|
|
668
|
+
json: async () => ({ choices: [{ message: { content: "按钮文案是保存" } }] })
|
|
669
|
+
};
|
|
670
|
+
};
|
|
671
|
+
const dir = await mkdtemp(join(tmpdir(), "vision-obs-"));
|
|
672
|
+
const logPath = join(dir, "usage.jsonl");
|
|
673
|
+
const makeController = () => new VisionFallbackController(
|
|
674
|
+
createServiceContext(),
|
|
675
|
+
() => ({ ...config, recordUsage: true, usageLogPath: logPath })
|
|
676
|
+
);
|
|
677
|
+
const first = userMessage("u1", [{ type: "text", text: "这个按钮是什么?" }, { type: "image", attachment }]);
|
|
678
|
+
|
|
679
|
+
try {
|
|
680
|
+
const controllerA = makeController();
|
|
681
|
+
const [rewrittenA] = await controllerA.preprocess([first], [first], undefined, "session-A");
|
|
682
|
+
assert.equal(visionRequests, 1);
|
|
683
|
+
// 模拟重启:新 controller 实例重新从磁盘加载观察缓存
|
|
684
|
+
const controllerB = makeController();
|
|
685
|
+
const [rewrittenB] = await controllerB.preprocess([first], [first], undefined, "session-A");
|
|
686
|
+
assert.equal(visionRequests, 1, "重启后同一位置应复用持久化观察");
|
|
687
|
+
assert.equal(rewrittenB.content[1].text, rewrittenA.content[1].text);
|
|
688
|
+
} finally {
|
|
689
|
+
globalThis.fetch = originalFetch;
|
|
690
|
+
await rm(dir, { recursive: true, force: true });
|
|
691
|
+
}
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
test("compaction 请求不再重新识图,普通请求保持原样", async () => {
|
|
695
|
+
const imageBlock = { type: "image", attachment: { attachmentId: "sha256:compact", mediaType: "image/png", name: "compact.png" } };
|
|
696
|
+
let visionRequests = 0;
|
|
697
|
+
const previousFetch = globalThis.fetch;
|
|
698
|
+
globalThis.fetch = async () => {
|
|
699
|
+
visionRequests += 1;
|
|
700
|
+
return {
|
|
701
|
+
ok: true,
|
|
702
|
+
status: 200,
|
|
703
|
+
json: async () => ({ choices: [{ message: { content: "压缩图中的关键错误是端口写错" } }] })
|
|
704
|
+
};
|
|
705
|
+
};
|
|
706
|
+
|
|
707
|
+
const makeSignal = () => ({ aborted: false, addEventListener() {}, removeEventListener() {} });
|
|
708
|
+
let streamHandler;
|
|
709
|
+
const ctx = {
|
|
710
|
+
llm: {
|
|
711
|
+
resolveModelInfo: async () => ({ provider: "opencode-go", id: "deepseek-v4-flash", name: "deepseek-v4-flash", inputModalities: ["text"] })
|
|
712
|
+
},
|
|
713
|
+
logger: { error() {} },
|
|
714
|
+
effect() {},
|
|
715
|
+
on(name, handler) {
|
|
716
|
+
if (name === "llm/stream") streamHandler = handler;
|
|
717
|
+
},
|
|
718
|
+
inject(deps, cb) {
|
|
719
|
+
if (deps[0] === "settings") {
|
|
720
|
+
cb({
|
|
721
|
+
settings: {
|
|
722
|
+
register: (_ns, _schema, options) => ({ get: () => options.base, watch() {} })
|
|
723
|
+
},
|
|
724
|
+
effect() {}
|
|
725
|
+
});
|
|
726
|
+
} else {
|
|
727
|
+
cb({ webServer: { register: () => () => {} }, effect() {} });
|
|
728
|
+
}
|
|
729
|
+
},
|
|
730
|
+
get(name) {
|
|
731
|
+
if (name === "credentials") return { resolve: async () => ({ value: "sk-test" }) };
|
|
732
|
+
if (name === "attachments") {
|
|
733
|
+
return {
|
|
734
|
+
readImage: async () => ({ data: Buffer.from("compact"), ref: { mediaType: "image/png" } })
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
return undefined;
|
|
738
|
+
}
|
|
739
|
+
};
|
|
740
|
+
|
|
741
|
+
try {
|
|
742
|
+
apply(ctx, { ...config, mode: "auto" });
|
|
743
|
+
assert.equal(typeof streamHandler, "function");
|
|
744
|
+
|
|
745
|
+
const compactionOptions = {
|
|
746
|
+
purpose: "compaction",
|
|
747
|
+
provider: "opencode-go",
|
|
748
|
+
model: "deepseek-v4-flash",
|
|
749
|
+
signal: makeSignal(),
|
|
750
|
+
messages: [userMessage("compact-1", [{ type: "text", text: "请压缩上下文" }, imageBlock])]
|
|
751
|
+
};
|
|
752
|
+
const compactionStream = streamHandler(compactionOptions, emptyStream);
|
|
753
|
+
assert.equal(typeof compactionStream[Symbol.asyncIterator], "function");
|
|
754
|
+
await consumeStream(compactionStream);
|
|
755
|
+
assert.equal(visionRequests, 0);
|
|
756
|
+
assert.equal(JSON.stringify(compactionOptions.messages).includes('"type":"image"'), false);
|
|
757
|
+
assert.match(compactionOptions.messages[0].content[1].text, /主模型已直接查看原图/);
|
|
758
|
+
|
|
759
|
+
const normalOptions = {
|
|
760
|
+
purpose: undefined,
|
|
761
|
+
provider: "opencode-go",
|
|
762
|
+
model: "deepseek-v4-flash",
|
|
763
|
+
signal: makeSignal(),
|
|
764
|
+
messages: [userMessage("normal-1", [{ type: "text", text: "正常请求" }, imageBlock])]
|
|
765
|
+
};
|
|
766
|
+
const before = JSON.stringify(normalOptions.messages);
|
|
767
|
+
await consumeStream(streamHandler(normalOptions, emptyStream));
|
|
768
|
+
assert.equal(JSON.stringify(normalOptions.messages), before);
|
|
769
|
+
assert.equal(visionRequests, 0);
|
|
770
|
+
} finally {
|
|
771
|
+
globalThis.fetch = previousFetch;
|
|
772
|
+
}
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
test("压缩优先复用主模型已经拿到的视觉观察", async () => {
|
|
776
|
+
const imageBlock = { type: "image", attachment: { attachmentId: "sha256:reuse", mediaType: "image/png", name: "reuse.png" } };
|
|
777
|
+
const original = userMessage("reuse-1", [{ type: "text", text: "看这张图" }, imageBlock]);
|
|
778
|
+
const rewritten = userMessage("reuse-1", [{ type: "text", text: "看这张图" }, { type: "text", text: "【视觉观察:vision-model · reuse.png】\n端口写的是 8080" }]);
|
|
779
|
+
let visionRequests = 0;
|
|
780
|
+
const previousFetch = globalThis.fetch;
|
|
781
|
+
globalThis.fetch = async () => {
|
|
782
|
+
visionRequests += 1;
|
|
783
|
+
return { ok: true, status: 200, json: async () => ({ choices: [{ message: { content: "不应重新识图" } }] }) };
|
|
784
|
+
};
|
|
785
|
+
const makeSignal = () => ({ aborted: false, addEventListener() {}, removeEventListener() {} });
|
|
786
|
+
let streamHandler;
|
|
787
|
+
const session = {
|
|
788
|
+
deriveMessages: () => [rewritten]
|
|
789
|
+
};
|
|
790
|
+
const ctx = {
|
|
791
|
+
sessions: {
|
|
792
|
+
get(sessionId) {
|
|
793
|
+
assert.equal(sessionId, "session-reuse");
|
|
794
|
+
return session;
|
|
795
|
+
}
|
|
796
|
+
},
|
|
797
|
+
llm: {
|
|
798
|
+
resolveModelInfo: async () => ({ provider: "opencode-go", id: "deepseek-v4-flash", name: "deepseek-v4-flash", inputModalities: ["text"] })
|
|
799
|
+
},
|
|
800
|
+
logger: { error() {} },
|
|
801
|
+
effect() {},
|
|
802
|
+
on(name, handler) {
|
|
803
|
+
if (name === "llm/stream") streamHandler = handler;
|
|
804
|
+
},
|
|
805
|
+
inject(deps, cb) {
|
|
806
|
+
if (deps[0] === "settings") {
|
|
807
|
+
cb({ settings: { register: (_ns, _schema, options) => ({ get: () => options.base, watch() {} }) }, effect() {} });
|
|
808
|
+
} else {
|
|
809
|
+
cb({ webServer: { register: () => () => {} }, effect() {} });
|
|
810
|
+
}
|
|
811
|
+
},
|
|
812
|
+
get() {
|
|
813
|
+
return undefined;
|
|
814
|
+
}
|
|
815
|
+
};
|
|
816
|
+
|
|
817
|
+
try {
|
|
818
|
+
apply(ctx, { ...config, mode: "auto" });
|
|
819
|
+
const options = {
|
|
820
|
+
purpose: "compaction",
|
|
821
|
+
sessionId: "session-reuse",
|
|
822
|
+
provider: "opencode-go",
|
|
823
|
+
model: "deepseek-v4-flash",
|
|
824
|
+
signal: makeSignal(),
|
|
825
|
+
messages: [original]
|
|
826
|
+
};
|
|
827
|
+
await consumeStream(streamHandler(options, emptyStream));
|
|
828
|
+
assert.equal(visionRequests, 0);
|
|
829
|
+
assert.equal(options.messages[0], rewritten);
|
|
830
|
+
assert.equal(JSON.stringify(options.messages).includes('"type":"image"'), false);
|
|
831
|
+
} finally {
|
|
832
|
+
globalThis.fetch = previousFetch;
|
|
833
|
+
}
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
test("compaction 请求在 mode=never 时不拦截图片", async () => {
|
|
837
|
+
const imageBlock = { type: "image", attachment: { attachmentId: "sha256:compact", mediaType: "image/png", name: "compact.png" } };
|
|
838
|
+
let visionRequests = 0;
|
|
839
|
+
const previousFetch = globalThis.fetch;
|
|
840
|
+
globalThis.fetch = async () => {
|
|
841
|
+
visionRequests += 1;
|
|
842
|
+
return { ok: true, status: 200, json: async () => ({ choices: [{ message: { content: "不应调用" } }] }) };
|
|
843
|
+
};
|
|
844
|
+
const makeSignal = () => ({ aborted: false, addEventListener() {}, removeEventListener() {} });
|
|
845
|
+
let streamHandler;
|
|
846
|
+
const ctx = {
|
|
847
|
+
llm: {
|
|
848
|
+
resolveModelInfo: async () => ({ provider: "opencode-go", id: "deepseek-v4-flash", name: "deepseek-v4-flash", inputModalities: ["text"] })
|
|
849
|
+
},
|
|
850
|
+
logger: { error() {} },
|
|
851
|
+
effect() {},
|
|
852
|
+
on(name, handler) {
|
|
853
|
+
if (name === "llm/stream") streamHandler = handler;
|
|
854
|
+
},
|
|
855
|
+
inject(deps, cb) {
|
|
856
|
+
if (deps[0] === "settings") {
|
|
857
|
+
cb({ settings: { register: (_ns, _schema, options) => ({ get: () => options.base, watch() {} }) }, effect() {} });
|
|
858
|
+
} else {
|
|
859
|
+
cb({ webServer: { register: () => () => {} }, effect() {} });
|
|
860
|
+
}
|
|
861
|
+
},
|
|
862
|
+
get() {
|
|
863
|
+
return undefined;
|
|
864
|
+
}
|
|
865
|
+
};
|
|
866
|
+
|
|
867
|
+
try {
|
|
868
|
+
apply(ctx, { ...config, mode: "never" });
|
|
869
|
+
const options = {
|
|
870
|
+
purpose: "compaction",
|
|
871
|
+
provider: "opencode-go",
|
|
872
|
+
model: "deepseek-v4-flash",
|
|
873
|
+
signal: makeSignal(),
|
|
874
|
+
messages: [userMessage("compact-never", [{ type: "text", text: "请压缩上下文" }, imageBlock])]
|
|
875
|
+
};
|
|
876
|
+
const before = JSON.stringify(options.messages);
|
|
877
|
+
await consumeStream(streamHandler(options, emptyStream));
|
|
878
|
+
assert.equal(JSON.stringify(options.messages), before);
|
|
879
|
+
assert.equal(visionRequests, 0);
|
|
880
|
+
} finally {
|
|
881
|
+
globalThis.fetch = previousFetch;
|
|
882
|
+
}
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
test("usage 记录包含响应耗时与图片信息", async () => {
|
|
886
|
+
const previousFetch = globalThis.fetch;
|
|
887
|
+
globalThis.fetch = async () => ({
|
|
888
|
+
ok: true,
|
|
889
|
+
status: 200,
|
|
890
|
+
json: async () => ({
|
|
891
|
+
choices: [{ message: { content: "按钮文案是保存" } }],
|
|
892
|
+
usage: { prompt_tokens: 100, completion_tokens: 50, prompt_tokens_details: { cached_tokens: 10 } }
|
|
893
|
+
})
|
|
894
|
+
});
|
|
895
|
+
const dir = await mkdtemp(join(tmpdir(), "vision-usage-"));
|
|
896
|
+
const logPath = join(dir, "usage.jsonl");
|
|
897
|
+
const controller = new VisionFallbackController(
|
|
898
|
+
createServiceContext(),
|
|
899
|
+
() => ({ ...config, recordUsage: true, usageLogPath: logPath })
|
|
900
|
+
);
|
|
901
|
+
const messages = [userMessage("u1", [{ type: "text", text: "这个按钮是什么?" }, { type: "image", attachment }])];
|
|
902
|
+
try {
|
|
903
|
+
await controller.preprocess(messages, messages, undefined);
|
|
904
|
+
const lines = (await readFile(logPath, "utf8")).trim().split("\n");
|
|
905
|
+
assert.equal(lines.length, 1);
|
|
906
|
+
const entry = JSON.parse(lines[0]);
|
|
907
|
+
assert.equal(entry.status, "ok");
|
|
908
|
+
assert.equal(entry.kind, "vision");
|
|
909
|
+
assert.equal(entry.model, "vision-model");
|
|
910
|
+
assert.equal(entry.inputTokens, 100);
|
|
911
|
+
assert.equal(entry.outputTokens, 50);
|
|
912
|
+
assert.equal(entry.cacheReadTokens, 10);
|
|
913
|
+
assert.equal(entry.imageName, "error.png");
|
|
914
|
+
assert.equal(entry.mediaType, "image/png");
|
|
915
|
+
assert.equal(entry.imageBytes, 3);
|
|
916
|
+
assert.equal(entry.imageIndex, 1);
|
|
917
|
+
assert.equal(entry.imageTotal, 1);
|
|
918
|
+
assert.equal(typeof entry.ts, "number");
|
|
919
|
+
assert.equal(typeof entry.durationMs, "number");
|
|
920
|
+
assert.ok(entry.durationMs >= 0, "durationMs 不应为负");
|
|
921
|
+
} finally {
|
|
922
|
+
globalThis.fetch = previousFetch;
|
|
923
|
+
await rm(dir, { recursive: true, force: true });
|
|
924
|
+
}
|
|
925
|
+
});
|
|
926
|
+
|
|
927
|
+
test("usage 记录失败调用并带错误信息", async () => {
|
|
928
|
+
const previousFetch = globalThis.fetch;
|
|
929
|
+
globalThis.fetch = async () => ({
|
|
930
|
+
ok: false,
|
|
931
|
+
status: 500,
|
|
932
|
+
statusText: "Internal Server Error",
|
|
933
|
+
text: async () => "boom"
|
|
934
|
+
});
|
|
935
|
+
const dir = await mkdtemp(join(tmpdir(), "vision-usage-err-"));
|
|
936
|
+
const logPath = join(dir, "usage.jsonl");
|
|
937
|
+
const controller = new VisionFallbackController(
|
|
938
|
+
createServiceContext(),
|
|
939
|
+
() => ({ ...config, recordUsage: true, usageLogPath: logPath })
|
|
940
|
+
);
|
|
941
|
+
const messages = [userMessage("u1", [{ type: "text", text: "看这张图" }, { type: "image", attachment }])];
|
|
942
|
+
try {
|
|
943
|
+
const [rewritten] = await controller.preprocess(messages, messages, undefined);
|
|
944
|
+
assert.match(rewritten.content[1].text, /图片转换失败/);
|
|
945
|
+
const lines = (await readFile(logPath, "utf8")).trim().split("\n");
|
|
946
|
+
assert.equal(lines.length, 1);
|
|
947
|
+
const entry = JSON.parse(lines[0]);
|
|
948
|
+
assert.equal(entry.status, "error");
|
|
949
|
+
assert.equal(entry.kind, "vision");
|
|
950
|
+
assert.equal(entry.inputTokens, 0);
|
|
951
|
+
assert.equal(entry.outputTokens, 0);
|
|
952
|
+
assert.match(entry.error, /500/);
|
|
953
|
+
assert.equal(entry.imageName, "error.png");
|
|
954
|
+
assert.equal(typeof entry.durationMs, "number");
|
|
955
|
+
assert.ok(entry.durationMs >= 0, "失败记录的 durationMs 不应为负");
|
|
956
|
+
} finally {
|
|
957
|
+
globalThis.fetch = previousFetch;
|
|
958
|
+
await rm(dir, { recursive: true, force: true });
|
|
959
|
+
}
|
|
960
|
+
});
|
|
961
|
+
|
|
962
|
+
test("npm 包声明可安装的 DSH bundle 和市场关键词", async () => {
|
|
963
|
+
const manifest = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8"));
|
|
964
|
+
const patch = await readFile(new URL("../cordis.patch.yml", import.meta.url), "utf8");
|
|
965
|
+
|
|
966
|
+
assert.equal(manifest.dsh?.bundle?.patch, "./cordis.patch.yml");
|
|
967
|
+
assert.ok(manifest.files.includes("cordis.patch.yml"));
|
|
968
|
+
assert.ok(manifest.keywords.includes("dsh-plugin"));
|
|
969
|
+
assert.match(patch, /id:\s*vision-fallback/);
|
|
970
|
+
assert.match(patch, /name:\s*['"]dsh-vision-fallback['"]/);
|
|
971
|
+
});
|