dsh-dock 0.11.2 → 0.11.3

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.
Files changed (38) hide show
  1. package/LICENSE +18 -18
  2. package/README.md +242 -396
  3. package/client.js +173 -9
  4. package/cordis.patch.yml +2 -2
  5. package/features/animation/host.js +119 -119
  6. package/features/animation/view.jsx +1662 -1662
  7. package/features/balance/host.js +494 -494
  8. package/features/balance/view.js +351 -351
  9. package/features/games/games/breakout.jsx +227 -227
  10. package/features/games/games/gomoku.jsx +244 -244
  11. package/features/games/games/racer.jsx +169 -169
  12. package/features/games/games/shared.jsx +118 -118
  13. package/features/games/games/snake.jsx +144 -144
  14. package/features/games/games/sokoban.jsx +180 -180
  15. package/features/games/games/tank.jsx +128 -128
  16. package/features/games/games/tetris.jsx +308 -308
  17. package/features/games/games/xiangqi.jsx +277 -277
  18. package/features/games/tank-core.js +152 -152
  19. package/features/games/view.jsx +639 -639
  20. package/features/games/xiangqi-core.js +299 -299
  21. package/features/heartbeat/view.js +35 -35
  22. package/features/mobile-relay/gateway.js +472 -472
  23. package/features/mobile-relay/host.js +570 -570
  24. package/features/mobile-relay/view.jsx +300 -300
  25. package/features/modelconfig/host.js +406 -406
  26. package/features/modelconfig/view.js +490 -490
  27. package/features/notify/host.js +374 -374
  28. package/features/notify/view.jsx +751 -751
  29. package/features/theme/view.js +61 -61
  30. package/features/tokenlog/host.js +882 -883
  31. package/features/tokenlog/view.jsx +1117 -932
  32. package/features/visionproxy/host.js +315 -315
  33. package/index.js +195 -195
  34. package/package.json +66 -64
  35. package/src/client.jsx +677 -677
  36. package/src/host-core.js +207 -207
  37. package/src/shared.js +192 -192
  38. package/src/task-track.js +354 -359
