dsh-vision-router 2.1.6 → 2.2.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.
Files changed (49) hide show
  1. package/README.md +8 -14
  2. package/README.zh.md +8 -14
  3. package/cordis.patch.yml +14 -0
  4. package/docs/architecture/compat-inventory.md +1 -1
  5. package/docs/architecture/dsh-compatibility-matrix.md +4 -1
  6. package/docs/architecture/dsh-support-window.md +1 -2
  7. package/docs/releases/v2.1.7.md +13 -0
  8. package/docs/releases/v2.2.0.md +25 -0
  9. package/docs/remote-settings.md +2 -0
  10. package/entry.js +2 -0
  11. package/index.js +387 -77
  12. package/lib/artifact-boundary.js +11 -0
  13. package/lib/artifact-io.js +60 -7
  14. package/lib/catalog-corrections.js +5 -0
  15. package/lib/client.js +293 -48
  16. package/lib/core-primitives.js +122 -7
  17. package/lib/degraded-local-evidence.js +39 -0
  18. package/lib/dsh-contract-compat.js +202 -0
  19. package/lib/dsh-support-window.js +1 -1
  20. package/lib/image-offload-compat.js +38 -0
  21. package/lib/live-model-discovery.js +125 -15
  22. package/lib/local-vision-stabilizer.js +15 -6
  23. package/lib/official-deepseek-catalog.js +120 -0
  24. package/lib/ollama-cold-start.js +3 -15
  25. package/lib/remote-settings-bridge.js +28 -5
  26. package/lib/replay-delegation.js +68 -42
  27. package/lib/runtime-composition.js +15 -2
  28. package/lib/runtime-config-normalizer.js +271 -1
  29. package/lib/runtime-i18n-boundary.js +4 -13
  30. package/lib/runtime-i18n.js +2 -2
  31. package/lib/session-surface-compat.js +40 -0
  32. package/lib/session-turn-resolver.js +57 -0
  33. package/lib/session-vision-index.js +206 -93
  34. package/lib/session-vision-mode-boundary.js +83 -6
  35. package/lib/session-vision-runtime.js +8 -2
  36. package/lib/session-vision-state.js +0 -13
  37. package/lib/tesseract-exec-compat.js +16 -2
  38. package/lib/twin-image-capability-fallback.js +2 -11
  39. package/lib/vision-artifact-store.js +16 -3
  40. package/lib/vision-attachment-handle-runtime.js +9 -21
  41. package/lib/vision-backend-runtime-policy.js +7 -6
  42. package/lib/vision-background-stop-store.js +30 -20
  43. package/lib/vision-breaker-shadow-health.js +15 -5
  44. package/lib/vision-capability-probe.js +24 -14
  45. package/lib/vision-evidence-guidance.js +39 -0
  46. package/lib/vision-image-input-verdict.js +22 -7
  47. package/lib/vision-resilience.js +73 -1
  48. package/lib/web-capability-boundary.js +85 -22
  49. package/package.json +14 -7
@@ -1,6 +1,8 @@
1
1
  import { createVisionArtifactStore } from './vision-artifact-store.js'
2
2
 
3
3
  export {
4
+ ARTIFACT_HANDOFF_RUN_ID,
5
+ ARTIFACT_RUNS_DIR,
4
6
  DEFAULT_ARTIFACTS_DIR,
5
7
  normalizeArtifactsDir,
6
8
  } from './artifact-io.js'
