dsh-vision-router 2.1.6 → 2.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -17,7 +17,7 @@
17
17
  </p>
18
18
 
19
19
  <p align="center">
20
- <a href="https://github.com/ysr666/dsh-vision-router/releases/tag/v2.1.6"><img src="https://img.shields.io/badge/release-v2.1.6-5B4CF0?style=flat-square" alt="Release v2.1.6" /></a>
20
+ <a href="https://github.com/ysr666/dsh-vision-router/releases/tag/v2.1.7"><img src="https://img.shields.io/badge/release-v2.1.7-5B4CF0?style=flat-square" alt="Release v2.1.7" /></a>
21
21
  <a href="tests"><img src="https://img.shields.io/badge/verified-Node%2022%20%2B%2024-2EA44F?style=flat-square" alt="Verified: Node 22 + 24" /></a>
22
22
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-2EA44F?style=flat-square" alt="License: MIT" /></a>
23
23
  <a href="package.json"><img src="https://img.shields.io/badge/Node.js-%3E%3D22-339933?style=flat-square&amp;logo=nodedotjs&amp;logoColor=white" alt="Node.js >=22" /></a>
@@ -39,9 +39,9 @@
39
39
  <p align="center">💬 <strong>QQ community group: 1105463028</strong></p>
40
40
 
41
41
  > [!WARNING]
42
- > 📌 **Announcement (v2.1.6)**
42
+ > 📌 **Announcement (v2.1.7)**
43
43
  >
44
- > **v2.1.6:** Advances exact stable support to DSH `0.1.5-rc.2`, fixes the remote-settings `webServer` startup error, and completes Host-first proxy isolation, lifecycle, redirect-boundary, HTTP CONNECT, and SOCKS5 hardening without raising the rc.8 Host floor. [What’s new →](docs/releases/v2.1.6.md)
44
+ > **v2.1.7:** Hardens remote/local capability boundaries, long-lived browser and persistent-cache resource ownership, DSH 0.1.5 connection/catalog compatibility, property-based fuzzing, and release provenance without raising the rc.8 Host floor. [What’s new →](docs/releases/v2.1.7.md)
45
45
 
46
46
  <p align="center">
47
47
  <img src="assets/vision-demo.gif" width="640" alt="Demo: paste an image, the agent locates the send button with vision_ground / vision_crop / vision_pixel_diff and answers with coordinates" />
package/README.zh.md CHANGED
@@ -17,7 +17,7 @@
17
17
  </p>
18
18
 
19
19
  <p align="center">
20
- <a href="https://github.com/ysr666/dsh-vision-router/releases/tag/v2.1.6"><img src="https://img.shields.io/badge/release-v2.1.6-5B4CF0?style=flat-square" alt="Release v2.1.6" /></a>
20
+ <a href="https://github.com/ysr666/dsh-vision-router/releases/tag/v2.1.7"><img src="https://img.shields.io/badge/release-v2.1.7-5B4CF0?style=flat-square" alt="Release v2.1.7" /></a>
21
21
  <a href="tests"><img src="https://img.shields.io/badge/verified-Node%2022%20%2B%2024-2EA44F?style=flat-square" alt="已验证 Node 22 + 24" /></a>
22
22
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-2EA44F?style=flat-square" alt="MIT 许可证" /></a>
23
23
  <a href="package.json"><img src="https://img.shields.io/badge/Node.js-%3E%3D22-339933?style=flat-square&amp;logo=nodedotjs&amp;logoColor=white" alt="Node.js >=22" /></a>
@@ -39,9 +39,9 @@
39
39
  <p align="center">💬 <strong>QQ 用户交流群:1105463028</strong></p>
40
40
 
41
41
  > [!WARNING]
42
- > 📌 **公告(v2.1.6)**
42
+ > 📌 **公告(v2.1.7)**
43
43
  >