@@ -1,315 +1,315 @@
1
- // dsh-dock · 功能模块【图片理解代理】· 宿主半部(v0.3.1 迁自 index.js,行为不变)
2
- //
3
- // 纯文本模型收到图片时,自动调用配置的视觉模型识别图片,
4
- // 把识别文本替换进请求(多模态模型不受影响,原样自识别)。
5
- // 拦截点:官方 llm/stream 瀑布流不允许改写 frozen 请求、registerAdapter 拒绝重复注册,
6
- // 故对 llm.stream / llm.prepareCall 做方法级包装(保存原函数,dispose 恢复)。
7
- // 配置存自有 settings 命名空间 dsh-dock(visionProxy: enabled/provider/model),
8
- // 其编辑 UI 在模型设置页内(见 features/modelconfig/view.js)。
9
- import { BlockAssembler } from '@deepseek-ai/dsh-llm'
10
- import { DOCK_NS } from '../../src/host-core.js'
11
- import { readModelDirectory } from '../modelconfig/host.js'
12
-
13
- /** 递归判断内容块里是否有图片(含 tool-result 嵌套,与官方 contentHasImage 同构)。 */
14
- function contentHasImageDeep(content) {
15
- if (!Array.isArray(content)) return false
16
- for (const block of content) {
17
- if (block && block.type === 'image') return true
18
- if (block && block.type === 'tool-result' && contentHasImageDeep(block.content)) return true
19
- }
20
- return false
21
- }
22
-
23
- /**
24
- * 用户在 dsh-dock 模型设置里勾选「图片」后的声明。
25
- * 这不是联网探测结果,而是明确让用户决定端点能力:声明后原图直发,
26
- * 未声明时才继续参考运行时目录或走图片理解代理。
27
- */
28
- function hasUserDeclaredImage(directory, provider, model) {
29
- const p = (directory.providers || []).find((x) => x && x.id === provider)
30
- const m = p && Array.isArray(p.models) ? p.models.find((x) => x && x.id === model) : undefined
31
- return !!(m && Array.isArray(m.input) && m.input.includes('image'))
32
- }
33
-
34
- /** 让视觉模型描述一张图片,返回识别文本(失败抛错)。 */
35
- async function describeImage(deps, cfg, block, signal) {
36
- const prompt = '请用中文详细描述这张图片的内容:主体、文字(若有则逐字转写)、数据/图表要点、与编码任务相关的细节。控制在 300 字以内,直接输出描述,不要客套。'
37
- // 单次识别调用:返回文本(可为空串,由调用方决定重试),失败抛错。
38
- // ⚠️ 不带 sessionId:llm/stream 的会话检查点监听对带 sessionId 的调用先做检查点,
39
- // 回合进行中会 fail-closed 短路适配器。
40
- // ⚠️ 走 prepareCall 通道而非 llm.stream:llm/stream 公共路径对带图请求实测会被
41
- // 截断(单 block-start 后静默结束,两种适配器均然,端点直连正常);agent-loop 的
42
- // 带图请求全部经 prepareCall().stream() 且工作正常,此处同通道。
43
- // prepareCall(config).stream(options) 要求 options 与 config 在 provider/model/
44
- // reasoningEffort/maxTokens 等请求控制字段上完全一致,故两处同源构造。
45
- const run = async (effort) => {
46
- const config = { provider: cfg.provider, model: cfg.model, maxTokens: 4096 }
47
- if (effort) config.reasoningEffort = effort
48
- const prepared = await deps.prepareCall(config, signal)
49
- // options 必须与 resolvedConfig 完全一致(callConfigEquals),以 prepared.config 为底
50
- const options = Object.assign({}, prepared.config, {
51
- messages: [{
52
- role: 'user',
53
- source: { kind: 'plugin', plugin: 'dsh-dock' },
54
- content: [block, { type: 'text', text: prompt }],
55
- }],
56
- // 递归保护:本请求由代理发起,包装层直接放行
57
- dockVisionProxy: true,
58
- })
59
- if (signal) options.signal = signal
60
- const assembler = new BlockAssembler()
61
- for await (const chunk of prepared.stream(options)) {
62
- assembler.push(chunk)
63
- // ⚠️ BlockAssembler.finish 是 getter,未收到 finish chunk 时也返回 {kind:'stop'}
64
- // (恒真值)——只能用 chunk.type 判断,用 assembler.finish 当条件会在第一个
65
- // chunk 后就 break,识别永远为空(2026-08-22 排查一下午的根因)。
66
- if (chunk && chunk.type === 'finish') break
67
- }
68
- if (assembler.finish && assembler.finish.kind === 'error') {
69
- throw new Error(String(assembler.finish.failure?.message || '视觉模型调用失败'))
70
- }
71
- return assembler.blocks()
72
- .filter((b) => b && b.type === 'text')
73
- .map((b) => b.text)
74
- .join('')
75
- .trim()
76
- }
77
- // 识别是辅助调用,求快:先试关思考(off);报不支持或返回空内容都回退默认档重试
78
- let text
79
- try {
80
- text = await run('off')
81
- } catch (e) {
82
- if (!(e instanceof Error && /does not support reasoning effort|UNSUPPORTED_REASONING_EFFORT/i.test(e.message))) {
83
- throw e
84
- }
85
- }
86
- if (!text) text = await run(undefined)
87
- if (!text) throw new Error('视觉模型没有返回文本')
88
- return text
89
- }
90
-
91
- /**
92
- * 识别结果缓存(attachmentId + 视觉模型 → 文本)。
93
- * 端点对连续识图有节流(实测第 ~6 次起掐流成单个 block-start);agent 每轮
94
- * 重试/多步都会带着历史图片重发请求,逐图重识别既慢又会撞限流——同一张图
95
- * 10 分钟内只识别一次,失败不缓存。
96
- */
97
- const describeCache = new Map()
98
- const DESCRIBE_CACHE_TTL = 10 * 60 * 1000
99
- const DESCRIBE_CACHE_MAX = 64
100
-
101
- function cacheKey(cfg, block) {
102
- return `${cfg.provider}/${cfg.model}:${block && block.attachment ? block.attachment.attachmentId : ''}`
103
- }
104
-
105
- function cacheGet(key) {
106
- const hit = describeCache.get(key)
107
- if (!hit) return undefined
108
- if (Date.now() - hit.at > DESCRIBE_CACHE_TTL) {
109
- describeCache.delete(key)
110
- return undefined
111
- }
112
- return hit.text
113
- }
114
-
115
- function cacheSet(key, text) {
116
- if (describeCache.size >= DESCRIBE_CACHE_MAX) {
117
- const oldest = describeCache.keys().next().value
118
- describeCache.delete(oldest)
119
- }
120
- describeCache.set(key, { text, at: Date.now() })
121
- }
122
-
123
- /**
124
- * 请求改写:目标是纯文本模型且带图片时,逐图调用视觉模型,图片块替换为识别文本。
125
- * 任何失败都只降级(保留原图或占位说明),绝不阻断主请求。
126
- * @returns 改写后的 options(无需改写时原样返回)
127
- */
128
- async function transformForVisionProxy(deps, options) {
129
- const cfg = deps.config()
130
- const log = (msg) => { try { console.log('[dsh-dock] visionproxy ' + msg) } catch { /* 日志失败不影响主流程 */ } }
131
- try {
132
- if (!cfg || !cfg.enabled || !cfg.provider || !cfg.model) return options
133
- if (!options || typeof options !== 'object') return options
134
- if (options.dockVisionProxy) return options
135
- if (options.provider === cfg.provider && options.model === cfg.model) return options
136
- if (options.purpose) return options // 内部辅助调用(如会话标题)不代理
137
- if (!Array.isArray(options.messages)) return options
138
- if (!options.messages.some((m) => m && contentHasImageDeep(m.content))) return options
139
- log(`带图请求 ${options.provider}/${options.model}`)
140
-
141
- // 图片能力判定的优先级:用户在模型设置里勾选「图片」优先。
142
- // 运行时目录只是自动提示,无法识别或识别错误时不能覆盖用户的明确决定。
143
- // 这让 kimi-k2.7-code 等端点可由用户声明多模态并原图直发;若端点实际不支持,
144
- // 上游会返回错误,用户取消勾选即可恢复走图片理解代理。
145
- const directory = deps.directory()
146
- if (hasUserDeclaredImage(directory, options.provider, options.model)) {
147
- log(`跳过 ${options.provider}/${options.model}:用户已声明图片能力(原图直发)`)
148
- return options
149
- }
150
-
151
- // 未声明时再问运行时(llm.resolveModelInfo,与官方投影同源:
152
- // entry.input → 内置目录 → 路由默认);旧宿主无此方法或单次解析失败时,
153
- // 保守走图片理解代理,避免猜错端点能力。
154
- let imageCapable
155
- try {
156
- const info = deps.resolveModelInfo ? await deps.resolveModelInfo(options.provider, options.model) : undefined
157
- if (info && Array.isArray(info.inputModalities)) imageCapable = info.inputModalities.includes('image')
158
- } catch {
159
- // 落到目录回退
160
- }
161
- if (imageCapable) {
162
- log(`跳过 ${options.provider}/${options.model}:运行时判定多模态(原图直发)`)
163
- return options
164
- }
165
-
166
- let changed = false
167
- const replaceBlocks = async (blocks) => {
168
- const next = []
169
- for (const block of blocks) {
170
- if (block && block.type === 'image') {
171
- changed = true
172
- const key = cacheKey(cfg, block)
173
- const cached = cacheGet(key)
174
- if (cached !== undefined) {
175
- log('命中识别缓存')
176
- next.push({ type: 'text', text: `[图片内容(由视觉模型 ${cfg.provider}/${cfg.model} 识别):${cached}]` })
177
- continue
178
- }
179
- try {
180
- const desc = await describeImage(deps, cfg, block, options.signal)
181
- cacheSet(key, desc)
182
- log(`识别成功 ${desc.length} 字`)
183
- next.push({ type: 'text', text: `[图片内容(由视觉模型 ${cfg.provider}/${cfg.model} 识别):${desc}]` })
184
- } catch (e) {
185
- // 识别失败:降级为说明文本,官方投影不会再把请求打挂
186
- log(`识别失败:${e instanceof Error ? e.message : String(e)}`)
187
- next.push({ type: 'text', text: `[图片未能识别(${e instanceof Error ? e.message : String(e)}),已省略]` })
188
- }
189
- continue
190
- }
191
- if (block && block.type === 'tool-result' && contentHasImageDeep(block.content)) {
192
- next.push(Object.assign({}, block, { content: await replaceBlocks(block.content) }))
193
- continue
194
- }
195
- next.push(block)
196
- }
197
- return next
198
- }
199
- const messages = []
200
- for (const msg of options.messages) {
201
- if (msg && contentHasImageDeep(msg.content)) {
202
- messages.push(Object.assign({}, msg, { content: await replaceBlocks(msg.content) }))
203
- } else {
204
- messages.push(msg)
205
- }
206
- }
207
- return changed ? Object.assign({}, options, { messages }) : options
208
- } catch {
209
- return options // 总兜底:代理自身异常不影响主请求
210
- }
211
- }
212
-
213
- /** 安装 llm 方法包装:stream 与 prepareCall 两条入口都过一遍图片代理改写。 */
214
- function installVisionProxy(ctx, directory) {
215
- const llm = ctx.get('llm')
216
- if (!llm || typeof llm.stream !== 'function' || typeof llm.prepareCall !== 'function') {
217
- throw new Error('llm 服务不可用,无法安装图片理解代理')
218
- }
219
- // 原函数先绑定保存:改写判定与识别调用都走原函数,不受包装影响
220
- const origResolve = typeof llm.resolveModelInfo === 'function' ? llm.resolveModelInfo.bind(llm) : null
221
- if (origResolve) llm.__dockOrigResolveModelInfo = origResolve
222
- const origPrepareCall = llm.prepareCall.bind(llm)
223
- const deps = {
224
- config: () => {
225
- try {
226
- const settings = ctx.get('settings')
227
- const v = settings && typeof settings.get === 'function' ? settings.get(DOCK_NS) : undefined
228
- return v && v.visionProxy ? v.visionProxy : { enabled: false, provider: '', model: '' }
229
- } catch {
230
- return { enabled: false, provider: '', model: '' }
231
- }
232
- },
233
- // 识别调用走保存的原函数:天然绕开包装层,无递归
234
- stream: (options) => llm.__dockOrigStream(options),
235
- // 识别调用走原 prepareCall 通道(带图请求的被验证路径)
236
- prepareCall: origPrepareCall,
237
- // 运行时模型信息查询(图片能力判定与官方投影同源);旧宿主无此方法时为 null
238
- resolveModelInfo: origResolve,
239
- directory,
240
- }
241
- llm.__dockOrigStream = llm.stream.bind(llm)
242
- const origPrepare = llm.prepareCall
243
- llm.stream = (options) => (async function* () {
244
- yield* llm.__dockOrigStream(await transformForVisionProxy(deps, options))
245
- })()
246
- llm.prepareCall = async (config, signal) => {
247
- const prepared = await origPrepare.call(llm, config, signal)
248
- // prepared 是冻结对象:浅拷贝后包一层 stream,dispatched 一次性约束经原闭包保持。
249
- // ⚠️ stream 必须同步返回 AsyncIterable:消费方 `for await (of stream)` 不接受
250
- // Promise(Node 语义:of 表达式直接取 Symbol.asyncIterator),async 包装会令
251
- // 全部请求报 "stream is not async iterable"(2026-08-22 事故)。
252
- return Object.assign({}, prepared, {
253
- stream: (options) => (async function* () {
254
- yield* prepared.stream(await transformForVisionProxy(deps, options))
255
- })(),
256
- })
257
- }
258
- // 虚拟多模态:代理启用时,纯文本模型对外宣称支持图片,让官方带图准入放行——
259
- // apiproxy 的 prompt 收图校验、mcp-client/tool-fs 的图片声明检查都查
260
- // llm.resolveModelInfo 的 inputModalities;图片真正到端点前会被 stream/prepareCall
261
- // 包装层替换为识别文本。视觉模型自身与多模态模型不受影响;官方内部投影不走
262
- // 此公共方法,truth 不变。
263
- if (origResolve) {
264
- llm.resolveModelInfo = async (provider, model, signal) => {
265
- const info = await origResolve(provider, model, signal)
266
- try {
267
- const cfg = deps.config()
268
- if (!cfg || !cfg.enabled || !cfg.provider || !cfg.model) return info
269
- if (provider === cfg.provider && model === cfg.model) return info
270
- if (info && Array.isArray(info.inputModalities) && info.inputModalities.length > 0
271
- && !info.inputModalities.includes('image')) {
272
- return Object.assign({}, info, { inputModalities: info.inputModalities.concat('image') })
273
- }
274
- } catch {
275
- // 保真回退
276
- }
277
- return info
278
- }
279
- }
280
- return () => {
281
- delete llm.__dockOrigStream
282
- delete llm.stream
283
- delete llm.prepareCall
284
- delete llm.__dockOrigResolveModelInfo
285
- delete llm.resolveModelInfo
286
- }
287
- }
288
-
289
- /** 测试钩子:清空识别缓存(冒烟在用例间隔离)。 */
290
- export function __clearDescribeCache() {
291
- describeCache.clear()
292
- }
293
-
294
- /** 功能注册表条目:index.js 组装 hostSetups 时使用。 */
295
- export const feature = {
296
- id: 'visionproxy',
297
- name: '图片理解代理',
298
- description: '纯文本模型收图时自动调用视觉模型识别(多模态模型不受影响)',
299
- defaultEnabled: false,
300
- setup(ctx) {
301
- // 模型目录的 TTL 缓存:图片代理判定目标模型是否纯文本时用(避免每个请求都全量 describe)
302
- let dockDirCache = { at: 0, data: { providers: [] } }
303
- const directoryCached = () => {
304
- if (Date.now() - dockDirCache.at > 10000) {
305
- try {
306
- dockDirCache = { at: Date.now(), data: readModelDirectory(ctx) }
307
- } catch {
308
- // 保持旧缓存
309
- }
310
- }
311
- return dockDirCache.data
312
- }
313
- return installVisionProxy(ctx, directoryCached)
314
- },
315
- }
1
+ // dsh-dock · 功能模块【图片理解代理】· 宿主半部(v0.3.1 迁自 index.js,行为不变)
2
+ //
3
+ // 纯文本模型收到图片时,自动调用配置的视觉模型识别图片,
4
+ // 把识别文本替换进请求(多模态模型不受影响,原样自识别)。
5
+ // 拦截点:官方 llm/stream 瀑布流不允许改写 frozen 请求、registerAdapter 拒绝重复注册,
6
+ // 故对 llm.stream / llm.prepareCall 做方法级包装(保存原函数,dispose 恢复)。
7
+ // 配置存自有 settings 命名空间 dsh-dock(visionProxy: enabled/provider/model),
8
+ // 其编辑 UI 在模型设置页内(见 features/modelconfig/view.js)。
9
+ import { BlockAssembler } from '@deepseek-ai/dsh-llm'
10
+ import { DOCK_NS } from '../../src/host-core.js'
11
+ import { readModelDirectory } from '../modelconfig/host.js'
12
+
13
+ /** 递归判断内容块里是否有图片(含 tool-result 嵌套,与官方 contentHasImage 同构)。 */
14
+ function contentHasImageDeep(content) {
15
+ if (!Array.isArray(content)) return false
16
+ for (const block of content) {
17
+ if (block && block.type === 'image') return true
18
+ if (block && block.type === 'tool-result' && contentHasImageDeep(block.content)) return true
19
+ }
20
+ return false
21
+ }
22
+
23
+ /**
24
+ * 用户在 dsh-dock 模型设置里勾选「图片」后的声明。
25
+ * 这不是联网探测结果,而是明确让用户决定端点能力:声明后原图直发,
26
+ * 未声明时才继续参考运行时目录或走图片理解代理。
27
+ */
28
+ function hasUserDeclaredImage(directory, provider, model) {
29
+ const p = (directory.providers || []).find((x) => x && x.id === provider)
30
+ const m = p && Array.isArray(p.models) ? p.models.find((x) => x && x.id === model) : undefined
31
+ return !!(m && Array.isArray(m.input) && m.input.includes('image'))
32
+ }
33
+
34
+ /** 让视觉模型描述一张图片,返回识别文本(失败抛错)。 */
35
+ async function describeImage(deps, cfg, block, signal) {
36
+ const prompt = '请用中文详细描述这张图片的内容:主体、文字(若有则逐字转写)、数据/图表要点、与编码任务相关的细节。控制在 300 字以内,直接输出描述,不要客套。'
37
+ // 单次识别调用:返回文本(可为空串,由调用方决定重试),失败抛错。
38
+ // ⚠️ 不带 sessionId:llm/stream 的会话检查点监听对带 sessionId 的调用先做检查点,
39
+ // 回合进行中会 fail-closed 短路适配器。
40
+ // ⚠️ 走 prepareCall 通道而非 llm.stream:llm/stream 公共路径对带图请求实测会被
41
+ // 截断(单 block-start 后静默结束,两种适配器均然,端点直连正常);agent-loop 的
42
+ // 带图请求全部经 prepareCall().stream() 且工作正常,此处同通道。
43
+ // prepareCall(config).stream(options) 要求 options 与 config 在 provider/model/
44
+ // reasoningEffort/maxTokens 等请求控制字段上完全一致,故两处同源构造。
45
+ const run = async (effort) => {
46
+ const config = { provider: cfg.provider, model: cfg.model, maxTokens: 4096 }
47
+ if (effort) config.reasoningEffort = effort
48
+ const prepared = await deps.prepareCall(config, signal)
49
+ // options 必须与 resolvedConfig 完全一致(callConfigEquals),以 prepared.config 为底
50
+ const options = Object.assign({}, prepared.config, {
51
+ messages: [{
52
+ role: 'user',
53
+ source: { kind: 'plugin', plugin: 'dsh-dock' },
54
+ content: [block, { type: 'text', text: prompt }],
55
+ }],
56
+ // 递归保护:本请求由代理发起,包装层直接放行
57
+ dockVisionProxy: true,
58
+ })
59
+ if (signal) options.signal = signal
60
+ const assembler = new BlockAssembler()
61
+ for await (const chunk of prepared.stream(options)) {
62
+ assembler.push(chunk)
63
+ // ⚠️ BlockAssembler.finish 是 getter,未收到 finish chunk 时也返回 {kind:'stop'}
64
+ // (恒真值)——只能用 chunk.type 判断,用 assembler.finish 当条件会在第一个
65
+ // chunk 后就 break,识别永远为空(2026-08-22 排查一下午的根因)。
66
+ if (chunk && chunk.type === 'finish') break
67
+ }
68
+ if (assembler.finish && assembler.finish.kind === 'error') {
69
+ throw new Error(String(assembler.finish.failure?.message || '视觉模型调用失败'))
70
+ }
71
+ return assembler.blocks()
72
+ .filter((b) => b && b.type === 'text')
73
+ .map((b) => b.text)
74
+ .join('')
75
+ .trim()
76
+ }
77
+ // 识别是辅助调用,求快:先试关思考(off);报不支持或返回空内容都回退默认档重试
78
+ let text
79
+ try {
80
+ text = await run('off')
81
+ } catch (e) {
82
+ if (!(e instanceof Error && /does not support reasoning effort|UNSUPPORTED_REASONING_EFFORT/i.test(e.message))) {
83
+ throw e
84
+ }
85
+ }
86
+ if (!text) text = await run(undefined)
87
+ if (!text) throw new Error('视觉模型没有返回文本')
88
+ return text
89
+ }
90
+
91
+ /**
92
+ * 识别结果缓存(attachmentId + 视觉模型 → 文本)。
93
+ * 端点对连续识图有节流(实测第 ~6 次起掐流成单个 block-start);agent 每轮
94
+ * 重试/多步都会带着历史图片重发请求,逐图重识别既慢又会撞限流——同一张图
95
+ * 10 分钟内只识别一次,失败不缓存。
96
+ */
97
+ const describeCache = new Map()
98
+ const DESCRIBE_CACHE_TTL = 10 * 60 * 1000
99
+ const DESCRIBE_CACHE_MAX = 64
100
+
101
+ function cacheKey(cfg, block) {
102
+ return `${cfg.provider}/${cfg.model}:${block && block.attachment ? block.attachment.attachmentId : ''}`
103
+ }
104
+
105
+ function cacheGet(key) {
106
+ const hit = describeCache.get(key)
107
+ if (!hit) return undefined
108
+ if (Date.now() - hit.at > DESCRIBE_CACHE_TTL) {
109
+ describeCache.delete(key)
110
+ return undefined
111
+ }
112
+ return hit.text
113
+ }
114
+
115
+ function cacheSet(key, text) {
116
+ if (describeCache.size >= DESCRIBE_CACHE_MAX) {
117
+ const oldest = describeCache.keys().next().value
118
+ describeCache.delete(oldest)
119
+ }
120
+ describeCache.set(key, { text, at: Date.now() })
121
+ }
122
+
123
+ /**
124
+ * 请求改写:目标是纯文本模型且带图片时,逐图调用视觉模型,图片块替换为识别文本。
125
+ * 任何失败都只降级(保留原图或占位说明),绝不阻断主请求。
126
+ * @returns 改写后的 options(无需改写时原样返回)
127
+ */
128
+ async function transformForVisionProxy(deps, options) {
129
+ const cfg = deps.config()
130
+ const log = (msg) => { try { console.log('[dsh-dock] visionproxy ' + msg) } catch { /* 日志失败不影响主流程 */ } }
131
+ try {
132
+ if (!cfg || !cfg.enabled || !cfg.provider || !cfg.model) return options
133
+ if (!options || typeof options !== 'object') return options
134
+ if (options.dockVisionProxy) return options
135
+ if (options.provider === cfg.provider && options.model === cfg.model) return options
136
+ if (options.purpose) return options // 内部辅助调用(如会话标题)不代理
137
+ if (!Array.isArray(options.messages)) return options
138
+ if (!options.messages.some((m) => m && contentHasImageDeep(m.content))) return options
139
+ log(`带图请求 ${options.provider}/${options.model}`)
140
+
141
+ // 图片能力判定的优先级:用户在模型设置里勾选「图片」优先。
142
+ // 运行时目录只是自动提示,无法识别或识别错误时不能覆盖用户的明确决定。
143
+ // 这让 kimi-k2.7-code 等端点可由用户声明多模态并原图直发;若端点实际不支持,
144
+ // 上游会返回错误,用户取消勾选即可恢复走图片理解代理。
145
+ const directory = deps.directory()
146
+ if (hasUserDeclaredImage(directory, options.provider, options.model)) {
147
+ log(`跳过 ${options.provider}/${options.model}:用户已声明图片能力(原图直发)`)
148
+ return options
149
+ }
150
+
151
+ // 未声明时再问运行时(llm.resolveModelInfo,与官方投影同源:
152
+ // entry.input → 内置目录 → 路由默认);旧宿主无此方法或单次解析失败时,
153
+ // 保守走图片理解代理,避免猜错端点能力。
154
+ let imageCapable
155
+ try {
156
+ const info = deps.resolveModelInfo ? await deps.resolveModelInfo(options.provider, options.model) : undefined
157
+ if (info && Array.isArray(info.inputModalities)) imageCapable = info.inputModalities.includes('image')
158
+ } catch {
159
+ // 落到目录回退
160
+ }
161
+ if (imageCapable) {
162
+ log(`跳过 ${options.provider}/${options.model}:运行时判定多模态(原图直发)`)
163
+ return options
164
+ }
165
+
166
+ let changed = false
167
+ const replaceBlocks = async (blocks) => {
168
+ const next = []
169
+ for (const block of blocks) {
170
+ if (block && block.type === 'image') {
171
+ changed = true
172
+ const key = cacheKey(cfg, block)
173
+ const cached = cacheGet(key)
174
+ if (cached !== undefined) {
175
+ log('命中识别缓存')
176
+ next.push({ type: 'text', text: `[图片内容(由视觉模型 ${cfg.provider}/${cfg.model} 识别):${cached}]` })
177
+ continue
178
+ }
179
+ try {
180
+ const desc = await describeImage(deps, cfg, block, options.signal)
181
+ cacheSet(key, desc)
182
+ log(`识别成功 ${desc.length} 字`)
183
+ next.push({ type: 'text', text: `[图片内容(由视觉模型 ${cfg.provider}/${cfg.model} 识别):${desc}]` })
184
+ } catch (e) {
185
+ // 识别失败:降级为说明文本,官方投影不会再把请求打挂
186
+ log(`识别失败:${e instanceof Error ? e.message : String(e)}`)
187
+ next.push({ type: 'text', text: `[图片未能识别(${e instanceof Error ? e.message : String(e)}),已省略]` })
188
+ }
189
+ continue
190
+ }
191
+ if (block && block.type === 'tool-result' && contentHasImageDeep(block.content)) {
192
+ next.push(Object.assign({}, block, { content: await replaceBlocks(block.content) }))
193
+ continue
194
+ }
195
+ next.push(block)
196
+ }
197
+ return next
198
+ }
199
+ const messages = []
200
+ for (const msg of options.messages) {
201
+ if (msg && contentHasImageDeep(msg.content)) {
202
+ messages.push(Object.assign({}, msg, { content: await replaceBlocks(msg.content) }))
203
+ } else {
204
+ messages.push(msg)
205
+ }
206
+ }
207
+ return changed ? Object.assign({}, options, { messages }) : options
208
+ } catch {
209
+ return options // 总兜底:代理自身异常不影响主请求
210
+ }
211
+ }
212
+
213
+ /** 安装 llm 方法包装:stream 与 prepareCall 两条入口都过一遍图片代理改写。 */
214
+ function installVisionProxy(ctx, directory) {
215
+ const llm = ctx.get('llm')
216
+ if (!llm || typeof llm.stream !== 'function' || typeof llm.prepareCall !== 'function') {
217
+ throw new Error('llm 服务不可用,无法安装图片理解代理')
218
+ }
219
+ // 原函数先绑定保存:改写判定与识别调用都走原函数,不受包装影响
220
+ const origResolve = typeof llm.resolveModelInfo === 'function' ? llm.resolveModelInfo.bind(llm) : null
221
+ if (origResolve) llm.__dockOrigResolveModelInfo = origResolve
222
+ const origPrepareCall = llm.prepareCall.bind(llm)
223
+ const deps = {
224
+ config: () => {
225
+ try {
226
+ const settings = ctx.get('settings')
227
+ const v = settings && typeof settings.get === 'function' ? settings.get(DOCK_NS) : undefined
228
+ return v && v.visionProxy ? v.visionProxy : { enabled: false, provider: '', model: '' }
229
+ } catch {
230
+ return { enabled: false, provider: '', model: '' }
231
+ }
232
+ },
233
+ // 识别调用走保存的原函数:天然绕开包装层,无递归
234
+ stream: (options) => llm.__dockOrigStream(options),
235
+ // 识别调用走原 prepareCall 通道(带图请求的被验证路径)
236
+ prepareCall: origPrepareCall,
237
+ // 运行时模型信息查询(图片能力判定与官方投影同源);旧宿主无此方法时为 null
238
+ resolveModelInfo: origResolve,
239
+ directory,
240
+ }
241
+ llm.__dockOrigStream = llm.stream.bind(llm)
242
+ const origPrepare = llm.prepareCall
243
+ llm.stream = (options) => (async function* () {
244
+ yield* llm.__dockOrigStream(await transformForVisionProxy(deps, options))
245
+ })()
246
+ llm.prepareCall = async (config, signal) => {
247
+ const prepared = await origPrepare.call(llm, config, signal)
248
+ // prepared 是冻结对象:浅拷贝后包一层 stream,dispatched 一次性约束经原闭包保持。
249
+ // ⚠️ stream 必须同步返回 AsyncIterable:消费方 `for await (of stream)` 不接受
250
+ // Promise(Node 语义:of 表达式直接取 Symbol.asyncIterator),async 包装会令
251
+ // 全部请求报 "stream is not async iterable"(2026-08-22 事故)。
252
+ return Object.assign({}, prepared, {
253
+ stream: (options) => (async function* () {
254
+ yield* prepared.stream(await transformForVisionProxy(deps, options))
255
+ })(),
256
+ })
257
+ }
258
+ // 虚拟多模态:代理启用时,纯文本模型对外宣称支持图片,让官方带图准入放行——
259
+ // apiproxy 的 prompt 收图校验、mcp-client/tool-fs 的图片声明检查都查
260
+ // llm.resolveModelInfo 的 inputModalities;图片真正到端点前会被 stream/prepareCall
261
+ // 包装层替换为识别文本。视觉模型自身与多模态模型不受影响;官方内部投影不走
262
+ // 此公共方法,truth 不变。
263
+ if (origResolve) {
264
+ llm.resolveModelInfo = async (provider, model, signal) => {
265
+ const info = await origResolve(provider, model, signal)
266
+ try {
267
+ const cfg = deps.config()
268
+ if (!cfg || !cfg.enabled || !cfg.provider || !cfg.model) return info
269
+ if (provider === cfg.provider && model === cfg.model) return info
270
+ if (info && Array.isArray(info.inputModalities) && info.inputModalities.length > 0
271
+ && !info.inputModalities.includes('image')) {
272
+ return Object.assign({}, info, { inputModalities: info.inputModalities.concat('image') })
273
+ }
274
+ } catch {
275
+ // 保真回退
276
+ }
277
+ return info
278
+ }
279
+ }
280
+ return () => {
281
+ delete llm.__dockOrigStream
282
+ delete llm.stream
283
+ delete llm.prepareCall
284
+ delete llm.__dockOrigResolveModelInfo
285
+ delete llm.resolveModelInfo
286
+ }
287
+ }
288
+
289
+ /** 测试钩子:清空识别缓存(冒烟在用例间隔离)。 */
290
+ export function __clearDescribeCache() {
291
+ describeCache.clear()
292
+ }
293
+
294
+ /** 功能注册表条目:index.js 组装 hostSetups 时使用。 */
295
+ export const feature = {
296
+ id: 'visionproxy',
297
+ name: '图片理解代理',
298
+ description: '纯文本模型收图时自动调用视觉模型识别(多模态模型不受影响)',
299
+ defaultEnabled: false,
300
+ setup(ctx) {
301
+ // 模型目录的 TTL 缓存:图片代理判定目标模型是否纯文本时用(避免每个请求都全量 describe)
302
+ let dockDirCache = { at: 0, data: { providers: [] } }
303
+ const directoryCached = () => {
304
+ if (Date.now() - dockDirCache.at > 10000) {
305
+ try {
306
+ dockDirCache = { at: Date.now(), data: readModelDirectory(ctx) }
307
+ } catch {
308
+ // 保持旧缓存
309
+ }
310
+ }
311
+ return dockDirCache.data
312
+ }
313
+ return installVisionProxy(ctx, directoryCached)
314
+ },
315
+ }