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 +3 -3
- package/README.zh.md +3 -3
- package/cordis.patch.yml +14 -0
- package/docs/releases/v2.1.7.md +13 -0
- package/index.js +34 -15
- package/lib/client.js +271 -31
- package/lib/core-primitives.js +7 -2
- package/lib/live-model-discovery.js +125 -15
- package/lib/remote-settings-bridge.js +9 -4
- package/lib/replay-delegation.js +35 -29
- package/lib/runtime-config-normalizer.js +271 -1
- package/lib/vision-background-stop-store.js +30 -20
- package/lib/vision-capability-probe.js +24 -14
- package/lib/vision-image-input-verdict.js +22 -7
- package/lib/web-capability-boundary.js +85 -22
- package/package.json +6 -4
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.
|
|
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&logo=nodedotjs&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.
|
|
42
|
+
> 📌 **Announcement (v2.1.7)**
|
|
43
43
|
>
|
|
44
|
-
> **v2.1.
|
|
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.
|
|
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&logo=nodedotjs&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.
|
|
42
|
+
> 📌 **公告(v2.1.7)**
|
|
43
43
|
>
|
|
44
|
-
> **v2.1.
|
|
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
|
|
1135
|
-
// adapter
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
1176
|
+
const listed = await real.listModels('deepseek-official')
|
|
1173
1177
|
entries.push(
|
|
1174
|
-
...listed
|
|
1175
|
-
.filter((model) =>
|
|
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
|
|
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
|
|
1232
|
+
if (real === undefined || typeof real.resolveModel !== 'function') {
|
|
1233
|
+
throw new Error('vision-router: the official DeepSeek adapter is not available')
|
|
1230
1234
|
}
|
|
1231
|
-
|
|
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:
|
|
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
|
|
4258
|
-
const
|
|
4259
|
-
|
|
4260
|
-
|
|
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
|
-
|
|
4271
|
-
|
|
4272
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
package/lib/core-primitives.js
CHANGED
|
@@ -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 = `${
|
|
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:
|
|
2525
|
+
provider: currentDelegateProvider,
|
|
2521
2526
|
messages: rewritten,
|
|
2522
2527
|
})
|
|
2523
2528
|
},
|