44
- > **v2.1.6:正式验证 DSH `0.1.5-rc.2`,修复远程设置 `webServer` 启动报错,并完成 Host-first 代理隔离、生命周期、redirect 边界、HTTP CONNECT 与 SOCKS5 加固;公开最低 Host 仍为 rc.8。** [查看完整更新 →](docs/releases/v2.1.6.md)
44
+ > **v2.1.7:加固远程/本机能力边界、浏览器与持久缓存的长生命周期资源所有权、DSH 0.1.5 connection/catalog 兼容、property-based fuzzing 与发布溯源;公开最低 Host 仍保持 `0.1.0-rc.8`。** [查看完整更新 →](docs/releases/v2.1.7.md)
45
45
 
46
46
  <p align="center">
47
47
  <img src="assets/vision-demo.gif" width="640" alt="演示:粘贴图片,Agent 用 vision_ground / vision_crop / vision_pixel_diff 定位发送按钮并给出坐标" />
package/cordis.patch.yml CHANGED
@@ -19,6 +19,20 @@
19
19
  name: '@deepseek-ai/dsh-client-modules'
20
20
  inject: [webServer]
21
21
 
22
+ # DSH rc.6 至 0.1.5-rc.2 的 Web bundle 里,Connection provider row 只声明
23
+ # webRuntime,但 HostConnectionService.rpc/fetch 的 accessor 会把调用 owner
24
+ # 追踪回这个 provider fiber;rpc.handle() 最终又通过 owner.webServer.register()
25
+ # 挂载路由。因此仅在 DVR 的调用 child fiber 注入 webServer 仍不足以覆盖
26
+ # Cordis Service shadow 语义(issue #478 / Desktop 2.0.9 可稳定复现)。
27
+ #
28
+ # 保留官方既有 webRuntime 依赖,并让 Connection provider 本身同时等待
29
+ # webServer。这样不改 connection config / trustedHosts,不复制 RPC 协议或鉴权,
30
+ # 也不改变非 Web surface。exact-source contract 会监控上游 row;官方一旦
31
+ # 自己拥有 webServer,此 shim 必须随支持窗口审计退休,避免覆盖未来新增依赖。
32
+ - id: connection
33
+ name: '@deepseek-ai/dsh-client-connection'
34
+ inject: [webRuntime, webServer]
35
+
22
36
  # 挂载插件行。默认保持完整视觉工具表常驻(issue #81):虽然渐进挂载可以
23
37
  # 少发一小段工具 schema,但图片轮首次扩展工具列表会改变请求前缀,可能让
24
38
  # 长会话的 provider KV/prefix cache 整体失效。高级用户仍可在 profile /
