dsh-vision-fallback 0.5.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/LICENSE +21 -0
- package/README.md +138 -0
- package/README.zh.md +138 -0
- package/cordis.patch.yml +5 -0
- package/lib/client.js +329 -0
- package/lib/index.js +551 -0
- package/package.json +65 -0
- package/test/vision-fallback.test.mjs +443 -0
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
import {
|
|
5
|
+
CONFIG_ROUTE,
|
|
6
|
+
ReplacementCoordinator,
|
|
7
|
+
VisionFallbackController,
|
|
8
|
+
apply,
|
|
9
|
+
buildVisionPrompt,
|
|
10
|
+
installCapabilityOverride,
|
|
11
|
+
rewriteMessages,
|
|
12
|
+
withImageCapability
|
|
13
|
+
} from "../lib/index.js";
|
|
14
|
+
|
|
15
|
+
const config = {
|
|
16
|
+
enabled: true,
|
|
17
|
+
model: "vision-model",
|
|
18
|
+
baseURL: "https://vision.example/v1",
|
|
19
|
+
apiKeyRef: "VISION_KEY",
|
|
20
|
+
maxTokens: 1024,
|
|
21
|
+
timeoutMs: 1000,
|
|
22
|
+
maxBytes: 1024 * 1024,
|
|
23
|
+
includeRecentContext: true,
|
|
24
|
+
contextMessages: 6,
|
|
25
|
+
contextMaxChars: 6000,
|
|
26
|
+
prompt: "分析图片并关注用户问题。",
|
|
27
|
+
tagResult: true
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const attachment = {
|
|
31
|
+
attachmentId: "sha256:test",
|
|
32
|
+
mediaType: "image/png",
|
|
33
|
+
bytes: 3,
|
|
34
|
+
width: 1,
|
|
35
|
+
height: 1,
|
|
36
|
+
name: "error.png"
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function userMessage(id, content) {
|
|
40
|
+
return { id, role: "user", source: { kind: "user" }, content };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function assistantMessage(id, text) {
|
|
44
|
+
return {
|
|
45
|
+
id,
|
|
46
|
+
role: "assistant",
|
|
47
|
+
source: { kind: "model", provider: "deepseek-official", model: "deepseek-v4-flash" },
|
|
48
|
+
content: [{ type: "text", text }]
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function createServiceContext() {
|
|
53
|
+
return {
|
|
54
|
+
get(service) {
|
|
55
|
+
if (service === "credentials") {
|
|
56
|
+
return { resolve: async () => ({ value: "test-key", source: "test" }) };
|
|
57
|
+
}
|
|
58
|
+
if (service === "attachments") {
|
|
59
|
+
return { readImage: async (ref) => ({ ref, data: new Uint8Array([1, 2, 3]) }) };
|
|
60
|
+
}
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
test("视觉提示词包含当前问题和最近上下文", () => {
|
|
67
|
+
const messages = [
|
|
68
|
+
userMessage("u1", [{ type: "text", text: "这是服务器配置页面。" }]),
|
|
69
|
+
assistantMessage("a1", "请发送报错截图。"),
|
|
70
|
+
userMessage("u2", [{ type: "text", text: "哪里配置错了?" }, { type: "image", attachment }])
|
|
71
|
+
];
|
|
72
|
+
const prompt = buildVisionPrompt(messages, attachment, 1, 1, config);
|
|
73
|
+
assert.match(prompt, /哪里配置错了/);
|
|
74
|
+
assert.match(prompt, /请发送报错截图/);
|
|
75
|
+
assert.match(prompt, /error\.png/);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("重写图片时保留消息身份和原文字", async () => {
|
|
79
|
+
const original = userMessage("u1", [
|
|
80
|
+
{ type: "text", text: "请分析错误" },
|
|
81
|
+
{ type: "image", attachment }
|
|
82
|
+
]);
|
|
83
|
+
const [rewritten] = await rewriteMessages([original], async () => "视觉观察结果");
|
|
84
|
+
assert.equal(rewritten.id, original.id);
|
|
85
|
+
assert.equal(rewritten.source, original.source);
|
|
86
|
+
assert.equal(rewritten.content[0].text, "请分析错误");
|
|
87
|
+
assert.equal(rewritten.content[1].text, "视觉观察结果");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("启用时仅覆盖图片能力元数据", () => {
|
|
91
|
+
const base = {
|
|
92
|
+
provider: "deepseek-official",
|
|
93
|
+
id: "deepseek-v4-flash",
|
|
94
|
+
name: "DeepSeek-V4-Flash",
|
|
95
|
+
inputModalities: ["text"]
|
|
96
|
+
};
|
|
97
|
+
assert.deepEqual(withImageCapability(base, config).inputModalities, ["text", "image"]);
|
|
98
|
+
assert.equal(withImageCapability(base, { ...config, enabled: false }), base);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("固定视觉模型把图片转换为主模型观察", async () => {
|
|
102
|
+
const visionRequests = [];
|
|
103
|
+
const originalFetch = globalThis.fetch;
|
|
104
|
+
globalThis.fetch = async (_url, options) => {
|
|
105
|
+
visionRequests.push(JSON.parse(options.body));
|
|
106
|
+
return {
|
|
107
|
+
ok: true,
|
|
108
|
+
json: async () => ({ choices: [{ message: { content: "发现配置项拼写错误" } }] })
|
|
109
|
+
};
|
|
110
|
+
};
|
|
111
|
+
const controller = new VisionFallbackController(createServiceContext(), () => config);
|
|
112
|
+
const current = userMessage("u2", [
|
|
113
|
+
{ type: "text", text: "图片里是什么?" },
|
|
114
|
+
{ type: "image", attachment }
|
|
115
|
+
]);
|
|
116
|
+
const context = [assistantMessage("a1", "请发截图"), current];
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
const [rewritten] = await controller.preprocess([current], context);
|
|
120
|
+
assert.equal(visionRequests[0].model, "vision-model");
|
|
121
|
+
assert.match(visionRequests[0].messages[1].content[0].text, /图片里是什么/);
|
|
122
|
+
assert.match(visionRequests[0].messages[1].content[0].text, /请发截图/);
|
|
123
|
+
assert.match(rewritten.content[1].text, /发现配置项拼写错误/);
|
|
124
|
+
} finally {
|
|
125
|
+
globalThis.fetch = originalFetch;
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("surface replacement 保留原图界面并只替换模型视图", async () => {
|
|
130
|
+
const errors = [];
|
|
131
|
+
const coordinator = new ReplacementCoordinator({
|
|
132
|
+
error(value) {
|
|
133
|
+
errors.push(value);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
const appends = [];
|
|
137
|
+
const original = userMessage("u1", [{ type: "image", attachment }]);
|
|
138
|
+
const rewritten = userMessage("u1", [{ type: "text", text: "隐藏视觉观察" }]);
|
|
139
|
+
let surface = [original];
|
|
140
|
+
const originalDeriveMessages = () => surface;
|
|
141
|
+
const session = {
|
|
142
|
+
deriveMessages: originalDeriveMessages,
|
|
143
|
+
append(type, data, options) {
|
|
144
|
+
appends.push({ type, data, options });
|
|
145
|
+
surface = [data];
|
|
146
|
+
return { seq: 13, type, data, ...options };
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
coordinator.stage(session, [original], [rewritten]);
|
|
151
|
+
coordinator.onSessionEvent(session, { seq: 12, type: "user/message", surfaceOp: "append", data: original });
|
|
152
|
+
assert.equal(session.deriveMessages()[0], rewritten);
|
|
153
|
+
await new Promise((resolve) => queueMicrotask(resolve));
|
|
154
|
+
|
|
155
|
+
assert.equal(errors.length, 0);
|
|
156
|
+
assert.deepEqual(appends, [{
|
|
157
|
+
type: "user/message",
|
|
158
|
+
data: rewritten,
|
|
159
|
+
options: {
|
|
160
|
+
surfaceOp: { op: "replace", start: 12, end: 12 },
|
|
161
|
+
sourceEventSeqs: [12]
|
|
162
|
+
}
|
|
163
|
+
}]);
|
|
164
|
+
assert.equal(session.deriveMessages()[0], rewritten);
|
|
165
|
+
coordinator.dispose();
|
|
166
|
+
assert.equal(session.deriveMessages, originalDeriveMessages);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("surface replacement 事件不会再次触发替换", async () => {
|
|
170
|
+
const coordinator = new ReplacementCoordinator({ error() {} });
|
|
171
|
+
const original = userMessage("u1", [{ type: "image", attachment }]);
|
|
172
|
+
const rewritten = userMessage("u1", [{ type: "text", text: "隐藏视觉观察" }]);
|
|
173
|
+
const appends = [];
|
|
174
|
+
const session = {
|
|
175
|
+
deriveMessages: () => [original],
|
|
176
|
+
append(type, data, options) {
|
|
177
|
+
appends.push({ type, data, options });
|
|
178
|
+
coordinator.onSessionEvent(session, {
|
|
179
|
+
seq: 13,
|
|
180
|
+
type,
|
|
181
|
+
surfaceOp: options.surfaceOp,
|
|
182
|
+
data
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
coordinator.stage(session, [original], [rewritten]);
|
|
188
|
+
coordinator.onSessionEvent(session, {
|
|
189
|
+
seq: 12,
|
|
190
|
+
type: "user/message",
|
|
191
|
+
surfaceOp: "append",
|
|
192
|
+
data: original
|
|
193
|
+
});
|
|
194
|
+
await new Promise((resolve) => queueMicrotask(resolve));
|
|
195
|
+
await new Promise((resolve) => queueMicrotask(resolve));
|
|
196
|
+
|
|
197
|
+
assert.equal(appends.length, 1);
|
|
198
|
+
assert.deepEqual(appends[0].options.surfaceOp, { op: "replace", start: 12, end: 12 });
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("图片能力覆盖可随开关变化并能恢复", async () => {
|
|
202
|
+
let current = config;
|
|
203
|
+
const original = async (provider, model) => ({
|
|
204
|
+
provider,
|
|
205
|
+
id: model,
|
|
206
|
+
name: model,
|
|
207
|
+
inputModalities: ["text"]
|
|
208
|
+
});
|
|
209
|
+
const ctx = { llm: { resolveModelInfo: original } };
|
|
210
|
+
const restore = installCapabilityOverride(ctx, () => current);
|
|
211
|
+
|
|
212
|
+
assert.deepEqual((await ctx.llm.resolveModelInfo("deepseek-official", "deepseek-v4-flash")).inputModalities, ["text", "image"]);
|
|
213
|
+
current = { ...config, enabled: false };
|
|
214
|
+
assert.deepEqual((await ctx.llm.resolveModelInfo("deepseek-official", "deepseek-v4-flash")).inputModalities, ["text"]);
|
|
215
|
+
restore();
|
|
216
|
+
assert.equal(ctx.llm.resolveModelInfo, original);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("插件注册静默预处理钩子而不注册新模型 adapter", () => {
|
|
220
|
+
const hooks = new Map();
|
|
221
|
+
let cleanup;
|
|
222
|
+
let registeredSettings;
|
|
223
|
+
let registeredRoute;
|
|
224
|
+
const settingsEffects = [];
|
|
225
|
+
const webEffects = [];
|
|
226
|
+
const replacements = [];
|
|
227
|
+
const settings = {
|
|
228
|
+
register(namespace, schema, options) {
|
|
229
|
+
registeredSettings = { namespace, schema, options };
|
|
230
|
+
return {
|
|
231
|
+
get() {
|
|
232
|
+
return options.base;
|
|
233
|
+
},
|
|
234
|
+
watch() {}
|
|
235
|
+
};
|
|
236
|
+
},
|
|
237
|
+
async replace(namespace, value) {
|
|
238
|
+
replacements.push({ namespace, value });
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
const ctx = {
|
|
242
|
+
llm: {
|
|
243
|
+
resolveModelInfo: async (provider, model) => ({ provider, id: model, name: model, inputModalities: ["text"] })
|
|
244
|
+
},
|
|
245
|
+
logger: { error() {} },
|
|
246
|
+
effect(factory) {
|
|
247
|
+
cleanup = factory();
|
|
248
|
+
},
|
|
249
|
+
on(name, handler) {
|
|
250
|
+
hooks.set(name, handler);
|
|
251
|
+
return () => hooks.delete(name);
|
|
252
|
+
},
|
|
253
|
+
inject(dependencies, callback) {
|
|
254
|
+
if (dependencies[0] === "settings") {
|
|
255
|
+
assert.deepEqual(dependencies, ["settings"]);
|
|
256
|
+
callback({
|
|
257
|
+
settings,
|
|
258
|
+
effect(factory) {
|
|
259
|
+
settingsEffects.push(factory());
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
assert.deepEqual(dependencies, ["webServer"]);
|
|
265
|
+
callback({
|
|
266
|
+
webServer: {
|
|
267
|
+
register(route) {
|
|
268
|
+
registeredRoute = route;
|
|
269
|
+
return () => {};
|
|
270
|
+
}
|
|
271
|
+
},
|
|
272
|
+
effect(factory) {
|
|
273
|
+
webEffects.push(factory());
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
apply(ctx, config);
|
|
280
|
+
assert.equal(typeof hooks.get("agent/pre-step"), "function");
|
|
281
|
+
assert.equal(typeof hooks.get("session/event"), "function");
|
|
282
|
+
assert.equal("registerAdapter" in ctx.llm, false);
|
|
283
|
+
assert.equal("registerConfigurableProviders" in ctx.llm, false);
|
|
284
|
+
assert.equal(typeof cleanup, "function");
|
|
285
|
+
assert.equal(registeredSettings.namespace, "vision-fallback");
|
|
286
|
+
assert.equal(registeredSettings.options.base, config);
|
|
287
|
+
assert.equal(settingsEffects.length, 2);
|
|
288
|
+
assert.equal(webEffects.length, 1);
|
|
289
|
+
assert.equal(registeredRoute.kind, "exact");
|
|
290
|
+
assert.equal(registeredRoute.path, CONFIG_ROUTE);
|
|
291
|
+
assert.equal(replacements.length, 0);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("插件私有配置路由允许本机读取和保存", async () => {
|
|
295
|
+
let route;
|
|
296
|
+
let stored = config;
|
|
297
|
+
const ctx = {
|
|
298
|
+
inject(dependencies, callback) {
|
|
299
|
+
assert.deepEqual(dependencies, ["webServer"]);
|
|
300
|
+
callback({
|
|
301
|
+
webServer: {
|
|
302
|
+
register(value) {
|
|
303
|
+
route = value;
|
|
304
|
+
return () => {};
|
|
305
|
+
}
|
|
306
|
+
},
|
|
307
|
+
effect(factory) {
|
|
308
|
+
factory();
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
const settings = {
|
|
314
|
+
async replace(namespace, value) {
|
|
315
|
+
assert.equal(namespace, "vision-fallback");
|
|
316
|
+
stored = value;
|
|
317
|
+
}
|
|
318
|
+
};
|
|
319
|
+
const { installConfigRoute } = await import("../lib/index.js");
|
|
320
|
+
installConfigRoute(ctx, () => stored, () => settings);
|
|
321
|
+
|
|
322
|
+
const createRequest = (method, body) => ({
|
|
323
|
+
method,
|
|
324
|
+
headers: {
|
|
325
|
+
host: "127.0.0.1:3080",
|
|
326
|
+
...(body === undefined ? {} : { "content-type": "application/json" })
|
|
327
|
+
},
|
|
328
|
+
socket: { remoteAddress: "127.0.0.1" },
|
|
329
|
+
async *[Symbol.asyncIterator]() {
|
|
330
|
+
if (body !== undefined) yield Buffer.from(JSON.stringify(body));
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
const createResponse = () => {
|
|
334
|
+
const result = { headers: {} };
|
|
335
|
+
return {
|
|
336
|
+
result,
|
|
337
|
+
setHeader(name, value) {
|
|
338
|
+
result.headers[name.toLowerCase()] = value;
|
|
339
|
+
},
|
|
340
|
+
writeHead(status, headers) {
|
|
341
|
+
result.status = status;
|
|
342
|
+
Object.assign(result.headers, headers);
|
|
343
|
+
},
|
|
344
|
+
end(body) {
|
|
345
|
+
result.body = JSON.parse(body);
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
const getResponse = createResponse();
|
|
351
|
+
await route.handler(createRequest("GET"), getResponse);
|
|
352
|
+
assert.equal(getResponse.result.status, 200);
|
|
353
|
+
assert.equal(getResponse.result.body.ok, true);
|
|
354
|
+
assert.equal(getResponse.result.body.value.model, "vision-model");
|
|
355
|
+
|
|
356
|
+
const replacement = { ...config, model: "mimo-v2.5" };
|
|
357
|
+
const postResponse = createResponse();
|
|
358
|
+
await route.handler(createRequest("POST", replacement), postResponse);
|
|
359
|
+
assert.equal(postResponse.result.status, 200);
|
|
360
|
+
assert.equal(postResponse.result.body.ok, true);
|
|
361
|
+
assert.equal(stored.model, "mimo-v2.5");
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test("浏览器端只显示静默视觉配置", async () => {
|
|
365
|
+
const packageJson = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8"));
|
|
366
|
+
const clientSource = await readFile(new URL("../lib/client.js", import.meta.url), "utf8");
|
|
367
|
+
assert.equal(packageJson.exports["./client"], "./lib/client.js");
|
|
368
|
+
assert.equal(packageJson.dsh.client.platform, "web");
|
|
369
|
+
assert.doesNotMatch(clientSource, /targetProvider|targetModel|主模型提供方|工作模式/);
|
|
370
|
+
assert.doesNotMatch(clientSource, /datalist|vf-vision-models/);
|
|
371
|
+
assert.match(clientSource, /开启后不会新增模型分组/);
|
|
372
|
+
assert.match(clientSource, /固定视觉模型/);
|
|
373
|
+
assert.match(clientSource, /mimo-v2\.5/);
|
|
374
|
+
assert.match(clientSource, /手动输入其他模型/);
|
|
375
|
+
assert.match(clientSource, /\/plugins\/dsh-vision-fallback\/config/);
|
|
376
|
+
assert.doesNotMatch(clientSource, /settingsScope/);
|
|
377
|
+
|
|
378
|
+
let definition;
|
|
379
|
+
const previousWindow = globalThis.window;
|
|
380
|
+
const previousFetch = globalThis.fetch;
|
|
381
|
+
const configRequests = [];
|
|
382
|
+
globalThis.window = {
|
|
383
|
+
__ModuleLoader__: {
|
|
384
|
+
load(value) {
|
|
385
|
+
definition = value;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
};
|
|
389
|
+
globalThis.fetch = async (url, options) => {
|
|
390
|
+
configRequests.push({ url, options });
|
|
391
|
+
return {
|
|
392
|
+
ok: true,
|
|
393
|
+
status: 200,
|
|
394
|
+
json: async () => ({ ok: true, value: config })
|
|
395
|
+
};
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
try {
|
|
399
|
+
await import(`../lib/client.js?test=${Date.now()}`);
|
|
400
|
+
const client = definition.factory((id) => {
|
|
401
|
+
if (id === "react") return {};
|
|
402
|
+
throw new Error(`unexpected client dependency: ${id}`);
|
|
403
|
+
});
|
|
404
|
+
assert.deepEqual(client.inject, ["slots", "connection"]);
|
|
405
|
+
|
|
406
|
+
let registration;
|
|
407
|
+
client.apply({
|
|
408
|
+
get(service) {
|
|
409
|
+
assert.equal(service, "connection");
|
|
410
|
+
return { api: {} };
|
|
411
|
+
},
|
|
412
|
+
effect() {},
|
|
413
|
+
slots: {
|
|
414
|
+
inject(name, mount) {
|
|
415
|
+
assert.equal(name, "settings.section");
|
|
416
|
+
mount();
|
|
417
|
+
},
|
|
418
|
+
register(options, component) {
|
|
419
|
+
registration = { options, component };
|
|
420
|
+
return () => {};
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
assert.equal(registration.options.id, "vision-fallback");
|
|
426
|
+
assert.equal(registration.options.label(), "视觉增强");
|
|
427
|
+
const injected = registration.options.inject();
|
|
428
|
+
assert.equal(typeof injected.scope.getSnapshot, "function");
|
|
429
|
+
assert.equal(typeof injected.scope.replace, "function");
|
|
430
|
+
assert.deepEqual(injected.api, {});
|
|
431
|
+
assert.equal(typeof registration.component, "function");
|
|
432
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
433
|
+
assert.equal(configRequests.length, 1);
|
|
434
|
+
assert.equal(configRequests[0].url, CONFIG_ROUTE);
|
|
435
|
+
assert.equal(configRequests[0].options.method, "GET");
|
|
436
|
+
assert.equal(injected.scope.getSnapshot().status, "ready");
|
|
437
|
+
assert.equal(injected.scope.getSnapshot().value.model, "vision-model");
|
|
438
|
+
} finally {
|
|
439
|
+
if (previousWindow === undefined) delete globalThis.window;
|
|
440
|
+
else globalThis.window = previousWindow;
|
|
441
|
+
globalThis.fetch = previousFetch;
|
|
442
|
+
}
|
|
443
|
+
});
|