@@ -23,3 +25,12 @@ export function resolveArtifactTarget(workspace, artifactsDir, relativePath, dep
23
25
  export function writeArtifactFile(workspace, artifactsDir, relativePath, data, deps = {}) {
24
26
  return createVisionArtifactStore({ workspace, artifactsDir, deps }).publish(relativePath, data)
25
27
  }
28
+
29
+ /**
30
+ * Persistent writer for file-path handoffs such as vision_materialize. It uses
31
+ * the same store boundary but publishes under one stable reserved managed run,
32
+ * so later tools can reference it without losing retention governance.
33
+ */
34
+ export function writePersistentArtifactFile(workspace, artifactsDir, relativePath, data, deps = {}) {
35
+ return createVisionArtifactStore({ workspace, artifactsDir, deps }).publishPersistent(relativePath, data)
36
+ }
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto'
2
- import { lstat, mkdir, realpath, rename, unlink, writeFile } from 'node:fs/promises'
2
+ import { lstat, mkdir, realpath, rename, unlink, utimes, writeFile } from 'node:fs/promises'
3
3
  import path from 'node:path'
4
4
  import {
5
5
  currentVisionTurnBudget,
@@ -12,6 +12,7 @@ import {
12
12
 
13
13
  export const DEFAULT_ARTIFACTS_DIR = '.dsh-vision-router/artifacts'
14
14
  export const ARTIFACT_RUNS_DIR = '.runs'
15
+ export const ARTIFACT_HANDOFF_RUN_ID = '.vision-run-handoff'
15
16
 
16
17
  function isPathInside(root, candidate) {
17
18
  const relative = path.relative(root, candidate)
@@ -99,7 +100,7 @@ async function safeLstat(target, lstatImpl) {
99
100
  * existing symlink is rejected even when it points somewhere else inside the
100
101
  * workspace. Artifacts outside a managed run keep their original root layout.
101
102
  */
102
- export async function resolveArtifactTarget(workspace, artifactsDir, relativePath, deps = {}) {
103
+ async function resolveArtifactTargetForRun(workspace, artifactsDir, relativePath, deps = {}, runId) {
103
104
  const realpathImpl = deps.realpath ?? realpath
104
105
  const mkdirImpl = deps.mkdir ?? mkdir
105
106
  const lstatImpl = deps.lstat ?? lstat
@@ -108,7 +109,6 @@ export async function resolveArtifactTarget(workspace, artifactsDir, relativePat
108
109
  const workspaceReal = await realpathImpl(path.resolve(String(workspace ?? '')))
109
110
  throwIfVisionAborted()
110
111
  const relativeBase = normalizeArtifactsDir(artifactsDir)
111
- const runId = currentArtifactRunId()
112
112
  const requestedTarget = normalizeRelativeArtifactPath(relativePath)
113
113
  const relativeTarget = runId
114
114
  ? normalizeRelativeArtifactPath(path.join(ARTIFACT_RUNS_DIR, runId, requestedTarget))
@@ -119,6 +119,7 @@ export async function resolveArtifactTarget(workspace, artifactsDir, relativePat
119
119
  }
120
120
 
121
121
  const lexicalRunsBase = runId ? path.resolve(lexicalBase, ARTIFACT_RUNS_DIR) : undefined
122
+ const lexicalRunBase = runId ? path.resolve(lexicalRunsBase, runId) : undefined
122
123
  if (lexicalRunsBase && !isPathInside(lexicalBase, lexicalRunsBase)) {
123
124
  throw new Error('vision-router: managed artifact runs must stay inside the artifacts directory')
124
125
  }
@@ -155,6 +156,7 @@ export async function resolveArtifactTarget(workspace, artifactsDir, relativePat
155
156
  }
156
157
 
157
158
  let runsBaseReal
159
+ let runBaseReal
158
160
  if (lexicalRunsBase) {
159
161
  const after = await safeLstat(lexicalRunsBase, lstatImpl)
160
162
  throwIfVisionAborted()
@@ -166,6 +168,16 @@ export async function resolveArtifactTarget(workspace, artifactsDir, relativePat
166
168
  if (!isPathInside(artifactsBaseReal, runsBaseReal)) {
167
169
  throw new Error('vision-router: managed artifact runs directory escapes the artifacts directory')
168
170
  }
171
+ const runInfo = await safeLstat(lexicalRunBase, lstatImpl)
172
+ throwIfVisionAborted()
173
+ if (!runInfo?.isDirectory?.() || runInfo.isSymbolicLink?.()) {
174
+ throw new Error('vision-router: managed artifact run must be a real directory')
175
+ }
176
+ runBaseReal = await realpathImpl(lexicalRunBase)
177
+ throwIfVisionAborted()
178
+ if (!isPathInside(runsBaseReal, runBaseReal)) {
179
+ throw new Error('vision-router: managed artifact run escapes the runs directory')
180
+ }
169
181
  }
170
182
 
171
183
  const target = path.join(parentReal, path.basename(lexicalTarget))
@@ -174,15 +186,33 @@ export async function resolveArtifactTarget(workspace, artifactsDir, relativePat
174
186
  if (existing?.isDirectory?.()) {
175
187
  throw new Error('vision-router: artifact target is a directory')
176
188
  }
177
- return { target, parentReal, artifactsBaseReal, runsBaseReal, existing, runId }
189
+ return { target, parentReal, artifactsBaseReal, runsBaseReal, runBaseReal, existing, runId }
178
190
  }
179
191
 
180
- /** Hardened atomic publication primitive used by VisionArtifactStore. */
181
- export async function writeArtifactFile(workspace, artifactsDir, relativePath, data, deps = {}) {
192
+ export function resolveArtifactTarget(workspace, artifactsDir, relativePath, deps = {}) {
193
+ return resolveArtifactTargetForRun(
194
+ workspace, artifactsDir, relativePath, deps, currentArtifactRunId(),
195
+ )
196
+ }
197
+
198
+ /**
199
+ * Resolve a stable cross-tool handoff artifact even when the caller is inside
200
+ * a random managed per-tool run. Handoff files share one reserved managed run,
201
+ * so their path is deterministic enough for later tool calls while existing
202
+ * retention still bounds their lifetime and total bytes.
203
+ */
204
+ export function resolvePersistentArtifactTarget(workspace, artifactsDir, relativePath, deps = {}) {
205
+ return resolveArtifactTargetForRun(
206
+ workspace, artifactsDir, relativePath, deps, ARTIFACT_HANDOFF_RUN_ID,
207
+ )
208
+ }
209
+
210
+ async function writeArtifactFileForRun(workspace, artifactsDir, relativePath, data, deps, runId) {
182
211
  const writeFileImpl = deps.writeFile ?? writeFile
183
212
  const renameImpl = deps.rename ?? rename
184
213
  const unlinkImpl = deps.unlink ?? unlink
185
- const resolved = await resolveArtifactTarget(workspace, artifactsDir, relativePath, deps)
214
+ const utimesImpl = deps.utimes ?? utimes
215
+ const resolved = await resolveArtifactTargetForRun(workspace, artifactsDir, relativePath, deps, runId)
186
216
  const temp = path.join(
187
217
  resolved.parentReal,
188
218
  `.dsh-vision-router-${process.pid}-${randomUUID()}.tmp`,
@@ -202,6 +232,11 @@ export async function writeArtifactFile(workspace, artifactsDir, relativePath, d
202
232
  }
203
233
  await renameImpl(temp, resolved.target)
204
234
  published = true
235
+ if (resolved.runId === ARTIFACT_HANDOFF_RUN_ID && resolved.runBaseReal) {
236
+ const touchedAt = new Date()
237
+ await utimesImpl(resolved.runBaseReal, touchedAt, touchedAt)
238
+ throwIfVisionAborted()
239
+ }
205
240
  if (resolved.runId) {
206
241
  // New managed runs live under `.runs`, so retention scans that directory
207
242
  // directly. Also schedule the old artifact root so pre-P2-B direct
@@ -218,3 +253,21 @@ export async function writeArtifactFile(workspace, artifactsDir, relativePath, d
218
253
  }
219
254
  }
220
255
  }
256
+
257
+ /** Hardened atomic publication primitive used by VisionArtifactStore. */
258
+ export async function writeArtifactFile(workspace, artifactsDir, relativePath, data, deps = {}) {
259
+ return writeArtifactFileForRun(
260
+ workspace, artifactsDir, relativePath, data, deps, currentArtifactRunId(),
261
+ )
262
+ }
263
+
264
+ /**
265
+ * Publish a stable workspace handoff artifact under the reserved managed run
266
+ * while preserving the exact same containment, symlink, cancellation, atomic
267
+ * write and retention checks as writeArtifactFile.
268
+ */
269
+ export async function writePersistentArtifactFile(workspace, artifactsDir, relativePath, data, deps = {}) {
270
+ return writeArtifactFileForRun(
271
+ workspace, artifactsDir, relativePath, data, deps, ARTIFACT_HANDOFF_RUN_ID,
272
+ )
273
+ }
@@ -17,6 +17,7 @@ import {
17
17
  import { stripTrailingSlashes } from './string-normalization.js'
18
18
  import { currentVisionProviderTransport } from './vision-provider-transport.js'
19
19
  import { directSessionAffinityHeaders } from './session-affinity.js'
20
+ import { isOffloadedImageBlock, offloadedImagePlaceholder } from './image-offload-compat.js'
20
21
 
21
22
  export const CATALOG_ROUTING_CORRECTIONS = [
22
23
  {
@@ -109,6 +110,10 @@ async function convertBlocks(blocks, bytesOf, depth = 0) {
109
110
  if (block.type === 'text' && typeof block.text === 'string' && block.text !== '') {
110
111
  out.push({ type: 'text', text: block.text })
111
112
  } else if (block.type === 'image') {
113
+ if (isOffloadedImageBlock(block)) {
114
+ out.push({ type: 'text', text: offloadedImagePlaceholder(block) })
115
+ continue
116
+ }
112
117
  if (typeof bytesOf !== 'function') continue
113
118
  let bytes
114
119
  try {
package/lib/client.js CHANGED
@@ -231,14 +231,15 @@ window.__ModuleLoader__.load({
231
231
  '本地后端不受影响,仍在最前。关闭时保持既有顺序(付费在前、免费补全在后)。',
232
232
  hintDesktopScreenshot: '允许模型按需截取桌面屏幕用于识图。默认关闭;开启后才提供 vision_screenshot 工具。macOS 在保存开启时会立即触发一次屏幕录制授权检查;如果已经授权,系统不会重复弹窗。',
233
233
  hintStealth:
234
- '只作用于官方 DeepSeek 路由:接管后模型选择器保持原样。需在 profile 补丁层 ' +
235
- '(cordis.patch.yml)禁用官方 llm-deepseek 行后才真正接管;官方行还在时 ' +
236
- '插件自动回退为选择器里的「自动识图」包装路由。opencode 等自定义路由与隐身模式无关,' +
237
- '它们默认由「自动创建 + 自动识图模型组」处理;只有要限制范围时才用下方手动包装。改动后需重启 dsh 生效。',
234
+ '只作用于官方 DeepSeek 路由。新版 DSH 中官方 provider 始终归 Host 所有,Vision Router 只使用「+ 自动识图」' +
235
+ '包装,不再要求你禁用 llm-deepseek;旧版 Host 仍保留官方行缺失时的历史接管兼容。opencode 等自定义路由' +
236
+ '默认自动包装,只有要限制范围时才用下方手动包装。',
238
237
  stealthOfficialDeadHint:
239
- '提示:检测到官方 DeepSeek 行当前被禁用(profile 补丁层),所以即使关闭隐身模式,' +
240
- '本插件仍会接管 deepseek-official 路由,否则 DeepSeek 模型会从选择器消失。' +
241
- '如需完全恢复官方原生行,请先把 cordis.patch.yml 里 llm-deepseek 的 disabled 改回 false 再重启。',
238
+ '提示:这是旧版 Host,且官方 DeepSeek 行当前被禁用;Vision Router 正使用兼容性 keep-alive 路由。' +
239
+ '如需恢复完全官方的原生行,请在 cordis.patch.yml 中重新启用 llm-deepseek 后重启。',
240
+ stealthOfficialHostOwnedHint:
241
+ '检测到官方 DeepSeek provider 被禁用或不可用,但当前 DSH 已由 Host 负责该路由。Vision Router 不会自行重建它。' +
242
+ '请重新启用 llm-deepseek,再通过「+ 自动识图」包装 /「👁 识图」处理图片任务。',
242
243
  numTimeoutMs: '单次识图请求超时(毫秒)',
243
244
  numVisionTaskTimeoutMs: '识图任务最长时间(毫秒)',
244
245
  numOcrTimeoutMs: 'OCR 最长时间(毫秒)',
@@ -507,16 +508,16 @@ window.__ModuleLoader__.load({
507
508
  'Local backends are unaffected and stay first. When off, the existing order is preserved (paid first, free appended last).',
508
509
  hintDesktopScreenshot: 'Lets the model capture the desktop on demand for image understanding. Off by default; vision_screenshot is exposed only when enabled. On macOS, saving the enabled setting immediately triggers a screen-recording permission check; if permission was already granted, macOS will not prompt again.',
509
510
  hintStealth:
510
- 'Only affects the official DeepSeek route: takes it over while the model picker looks exactly like stock. Requires the ' +
511
- 'official llm-deepseek row to be disabled in your profile patch layer (cordis.patch.yml); while the ' +
512
- 'stock row is present the plugin falls back to the visible auto-vision wrapper entry. Custom routes like opencode ' +
513
- 'are auto-wrapped by default; use the manual wrapper section below only when you want to restrict their scope. ' +
514
- 'Restart dsh after changing stealth/profile takeover.',
511
+ 'Only affects the official DeepSeek route. On newer DSH Hosts the official provider stays Host-owned, so Vision Router ' +
512
+ 'uses the “+ Auto Vision” wrapper and never asks you to disable llm-deepseek. Legacy Hosts retain the historical ' +
513
+ 'takeover compatibility when the stock row is absent. Custom routes like opencode are auto-wrapped by default; use ' +
514
+ 'the manual wrapper section below only when you want to restrict their scope.',
515
515
  stealthOfficialDeadHint:
516
- 'Notice: the official DeepSeek row is currently disabled (profile patch layer), so this plugin keeps ' +
517
- 'serving the deepseek-official route even with stealth mode off — otherwise the DeepSeek models would ' +
518
- 'vanish from the picker. To restore the fully official route, re-enable the llm-deepseek row in ' +
519
- 'cordis.patch.yml first, then restart.',
516
+ 'Notice: on this legacy Host the official DeepSeek row is disabled, so Vision Router is using its compatibility ' +
517
+ 'keep-alive route. To restore the fully official route, re-enable llm-deepseek in cordis.patch.yml and restart.',
518
+ stealthOfficialHostOwnedHint:
519
+ 'The official DeepSeek provider is disabled or unavailable, but this DSH Host owns that provider route. Vision Router ' +
520
+ 'will not recreate it. Re-enable llm-deepseek, then use the “+ Auto Vision” wrapper / Vision control for image tasks.',
520
521
  numTimeoutMs: 'Single vision request timeout (ms)',
521
522
  numVisionTaskTimeoutMs: 'Vision task maximum time (ms)',
522
523
  numOcrTimeoutMs: 'OCR maximum time (ms)',
@@ -3913,7 +3914,11 @@ window.__ModuleLoader__.load({
3913
3914
  const stealthNotice = () => {
3914
3915
  if (testState.status !== 'done') return null
3915
3916
  const state = testState.result && testState.result.stealth
3916
- if (!state || state.active !== true || state.reason !== 'official-unavailable') return null
3917
+ if (!state) return null
3918
+ if (state.reason === 'host-owned-official-unavailable') {
3919
+ return h('p', { className: 'vr-hint vr-stealth-notice' }, t('stealthOfficialHostOwnedHint'))
3920
+ }
3921
+ if (state.active !== true || state.reason !== 'official-unavailable') return null
3917
3922
  return h('p', { className: 'vr-hint vr-stealth-notice' }, t('stealthOfficialDeadHint'))
3918
3923
  }
3919
3924
 
@@ -4252,40 +4257,257 @@ window.__ModuleLoader__.load({
4252
4257
  )
4253
4258
  }
4254
4259
 
4260
+ const PRESENTED_IMAGE_CACHE_MAX_ENTRIES = 32
4261
+ const PRESENTED_IMAGE_CACHE_MAX_BYTES = 64 * 1024 * 1024
4262
+ const PRESENTED_IMAGE_CACHE_MAX_ACTIVE_LOADS = 3
4263
+ const PRESENTED_IMAGE_CACHE_MAX_PENDING_ENTRIES = 256
4264
+
4265
+ function presentedImageResourceKey(sessionId, attachment) {
4266
+ return `${String(sessionId)}:${String(attachment && attachment.attachmentId)}`
4267
+ }
4268
+
4269
+ function createPresentedImageResourceCache(options = {}) {
4270
+ const maxEntries = Number.isFinite(options.maxEntries)
4271
+ ? Math.max(0, Math.floor(options.maxEntries))
4272
+ : PRESENTED_IMAGE_CACHE_MAX_ENTRIES
4273
+ const maxBytes = Number.isFinite(options.maxBytes)
4274
+ ? Math.max(0, Math.floor(options.maxBytes))
4275
+ : PRESENTED_IMAGE_CACHE_MAX_BYTES
4276
+ const maxActiveLoads = Number.isFinite(options.maxActiveLoads)
4277
+ ? Math.max(1, Math.floor(options.maxActiveLoads))
4278
+ : PRESENTED_IMAGE_CACHE_MAX_ACTIVE_LOADS
4279
+ const maxPendingEntries = Number.isFinite(options.maxPendingEntries)
4280
+ ? Math.max(0, Math.floor(options.maxPendingEntries))
4281
+ : PRESENTED_IMAGE_CACHE_MAX_PENDING_ENTRIES
4282
+ const nativeObjectUrls =
4283
+ typeof URL !== 'undefined' &&
4284
+ typeof URL.createObjectURL === 'function' &&
4285
+ typeof URL.revokeObjectURL === 'function' &&
4286
+ typeof Blob === 'function'
4287
+ const createObjectURL = typeof options.createObjectURL === 'function'
4288
+ ? options.createObjectURL
4289
+ : nativeObjectUrls
4290
+ ? (data, mediaType) => URL.createObjectURL(new Blob([data], { type: mediaType }))
4291
+ : undefined
4292
+ const revokeObjectURL = typeof options.revokeObjectURL === 'function'
4293
+ ? options.revokeObjectURL
4294
+ : nativeObjectUrls
4295
+ ? (url) => URL.revokeObjectURL(url)
4296
+ : undefined
4297
+ const encodeDataURL = typeof options.encodeDataURL === 'function'
4298
+ ? options.encodeDataURL
4299
+ : (data, mediaType) => {
4300
+ let binary = ''
4301
+ const chunk = 0x8000
4302
+ for (let offset = 0; offset < data.length; offset += chunk) {
4303
+ binary += String.fromCharCode(...data.subarray(offset, offset + chunk))
4304
+ }
4305
+ return `data:${mediaType};base64,${btoa(binary)}`
4306
+ }
4307
+ const useObjectUrls = typeof createObjectURL === 'function' && typeof revokeObjectURL === 'function'
4308
+ const entries = new Map()
4309
+ const loadQueue = []
4310
+ let sequence = 0
4311
+ let disposed = false
4312
+ let activeLoads = 0
4313
+ let pendingEntries = 0
4314
+
4315
+ const resourceError = (code, message) => {
4316
+ const error = new Error(message)
4317
+ error.code = code
4318
+ return error
4319
+ }
4320
+ const touch = (entry) => { entry.lastUsed = ++sequence }
4321
+ const dataBytes = (data) => {
4322
+ const value = Number(data && (data.byteLength ?? data.length))
4323
+ return Number.isFinite(value) && value > 0 ? value : 0
4324
+ }
4325
+ const remove = (entry, reason) => {
4326
+ if (!entry || entries.get(entry.key) !== entry) return false
4327
+ entries.delete(entry.key)
4328
+ if (!entry.settled) {
4329
+ pendingEntries = Math.max(0, pendingEntries - 1)
4330
+ entry.cancelled = true
4331
+ entry.cancelReason = reason ?? resourceError(
4332
+ 'VISION_PRESENT_LOAD_RELEASED',
4333
+ 'vision_present: image resource load released',
4334
+ )
4335
+ if (!entry.started && typeof entry.reject === 'function') entry.reject(entry.cancelReason)
4336
+ }
4337
+ if (entry.revocable && entry.url) {
4338
+ try { revokeObjectURL(entry.url) } catch {}
4339
+ entry.revocable = false
4340
+ }
4341
+ return true
4342
+ }
4343
+ const settledEntries = () => [...entries.values()].filter((entry) => entry.settled)
4344
+ const trim = () => {
4345
+ const settled = settledEntries()
4346
+ let count = settled.length
4347
+ let bytes = settled.reduce((total, entry) => total + entry.weight, 0)
4348
+ const victims = settled.sort((left, right) => {
4349
+ const leftOwned = left.owners.size > 0 ? 1 : 0
4350
+ const rightOwned = right.owners.size > 0 ? 1 : 0
4351
+ return leftOwned - rightOwned || left.lastUsed - right.lastUsed
4352
+ })
4353
+ for (const entry of victims) {
4354
+ if (count <= maxEntries && bytes <= maxBytes) break
4355
+ if (!remove(entry)) continue
4356
+ count -= 1
4357
+ bytes = Math.max(0, bytes - entry.weight)
4358
+ }
4359
+ }
4360
+ const pump = () => {
4361
+ if (disposed) return
4362
+ while (activeLoads < maxActiveLoads && loadQueue.length > 0) {
4363
+ const entry = loadQueue.shift()
4364
+ if (!entry || entries.get(entry.key) !== entry || entry.started || entry.cancelled) continue
4365
+ entry.started = true
4366
+ activeLoads += 1
4367
+ Promise.resolve()
4368
+ .then(() => entry.loader())
4369
+ .then(({ data, mediaType }) => {
4370
+ if (disposed || entry.cancelled || entries.get(entry.key) !== entry) {
4371
+ throw entry.cancelReason ?? resourceError(
4372
+ 'VISION_PRESENT_CACHE_DISPOSED',
4373
+ 'vision_present: image resource cache disposed',
4374
+ )
4375
+ }
4376
+ const rawBytes = dataBytes(data)
4377
+ if (useObjectUrls) {
4378
+ entry.url = createObjectURL(data, mediaType)
4379
+ entry.revocable = true
4380
+ entry.weight = rawBytes
4381
+ } else {
4382
+ entry.url = encodeDataURL(data, mediaType)
4383
+ entry.weight = entry.url.length * 2
4384
+ }
4385
+ entry.settled = true
4386
+ pendingEntries = Math.max(0, pendingEntries - 1)
4387
+ entry.loader = undefined
4388
+ touch(entry)
4389
+ trim()
4390
+ activeLoads = Math.max(0, activeLoads - 1)
4391
+ pump()
4392
+ entry.resolve(entry.url)
4393
+ })
4394
+ .catch((error) => {
4395
+ const retained = entries.get(entry.key) === entry
4396
+ if (retained) remove(entry, error)
4397
+ activeLoads = Math.max(0, activeLoads - 1)
4398
+ pump()
4399
+ entry.reject(error)
4400
+ })
4401
+ }
4402
+ }
4403
+
4404
+ const load = (keyValue, loader, ownerToken) => {
4405
+ if (disposed) {
4406
+ return Promise.reject(resourceError(
4407
+ 'VISION_PRESENT_CACHE_DISPOSED',
4408
+ 'vision_present: image resource cache disposed',
4409
+ ))
4410
+ }
4411
+ const key = String(keyValue)
4412
+ const cached = entries.get(key)
4413
+ if (cached) {
4414
+ if (ownerToken !== undefined) cached.owners.add(ownerToken)
4415
+ touch(cached)
4416
+ return cached.promise
4417
+ }
4418
+ if (pendingEntries >= maxPendingEntries) {
4419
+ return Promise.reject(resourceError(
4420
+ 'VISION_PRESENT_CACHE_BUSY',
4421
+ 'vision_present: too many image resources are waiting to load; retry when earlier images finish',
4422
+ ))
4423
+ }
4424
+ let resolvePending
4425
+ let rejectPending
4426
+ const pending = new Promise((resolve, reject) => {
4427
+ resolvePending = resolve
4428
+ rejectPending = reject
4429
+ })
4430
+ const entry = {
4431
+ key,
4432
+ owners: new Set(ownerToken === undefined ? [] : [ownerToken]),
4433
+ promise: pending,
4434
+ resolve: resolvePending,
4435
+ reject: rejectPending,
4436
+ loader,
4437
+ url: undefined,
4438
+ revocable: false,
4439
+ settled: false,
4440
+ started: false,
4441
+ cancelled: false,
4442
+ cancelReason: undefined,
4443
+ weight: 0,
4444
+ lastUsed: 0,
4445
+ }
4446
+ touch(entry)
4447
+ entries.set(key, entry)
4448
+ pendingEntries += 1
4449
+ loadQueue.push(entry)
4450
+ pump()
4451
+ return pending
4452
+ }
4453
+ const release = (keyValue, ownerToken, releaseOptions = {}) => {
4454
+ const key = String(keyValue)
4455
+ const entry = entries.get(key)
4456
+ if (!entry) return false
4457
+ if (ownerToken !== undefined) entry.owners.delete(ownerToken)
4458
+ touch(entry)
4459
+ if (releaseOptions.purge === true && entry.owners.size === 0) {
4460
+ return remove(entry)
4461
+ }
4462
+ trim()
4463
+ return true
4464
+ }
4465
+ const dispose = () => {
4466
+ if (disposed) return
4467
+ disposed = true
4468
+ const reason = resourceError(
4469
+ 'VISION_PRESENT_CACHE_DISPOSED',
4470
+ 'vision_present: image resource cache disposed',
4471
+ )
4472
+ for (const entry of [...entries.values()]) remove(entry, reason)
4473
+ loadQueue.length = 0
4474
+ entries.clear()
4475
+ }
4476
+ const stats = () => {
4477
+ const settled = settledEntries()
4478
+ const queuedLoads = [...entries.values()].filter((entry) => !entry.settled && !entry.started).length
4479
+ return {
4480
+ entries: entries.size,
4481
+ settledEntries: settled.length,
4482
+ pendingEntries,
4483
+ activeLoads,
4484
+ queuedLoads,
4485
+ residentBytes: settled.reduce((total, entry) => total + entry.weight, 0),
4486
+ ownedEntries: settled.filter((entry) => entry.owners.size > 0).length,
4487
+ disposed,
4488
+ }
4489
+ }
4490
+
4491
+ return { load, release, dispose, stats }
4492
+ }
4493
+
4255
4494
  function apply(ctx) {
4256
4495
  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 () => {
4496
+ const presentedImageResources = createPresentedImageResourceCache()
4497
+ const loadPresentedImage = (sessionId, attachment, ownerToken) => {
4498
+ const key = presentedImageResourceKey(sessionId, attachment)
4499
+ return presentedImageResources.load(key, async () => {
4264
4500
  const binding = ctx.sessions.binding(sessionId)
4265
4501
  if (binding === undefined) throw new Error(`vision_present: unknown session ${String(sessionId)}`)
4266
4502
  const result = await binding.session.readAttachment(attachment.attachmentId)
4267
4503
  if (!result.ok) {
4268
4504
  throw new Error(`vision_present: ${result.error.code}: ${result.error.message}`)
4269
4505
  }
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))
4506
+ return {
4507
+ data: result.value.data,
4508
+ mediaType: result.value.attachment.mediaType,
4281
4509
  }
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
4510
+ }, ownerToken)
4289
4511
  }
4290
4512
  // Follow the app language: register our dictionaries and re-read them
4291
4513
  // whenever the user switches the locale in Settings → General.
@@ -4297,6 +4519,27 @@ window.__ModuleLoader__.load({
4297
4519
  const images = content
4298
4520
  .filter((item) => item && item.type === 'image' && item.attachment)
4299
4521
  .map((item) => ({ attachment: item.attachment }))
4522
+ const imageKeys = images.map(({ attachment }) => presentedImageResourceKey(sessionId, attachment))
4523
+ const imageKeySignature = JSON.stringify(imageKeys)
4524
+ const imageOwnerToken = React.useRef({}).current
4525
+ const ownedImageKeys = React.useRef(new Set())
4526
+ React.useEffect(() => {
4527
+ const current = new Set(imageKeys)
4528
+ for (const key of ownedImageKeys.current) {
4529
+ if (current.has(key)) continue
4530
+ presentedImageResources.release(key, imageOwnerToken, { purge: true })
4531
+ ownedImageKeys.current.delete(key)
4532
+ }
4533
+ }, [imageKeySignature])
4534
+ React.useEffect(() => () => {
4535
+ for (const key of ownedImageKeys.current) presentedImageResources.release(key, imageOwnerToken, { purge: true })
4536
+ ownedImageKeys.current.clear()
4537
+ }, [])
4538
+ const loadOwnedPresentedImage = (attachment) => {
4539
+ const key = presentedImageResourceKey(sessionId, attachment)
4540
+ ownedImageKeys.current.add(key)
4541
+ return loadPresentedImage(sessionId, attachment, imageOwnerToken)
4542
+ }
4300
4543
  if (images.length === 0) {
4301
4544
  return React.createElement(
4302
4545
  'div',
@@ -4331,16 +4574,12 @@ window.__ModuleLoader__.load({
4331
4574
  images,
4332
4575
  align: 'start',
4333
4576
  labels,
4334
- load: (attachment) => loadPresentedImage(sessionId, attachment),
4577
+ load: loadOwnedPresentedImage,
4335
4578
  }),
4336
4579
  )
4337
4580
  }
4338
4581
  ctx.effect(
4339
- () => () => {
4340
- for (const url of createdImageUrls) URL.revokeObjectURL(url)
4341
- createdImageUrls.clear()
4342
- presentedImageUrls.clear()
4343
- },
4582
+ () => () => presentedImageResources.dispose(),
4344
4583
  'vision-router: presented image URL cache',
4345
4584
  )
4346
4585
  // Lazily reach the client `connection` service for the model catalog.
@@ -4449,6 +4688,12 @@ window.__ModuleLoader__.load({
4449
4688
  exports.createRemoteSettingsScope = createRemoteSettingsScope
4450
4689
  exports.shouldUseRemoteSettings = shouldUseRemoteSettings
4451
4690
  exports.installSettingsPersistence = installSettingsPersistence
4691
+ exports.PRESENTED_IMAGE_CACHE_MAX_ENTRIES = PRESENTED_IMAGE_CACHE_MAX_ENTRIES
4692
+ exports.PRESENTED_IMAGE_CACHE_MAX_BYTES = PRESENTED_IMAGE_CACHE_MAX_BYTES
4693
+ exports.PRESENTED_IMAGE_CACHE_MAX_ACTIVE_LOADS = PRESENTED_IMAGE_CACHE_MAX_ACTIVE_LOADS
4694
+ exports.PRESENTED_IMAGE_CACHE_MAX_PENDING_ENTRIES = PRESENTED_IMAGE_CACHE_MAX_PENDING_ENTRIES
4695
+ exports.presentedImageResourceKey = presentedImageResourceKey
4696
+ exports.createPresentedImageResourceCache = createPresentedImageResourceCache
4452
4697
  exports.GUIDE_STATE = GUIDE_STATE
4453
4698
  exports.GUIDE_EVENT = GUIDE_EVENT
4454
4699
  exports.guideTransition = guideTransition