@@ -0,0 +1,13 @@
1
+ # v2.1.7
2
+
3
+ DSH Vision Router 2.1.7 is a security, lifecycle, compatibility, and release-integrity patch for the 2.1.x train.
4
+
5
+ ## Highlights
6
+
7
+ - Hardens remote Settings admission and local-only capability boundaries.
8
+ - Bounds browser presentation resources and long-lived persistent-cache state.
9
+ - Adds property-based fuzzing and expands adversarial resource coverage.
10
+ - Tightens current DSH 0.1.5 compatibility around connection and live model catalog ownership.
11
+ - Hardens release provenance, exact-main verification, and GitHub Actions source policy.
12
+
13
+ The public DSH support floor remains `0.1.0-rc.8`; existing 2.1.x settings remain compatible and require no migration.
package/index.js CHANGED
@@ -1131,8 +1131,8 @@ export function apply(ctx, config = {}, runtime = {}) {
1131
1131
  // session model does not declare image input, and the DeepSeek adapter
1132
1132
  // hardcodes text-only. This wrapper route (`deepseek-vision` by default)
1133
1133
  // declares image input so the admission passes, shows up in the model
1134
- // picker as "DeepSeek + 自动识图", and delegates to the real text-provider
1135
- // adapter for anything the waterfalls did not rewrite.
1134
+ // picker as "DeepSeek + 自动识图", and delegates only to the official
1135
+ // DeepSeek adapter (or the hidden native route during stealth takeover).
1136
1136
  //
1137
1137
  // The adapter is built unconditionally; whether (and under which name) it
1138
1138
  // mounts is reconciled reactively against the resolved settings document by
@@ -1140,12 +1140,16 @@ export function apply(ctx, config = {}, runtime = {}) {
1140
1140
  // take effect without a restart.
1141
1141
  let wrapperAdapter
1142
1142
  {
1143
- const WRAPPER_MODEL_IDS = ['deepseek-v4-pro', 'deepseek-v4-flash']
1144
1143
  const wrapName = (name) => name ?? 'DeepSeek'
1145
- const textProviderRoute = () => (stealthActive ? nativeRoute : textProvider().provider)
1144
+ // The row is explicitly branded as DeepSeek, so its metadata and network
1145
+ // authority must come from DeepSeek as well. `textProvider` is legacy
1146
+ // configuration and must never let an arbitrary relay masquerade behind
1147
+ // the special wrapper. During stealth takeover old wrapper sessions keep
1148
+ // delegating to the hidden native DeepSeek route.
1149
+ const wrapperDelegateRoute = () => (stealthActive ? nativeRoute : 'deepseek-official')
1146
1150
  const delegateAdapter = () => {
1147
1151
  try {
1148
- return ctx.llm.registration(textProviderRoute()).adapter
1152
+ return ctx.llm.registration(wrapperDelegateRoute()).adapter
1149
1153
  } catch {
1150
1154
  return undefined
1151
1155
  }
@@ -1156,7 +1160,7 @@ export function apply(ctx, config = {}, runtime = {}) {
1156
1160
  },
1157
1161
  providerRetryPolicy() {
1158
1162
  try {
1159
- return ctx.llm.registration(textProviderRoute()).retryPolicy
1163
+ return ctx.llm.registration(wrapperDelegateRoute()).retryPolicy
1160
1164
  } catch {
1161
1165
  return undefined
1162
1166
  }
@@ -1167,12 +1171,12 @@ export function apply(ctx, config = {}, runtime = {}) {
1167
1171
  if (stealthActive) return []
1168
1172
  const entries = []
1169
1173
  const real = delegateAdapter()
1170
- if (real !== undefined) {
1174
+ if (real !== undefined && typeof real.listModels === 'function') {
1171
1175
  try {
1172
- const listed = await real.listModels(textProviderRoute())
1176
+ const listed = await real.listModels('deepseek-official')
1173
1177
  entries.push(
1174
- ...listed
1175
- .filter((model) => WRAPPER_MODEL_IDS.includes(model.id))
1178
+ ...(Array.isArray(listed) ? listed : [])
1179
+ .filter((model) => model && typeof model.id === 'string' && model.id !== '')
1176
1180
  .map((model) => ({
1177
1181
  ...model,
1178
1182
  provider: wrapperRoute(),
@@ -1221,14 +1225,29 @@ export function apply(ctx, config = {}, runtime = {}) {
1221
1225
  inputModalities: ['text', 'image'],
1222
1226
  }
1223
1227
  } catch {
1224
- /* fall through to the text-provider path */
1228
+ /* fall through to the official DeepSeek path */
1225
1229
  }
1226
1230
  }
1227
1231
  const real = delegateAdapter()
1228
- if (real === undefined) {
1229
- throw new Error('vision-router: the text provider adapter is not available')
1232
+ if (real === undefined || typeof real.resolveModel !== 'function') {
1233
+ throw new Error('vision-router: the official DeepSeek adapter is not available')
1230
1234
  }
1231
- const base = await real.resolveModel(textProviderRoute(), model)
1235
+ // Outside stealth mode, accept only models the live official catalog
1236
+ // actually publishes. Some adapters can resolve arbitrary ids; that is
1237
+ // not permission to expose them under the DeepSeek product identity.
1238
+ if (!stealthActive) {
1239
+ if (typeof real.listModels !== 'function') {
1240
+ throw new Error('vision-router: the official DeepSeek catalog is not available')
1241
+ }
1242
+ const listed = await real.listModels('deepseek-official')
1243
+ const admitted = Array.isArray(listed) && listed.some(
1244
+ (entry) => entry && entry.id === model,
1245
+ )
1246
+ if (!admitted) {
1247
+ throw new Error(`vision-router: DeepSeek model "${model}" is not in the live official catalog`)
1248
+ }
1249
+ }
1250
+ const base = await real.resolveModel(wrapperDelegateRoute(), model)
1232
1251
  return {
1233
1252
  ...base,
1234
1253
  provider: wrapperRoute(),
@@ -1238,7 +1257,7 @@ export function apply(ctx, config = {}, runtime = {}) {
1238
1257
  },
1239
1258
  ...createWrapperStreamBody(ctx, {
1240
1259
  imageMemory,
1241
- delegateProvider: textProviderRoute(),
1260
+ delegateProvider: wrapperDelegateRoute,
1242
1261
  instantLocal: instantLocalProvider,
1243
1262
  instantLocalStyle,
1244
1263
  instantLocalTimeoutMs: timeoutMs,
package/lib/client.js CHANGED
@@ -4252,40 +4252,257 @@ window.__ModuleLoader__.load({
4252
4252
  )
4253
4253
  }
4254
4254
 
4255
+ const PRESENTED_IMAGE_CACHE_MAX_ENTRIES = 32
4256
+ const PRESENTED_IMAGE_CACHE_MAX_BYTES = 64 * 1024 * 1024
4257
+ const PRESENTED_IMAGE_CACHE_MAX_ACTIVE_LOADS = 3
4258
+ const PRESENTED_IMAGE_CACHE_MAX_PENDING_ENTRIES = 256
4259
+
4260
+ function presentedImageResourceKey(sessionId, attachment) {
4261
+ return `${String(sessionId)}:${String(attachment && attachment.attachmentId)}`
4262
+ }
4263
+
4264
+ function createPresentedImageResourceCache(options = {}) {
4265
+ const maxEntries = Number.isFinite(options.maxEntries)
4266
+ ? Math.max(0, Math.floor(options.maxEntries))
4267
+ : PRESENTED_IMAGE_CACHE_MAX_ENTRIES
4268
+ const maxBytes = Number.isFinite(options.maxBytes)
4269
+ ? Math.max(0, Math.floor(options.maxBytes))
4270
+ : PRESENTED_IMAGE_CACHE_MAX_BYTES
4271
+ const maxActiveLoads = Number.isFinite(options.maxActiveLoads)
4272
+ ? Math.max(1, Math.floor(options.maxActiveLoads))
4273
+ : PRESENTED_IMAGE_CACHE_MAX_ACTIVE_LOADS
4274
+ const maxPendingEntries = Number.isFinite(options.maxPendingEntries)
4275
+ ? Math.max(0, Math.floor(options.maxPendingEntries))
4276
+ : PRESENTED_IMAGE_CACHE_MAX_PENDING_ENTRIES
4277
+ const nativeObjectUrls =
4278
+ typeof URL !== 'undefined' &&
4279
+ typeof URL.createObjectURL === 'function' &&
4280
+ typeof URL.revokeObjectURL === 'function' &&
4281
+ typeof Blob === 'function'
4282
+ const createObjectURL = typeof options.createObjectURL === 'function'
4283
+ ? options.createObjectURL
4284
+ : nativeObjectUrls
4285
+ ? (data, mediaType) => URL.createObjectURL(new Blob([data], { type: mediaType }))
4286
+ : undefined
4287
+ const revokeObjectURL = typeof options.revokeObjectURL === 'function'
4288
+ ? options.revokeObjectURL
4289
+ : nativeObjectUrls
4290
+ ? (url) => URL.revokeObjectURL(url)
4291
+ : undefined
4292
+ const encodeDataURL = typeof options.encodeDataURL === 'function'
4293
+ ? options.encodeDataURL
4294
+ : (data, mediaType) => {
4295
+ let binary = ''
4296
+ const chunk = 0x8000
4297
+ for (let offset = 0; offset < data.length; offset += chunk) {
4298
+ binary += String.fromCharCode(...data.subarray(offset, offset + chunk))
4299
+ }
4300
+ return `data:${mediaType};base64,${btoa(binary)}`
4301
+ }
4302
+ const useObjectUrls = typeof createObjectURL === 'function' && typeof revokeObjectURL === 'function'
4303
+ const entries = new Map()
4304
+ const loadQueue = []
4305
+ let sequence = 0
4306
+ let disposed = false
4307
+ let activeLoads = 0
4308
+ let pendingEntries = 0
4309
+
4310
+ const resourceError = (code, message) => {
4311
+ const error = new Error(message)
4312
+ error.code = code
4313
+ return error
4314
+ }
4315
+ const touch = (entry) => { entry.lastUsed = ++sequence }
4316
+ const dataBytes = (data) => {
4317
+ const value = Number(data && (data.byteLength ?? data.length))
4318
+ return Number.isFinite(value) && value > 0 ? value : 0
4319
+ }
4320
+ const remove = (entry, reason) => {
4321
+ if (!entry || entries.get(entry.key) !== entry) return false
4322
+ entries.delete(entry.key)
4323
+ if (!entry.settled) {
4324
+ pendingEntries = Math.max(0, pendingEntries - 1)
4325
+ entry.cancelled = true
4326
+ entry.cancelReason = reason ?? resourceError(
4327
+ 'VISION_PRESENT_LOAD_RELEASED',
4328
+ 'vision_present: image resource load released',
4329
+ )
4330
+ if (!entry.started && typeof entry.reject === 'function') entry.reject(entry.cancelReason)
4331
+ }
4332
+ if (entry.revocable && entry.url) {
4333
+ try { revokeObjectURL(entry.url) } catch {}
4334
+ entry.revocable = false
4335
+ }
4336
+ return true
4337
+ }
4338
+ const settledEntries = () => [...entries.values()].filter((entry) => entry.settled)
4339
+ const trim = () => {
4340
+ const settled = settledEntries()
4341
+ let count = settled.length
4342
+ let bytes = settled.reduce((total, entry) => total + entry.weight, 0)
4343
+ const victims = settled.sort((left, right) => {
4344
+ const leftOwned = left.owners.size > 0 ? 1 : 0
4345
+ const rightOwned = right.owners.size > 0 ? 1 : 0
4346
+ return leftOwned - rightOwned || left.lastUsed - right.lastUsed
4347
+ })
4348
+ for (const entry of victims) {
4349
+ if (count <= maxEntries && bytes <= maxBytes) break
4350
+ if (!remove(entry)) continue
4351
+ count -= 1
4352
+ bytes = Math.max(0, bytes - entry.weight)
4353
+ }
4354
+ }
4355
+ const pump = () => {
4356
+ if (disposed) return
4357
+ while (activeLoads < maxActiveLoads && loadQueue.length > 0) {
4358
+ const entry = loadQueue.shift()
4359
+ if (!entry || entries.get(entry.key) !== entry || entry.started || entry.cancelled) continue
4360
+ entry.started = true
4361
+ activeLoads += 1
4362
+ Promise.resolve()
4363
+ .then(() => entry.loader())
4364
+ .then(({ data, mediaType }) => {
4365
+ if (disposed || entry.cancelled || entries.get(entry.key) !== entry) {
4366
+ throw entry.cancelReason ?? resourceError(
4367
+ 'VISION_PRESENT_CACHE_DISPOSED',
4368
+ 'vision_present: image resource cache disposed',
4369
+ )
4370
+ }
4371
+ const rawBytes = dataBytes(data)
4372
+ if (useObjectUrls) {
4373
+ entry.url = createObjectURL(data, mediaType)
4374
+ entry.revocable = true
4375
+ entry.weight = rawBytes
4376
+ } else {
4377
+ entry.url = encodeDataURL(data, mediaType)
4378
+ entry.weight = entry.url.length * 2
4379
+ }
4380
+ entry.settled = true
4381
+ pendingEntries = Math.max(0, pendingEntries - 1)
4382
+ entry.loader = undefined
4383
+ touch(entry)
4384
+ trim()
4385
+ activeLoads = Math.max(0, activeLoads - 1)
4386
+ pump()
4387
+ entry.resolve(entry.url)
4388
+ })
4389
+ .catch((error) => {
4390
+ const retained = entries.get(entry.key) === entry
4391
+ if (retained) remove(entry, error)
4392
+ activeLoads = Math.max(0, activeLoads - 1)
4393
+ pump()
4394
+ entry.reject(error)
4395
+ })
4396
+ }
4397
+ }
4398
+
4399
+ const load = (keyValue, loader, ownerToken) => {
4400
+ if (disposed) {
4401
+ return Promise.reject(resourceError(
4402
+ 'VISION_PRESENT_CACHE_DISPOSED',
4403
+ 'vision_present: image resource cache disposed',
4404
+ ))
4405
+ }
4406
+ const key = String(keyValue)
4407
+ const cached = entries.get(key)
4408
+ if (cached) {
4409
+ if (ownerToken !== undefined) cached.owners.add(ownerToken)
4410
+ touch(cached)
4411
+ return cached.promise
4412
+ }
4413
+ if (pendingEntries >= maxPendingEntries) {
4414
+ return Promise.reject(resourceError(
4415
+ 'VISION_PRESENT_CACHE_BUSY',
4416
+ 'vision_present: too many image resources are waiting to load; retry when earlier images finish',
4417
+ ))
4418
+ }
4419
+ let resolvePending
4420
+ let rejectPending
4421
+ const pending = new Promise((resolve, reject) => {
4422
+ resolvePending = resolve
4423
+ rejectPending = reject
4424
+ })
4425
+ const entry = {
4426
+ key,
4427
+ owners: new Set(ownerToken === undefined ? [] : [ownerToken]),
4428
+ promise: pending,
4429
+ resolve: resolvePending,
4430
+ reject: rejectPending,
4431
+ loader,
4432
+ url: undefined,
4433
+ revocable: false,
4434
+ settled: false,
4435
+ started: false,
4436
+ cancelled: false,
4437
+ cancelReason: undefined,
4438
+ weight: 0,
4439
+ lastUsed: 0,
4440
+ }
4441
+ touch(entry)
4442
+ entries.set(key, entry)
4443
+ pendingEntries += 1
4444
+ loadQueue.push(entry)
4445
+ pump()
4446
+ return pending
4447
+ }
4448
+ const release = (keyValue, ownerToken, releaseOptions = {}) => {
4449
+ const key = String(keyValue)
4450
+ const entry = entries.get(key)
4451
+ if (!entry) return false
4452
+ if (ownerToken !== undefined) entry.owners.delete(ownerToken)
4453
+ touch(entry)
4454
+ if (releaseOptions.purge === true && entry.owners.size === 0) {
4455
+ return remove(entry)
4456
+ }
4457
+ trim()
4458
+ return true
4459
+ }
4460
+ const dispose = () => {
4461
+ if (disposed) return
4462
+ disposed = true
4463
+ const reason = resourceError(
4464
+ 'VISION_PRESENT_CACHE_DISPOSED',
4465
+ 'vision_present: image resource cache disposed',
4466
+ )
4467
+ for (const entry of [...entries.values()]) remove(entry, reason)
4468
+ loadQueue.length = 0
4469
+ entries.clear()
4470
+ }
4471
+ const stats = () => {
4472
+ const settled = settledEntries()
4473
+ const queuedLoads = [...entries.values()].filter((entry) => !entry.settled && !entry.started).length
4474
+ return {
4475
+ entries: entries.size,
4476
+ settledEntries: settled.length,
4477
+ pendingEntries,
4478
+ activeLoads,
4479
+ queuedLoads,
4480
+ residentBytes: settled.reduce((total, entry) => total + entry.weight, 0),
4481
+ ownedEntries: settled.filter((entry) => entry.owners.size > 0).length,
4482
+ disposed,
4483
+ }
4484
+ }
4485
+
4486
+ return { load, release, dispose, stats }
4487
+ }
4488
+
4255
4489
  function apply(ctx) {
4256
4490
  const localScope = ctx.settingsScope.bind({ namespace: 'vision-router' })
4257
- const presentedImageUrls = new Map()
4258
- const createdImageUrls = new Set()
4259
- const loadPresentedImage = (sessionId, attachment) => {
4260
- const key = `${String(sessionId)}:${String(attachment.attachmentId)}`
4261
- const cached = presentedImageUrls.get(key)
4262
- if (cached !== undefined) return cached
4263
- const pending = (async () => {
4491
+ const presentedImageResources = createPresentedImageResourceCache()
4492
+ const loadPresentedImage = (sessionId, attachment, ownerToken) => {
4493
+ const key = presentedImageResourceKey(sessionId, attachment)
4494
+ return presentedImageResources.load(key, async () => {
4264
4495
  const binding = ctx.sessions.binding(sessionId)
4265
4496
  if (binding === undefined) throw new Error(`vision_present: unknown session ${String(sessionId)}`)
4266
4497
  const result = await binding.session.readAttachment(attachment.attachmentId)
4267
4498
  if (!result.ok) {
4268
4499
  throw new Error(`vision_present: ${result.error.code}: ${result.error.message}`)
4269
4500
  }
4270
- const ref = result.value.attachment
4271
- const data = result.value.data
4272
- if (typeof URL.createObjectURL === 'function') {
4273
- const url = URL.createObjectURL(new Blob([data], { type: ref.mediaType }))
4274
- createdImageUrls.add(url)
4275
- return url
4276
- }
4277
- let binary = ''
4278
- const chunk = 0x8000
4279
- for (let offset = 0; offset < data.length; offset += chunk) {
4280
- binary += String.fromCharCode(...data.subarray(offset, offset + chunk))
4501
+ return {
4502
+ data: result.value.data,
4503
+ mediaType: result.value.attachment.mediaType,
4281
4504
  }
4282
- return `data:${ref.mediaType};base64,${btoa(binary)}`
4283
- })().catch((error) => {
4284
- presentedImageUrls.delete(key)
4285
- throw error
4286
- })
4287
- presentedImageUrls.set(key, pending)
4288
- return pending
4505
+ }, ownerToken)
4289
4506
  }
4290
4507
  // Follow the app language: register our dictionaries and re-read them
4291
4508
  // whenever the user switches the locale in Settings → General.
@@ -4297,6 +4514,27 @@ window.__ModuleLoader__.load({
4297
4514
  const images = content
4298
4515
  .filter((item) => item && item.type === 'image' && item.attachment)
4299
4516
  .map((item) => ({ attachment: item.attachment }))
4517
+ const imageKeys = images.map(({ attachment }) => presentedImageResourceKey(sessionId, attachment))
4518
+ const imageKeySignature = JSON.stringify(imageKeys)
4519
+ const imageOwnerToken = React.useRef({}).current
4520
+ const ownedImageKeys = React.useRef(new Set())
4521
+ React.useEffect(() => {
4522
+ const current = new Set(imageKeys)
4523
+ for (const key of ownedImageKeys.current) {
4524
+ if (current.has(key)) continue
4525
+ presentedImageResources.release(key, imageOwnerToken, { purge: true })
4526
+ ownedImageKeys.current.delete(key)
4527
+ }
4528
+ }, [imageKeySignature])
4529
+ React.useEffect(() => () => {
4530
+ for (const key of ownedImageKeys.current) presentedImageResources.release(key, imageOwnerToken, { purge: true })
4531
+ ownedImageKeys.current.clear()
4532
+ }, [])
4533
+ const loadOwnedPresentedImage = (attachment) => {
4534
+ const key = presentedImageResourceKey(sessionId, attachment)
4535
+ ownedImageKeys.current.add(key)
4536
+ return loadPresentedImage(sessionId, attachment, imageOwnerToken)
4537
+ }
4300
4538
  if (images.length === 0) {
4301
4539
  return React.createElement(
4302
4540
  'div',
@@ -4331,16 +4569,12 @@ window.__ModuleLoader__.load({
4331
4569
  images,
4332
4570
  align: 'start',
4333
4571
  labels,
4334
- load: (attachment) => loadPresentedImage(sessionId, attachment),
4572
+ load: loadOwnedPresentedImage,
4335
4573
  }),
4336
4574
  )
4337
4575
  }
4338
4576
  ctx.effect(
4339
- () => () => {
4340
- for (const url of createdImageUrls) URL.revokeObjectURL(url)
4341
- createdImageUrls.clear()
4342
- presentedImageUrls.clear()
4343
- },
4577
+ () => () => presentedImageResources.dispose(),
4344
4578
  'vision-router: presented image URL cache',
4345
4579
  )
4346
4580
  // Lazily reach the client `connection` service for the model catalog.
@@ -4449,6 +4683,12 @@ window.__ModuleLoader__.load({
4449
4683
  exports.createRemoteSettingsScope = createRemoteSettingsScope
4450
4684
  exports.shouldUseRemoteSettings = shouldUseRemoteSettings
4451
4685
  exports.installSettingsPersistence = installSettingsPersistence
4686
+ exports.PRESENTED_IMAGE_CACHE_MAX_ENTRIES = PRESENTED_IMAGE_CACHE_MAX_ENTRIES
4687
+ exports.PRESENTED_IMAGE_CACHE_MAX_BYTES = PRESENTED_IMAGE_CACHE_MAX_BYTES
4688
+ exports.PRESENTED_IMAGE_CACHE_MAX_ACTIVE_LOADS = PRESENTED_IMAGE_CACHE_MAX_ACTIVE_LOADS
4689
+ exports.PRESENTED_IMAGE_CACHE_MAX_PENDING_ENTRIES = PRESENTED_IMAGE_CACHE_MAX_PENDING_ENTRIES
4690
+ exports.presentedImageResourceKey = presentedImageResourceKey
4691
+ exports.createPresentedImageResourceCache = createPresentedImageResourceCache
4452
4692
  exports.GUIDE_STATE = GUIDE_STATE
4453
4693
  exports.GUIDE_EVENT = GUIDE_EVENT
4454
4694
  exports.guideTransition = guideTransition
@@ -2502,11 +2502,16 @@ export function createWrapperStreamBody(ctx, { imageMemory, delegateProvider, pr
2502
2502
  })
2503
2503
  return result.changed ? { ...message, content: result.content } : message
2504
2504
  })
2505
+ // Provider authority can be live (for example the main DeepSeek wrapper
2506
+ // switches to its hidden native route only after a delayed takeover).
2507
+ // Resolve it once per model call so metadata, reasoning memory and the
2508
+ // actual network dispatch all use one coherent delegate identity.
2509
+ const currentDelegateProvider = liveValue(delegateProvider)
2505
2510
  // Remember per delegate+model rather than per delegate alone: the
2506
2511
  // stream boundary carries no session id, so provider+model is the
2507
2512
  // narrowest scope available and keeps two concurrent sessions on the
2508
2513
  // same twin from sharing one memory slot.
2509
- const effortKey = `${delegateProvider}\u0000${options.model ?? ''}`
2514
+ const effortKey = `${currentDelegateProvider}\u0000${options.model ?? ''}`
2510
2515
  let effort = typeof options.reasoningEffort === 'string' && options.reasoningEffort !== ''
2511
2516
  ? options.reasoningEffort
2512
2517
  : undefined
@@ -2517,7 +2522,7 @@ export function createWrapperStreamBody(ctx, { imageMemory, delegateProvider, pr
2517
2522
  }
2518
2523
  yield* ctx.llm.stream({
2519
2524
  ...(effort === undefined ? options : { ...options, reasoningEffort: effort }),
2520
- provider: delegateProvider,
2525
+ provider: currentDelegateProvider,
2521
2526
  messages: rewritten,
2522
2527
  })
2523
2528
  },