dsh-vision-router 2.2.4 → 2.2.6
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 +26 -6
- package/README.zh.md +26 -6
- package/docs/architecture/dsh-support-window.md +6 -0
- package/docs/releases/v2.2.5.md +22 -0
- package/docs/releases/v2.2.6.md +24 -0
- package/index.js +36 -2
- package/lib/client.js +7 -1
- package/lib/core-primitives.js +35 -2
- package/lib/doctor-cli-p0.js +4 -1
- package/lib/doctor-runtime.js +12 -11
- package/lib/dsh-settings-017-compat.js +81 -24
- package/lib/http-body-limit.js +5 -1
- package/lib/local-vision-stabilizer.js +17 -4
- package/lib/remote-settings-bridge.js +7 -1
- package/lib/replay-delegation.js +1 -0
- package/lib/runtime-composition.js +3 -3
- package/lib/settings-ia-client-prelude.js +3 -3
- package/lib/settings-native-card-layout.js +1 -1
- package/lib/vision-backend-runtime-policy.js +24 -0
- package/lib/vision-routing-evidence.js +1 -0
- package/lib/web-capability-boundary.js +55 -46
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -38,10 +38,13 @@
|
|
|
38
38
|
|
|
39
39
|
<p align="center">💬 <strong>QQ community group: 1105463028</strong></p>
|
|
40
40
|
|
|
41
|
+
> [!CAUTION]
|
|
42
|
+
> **Data leaves your machine by default.** When a cloud vision model is used, Vision Router sends the image (or a derived crop), the vision prompt and related request metadata to that provider. A fresh install has an anonymous OVHcloud vision fallback enabled, so "free" and "no key" do **not** mean offline. Do not use the cloud chain for confidential, regulated or classified material. See [Data flow and strict local-only use](#data-flow-and-strict-local-only-use).
|
|
43
|
+
|
|
41
44
|
> [!WARNING]
|
|
42
|
-
> 📌 **Announcement (v2.2.
|
|
45
|
+
> 📌 **Announcement (v2.2.5)**
|
|
43
46
|
>
|
|
44
|
-
> **v2.2.
|
|
47
|
+
> **v2.2.5:** Adds fail-closed **Local-only vision** and fixes the remaining DSH `0.1.7-rc.2` Windows Desktop multi-plugin `Loading plugins…` hang by isolating Vision Router’s WebServer registrar from other plugins. The public Host floor remains `0.1.0-rc.8`. [What’s new →](docs/releases/v2.2.5.md)
|
|
45
48
|
|
|
46
49
|
<p align="center">
|
|
47
50
|
<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" />
|
|
@@ -49,6 +52,7 @@
|
|
|
49
52
|
|
|
50
53
|
## Contents
|
|
51
54
|
|
|
55
|
+
- [Data flow and strict local-only use](#data-flow-and-strict-local-only-use)
|
|
52
56
|
- [Why this exists](#why-this-exists)
|
|
53
57
|
- [How it compares](#how-it-compares)
|
|
54
58
|
- [Design lineage](#design-lineage)
|
|
@@ -62,6 +66,21 @@
|
|
|
62
66
|
- [Install and lifecycle](#install-and-lifecycle)
|
|
63
67
|
- [Troubleshooting](#troubleshooting)
|
|
64
68
|
|
|
69
|
+
## Data flow and strict local-only use
|
|
70
|
+
|
|
71
|
+
Vision Router does not operate fully offline in its default configuration. The exact boundary depends on the operation and the selected models:
|
|
72
|
+
|
|
73
|
+
| Operation | Where data goes |
|
|
74
|
+
|---|---|
|
|
75
|
+
| Local pixel tools such as crop, pixel diff, palette, SVG trace, cutout, materialize and HTML screenshot | Processed on the DSH machine; these operations do not call a vision model. |
|
|
76
|
+
| `vision_ocr` with Tesseract | Processed on the DSH machine. With the default `auto` engine, an unavailable or empty local OCR result may fall back to a vision model. |
|
|
77
|
+
| Image-turn routing and vision-model tools such as describe, detect, ground or vision OCR | The image or derived crop, prompt and relevant context are sent to the selected vision provider. |
|
|
78
|
+
| Built-in free fallback | Sent anonymously (no API key) to OVHcloud AI Endpoints at `oai.endpoints.kepler.ai.cloud.ovh.net`; the service still receives the request payload and network metadata such as the source IP. |
|
|
79
|
+
| User-configured cloud model / HTTP provider | Sent to that provider's configured endpoint under its own retention and privacy terms. |
|
|
80
|
+
| Local Ollama / LM Studio | Image pixels are sent to the configured local endpoint. The resulting text still returns to the current chat model, which may itself be remote. |
|
|
81
|
+
|
|
82
|
+
To stop **Vision Router itself** from sending image data to remote vision endpoints, enable **Settings → Vision Router → General → Local-only vision**. This is a runtime policy, not a destructive rewrite: saved cloud rows and `freeFallback` stay configured but cannot execute while the switch is on. Only loopback visual endpoints (`localhost`, `127.0.0.0/8`, `::1`) are eligible, including Ollama, LM Studio, and custom local HTTP backends. For a **strict local-only workflow**, also use a local chat model because vision result text still returns to the current chat model. Test the final network boundary in your own environment; Vision Router cannot make a remote chat model, proxy or Host integration local.
|
|
83
|
+
|
|
65
84
|
## Why this exists
|
|
66
85
|
|
|
67
86
|
Most DSH vision plugins bridge images to DeepSeek as *text descriptions* — lossy, one-shot, and blind to pixels. This plugin keeps the **Host-canonical image pixels on the vision model's side** and DeepSeek on the reasoning side, and makes looking at an image an **ordinary tool call**:
|
|
@@ -356,7 +375,8 @@ Everything is optional; defaults work out of the box. Prefer **Settings → Visi
|
|
|
356
375
|
| `tool` / `progressiveTools` / `autoActivateOnImage` | `true` / `false` / `true` | vision tools on / progressive mounting (off by default for a stable tool schema) / image-turn auto-mount when progressive mode is enabled; `progressiveTools` is boot-time config |
|
|
357
376
|
| `rewriteImages` | `true` | rewrite image blocks in the model input (cached description or tool-hint marker); the UI log keeps images |
|
|
358
377
|
| `desktopScreenshot` | `false` | privacy opt-in for the model-callable `vision_screenshot` desktop-capture tool; checked live before every capture |
|
|
359
|
-
| `
|
|
378
|
+
| `localOnlyVision` | `false` | runtime privacy policy: when enabled, only loopback vision endpoints may execute; cloud/DSH providers and the built-in OVH fallback stay saved but are fail-closed |
|
|
379
|
+
| `freeFallback` | `true` | append the anonymous OVH models after explicit local/custom HTTP backends; ignored while `localOnlyVision` is enabled |
|
|
360
380
|
| `localOllama` | `{ enabled: false, baseURL: 'http://127.0.0.1:11434/v1', model: 'qwen2.5vl', format: 'openai', maxTokens: 4096, reasoningEffort: 'none' }` | local vision backend; OpenAI mode disables supported model reasoning by default so the output budget is spent on answer text |
|
|
361
381
|
| `localLmStudio` | `{ enabled: false, baseURL: 'http://localhost:1234/v1', model: '', format: 'openai', maxTokens: 4096, reasoningEffort: 'none' }` | local LM Studio backend after Ollama; LM Studio 0.4+ can use `format: 'lmstudio'` for documented native reasoning control |
|
|
362
382
|
| `visionTurnBudgetMs` | `0` | whole-turn vision wall-clock budget; `0` means unlimited. Concrete provider calls/tools still keep their own hard deadlines |
|
|
@@ -406,17 +426,17 @@ ollama pull qwen2.5vl
|
|
|
406
426
|
|
|
407
427
|
**3. What happens**
|
|
408
428
|
|
|
409
|
-
- When enabled, `local-ollama` heads the HTTP vision chain.
|
|
429
|
+
- When enabled, `local-ollama` heads the HTTP vision chain. To hard-block remote visual egress without deleting saved cloud settings, enable **General → Local-only vision**; only loopback visual endpoints remain executable while the policy is on.
|
|
410
430
|
- The selected loopback Ollama model is prewarmed through Ollama's native API and kept resident for 30 minutes. If it is cold when Ollama is the primary image backend, loading completes before the normal vision-task budget starts; a short `/api/ps` probe keeps a dead service on the fast fallback path. Remote Ollama URLs are never auto-warmed.
|
|
411
431
|
- **LM Studio works the same way** — enable `localLmStudio` with its OpenAI-compatible endpoint (default `http://localhost:1234/v1`) and enter the exact model identifier shown in Developer or `/v1/models`. It sits after `local-ollama` and before custom/cloud HTTP backends.
|
|
412
432
|
- Local backends keep **OpenAI** as the compatibility default and can also use **Anthropic**. LM Studio additionally offers **LM Studio native** mode (`format: 'lmstudio'`, LM Studio 0.4+) at `/api/v1/chat`; use it when you need documented reasoning control (`reasoningEffort: 'none'` maps to `reasoning: off`). `maxTokens` is configurable and defaults to 4096.
|
|
413
|
-
- If a local backend is down or the call times out, its entry is skipped automatically
|
|
433
|
+
- If a local backend is down or the call times out, its entry is skipped automatically. Normally the chain can continue to cloud backends; with **Local-only vision** enabled, remote fallbacks remain blocked and the visual call fails closed instead.
|
|
414
434
|
- `vision_screenshot` is disabled by default. After the separate Desktop screenshot opt-in, `identify=true` uses the same Ollama → LM Studio fallback.
|
|
415
435
|
|
|
416
436
|
## Requirements
|
|
417
437
|
|
|
418
438
|
- DeepSeek Harness Web profile. Normal installs can use `npx @deepseek-ai/dsh ...`; source checkouts use `pnpm dsh ...`. A bare `dsh ...` command only works when the CLI is already on your shell `PATH`.
|
|
419
|
-
- **DSH Host support policy:** DVR 2.2.x keeps DSH `0.1.0-rc.8` as the public minimum and currently supports the released stable channel through `0.1.5-rc.3`.
|
|
439
|
+
- **DSH Host support policy:** DVR 2.2.x keeps DSH `0.1.0-rc.8` as the public minimum and currently supports the released stable channel through `0.1.5-rc.3`. The DSH `0.2.x` train is forward-admitted (`^0.2.0`) after exact pre-release master, real Host, Windows Desktop, multi-plugin isolation, and packaged-ASAR validation; the final immutable `0.2.0` tag and signed Desktop artifact will be revalidated when upstream publishes them. Exact `0.1.7-rc.2` (`next`) coverage remains verification evidence. DVR 2.0.x was the final train with public support for rc.6/rc.7. See [DSH Host support window](docs/architecture/dsh-support-window.md).
|
|
420
440
|
- Node ≥ 22 (host side).
|
|
421
441
|
- No API key for the default free chain; a credential reference (`apiKeyEnv`) only for paid `httpProviders`.
|
|
422
442
|
- Chrome / Chromium / Edge is needed only for `vision_html_screenshot`; every other tool works without a browser.
|
package/README.zh.md
CHANGED
|
@@ -38,10 +38,13 @@
|
|
|
38
38
|
|
|
39
39
|
<p align="center">💬 <strong>QQ 用户交流群:1105463028</strong></p>
|
|
40
40
|
|
|
41
|
+
> [!CAUTION]
|
|
42
|
+
> **默认配置会让数据出网。** 使用云端视觉模型时,Vision Router 会将图片(或由它裁剪出的局部)、识图提示词及相关请求元数据发送给对应供应商。新安装默认开启 OVHcloud 匿名视觉兜底,因此“免费”和“免 Key”不等于离线。涉密、涉案、内部或其他受限材料请勿使用云端链路。详见[数据流向与严格纯本地配置](#数据流向与严格纯本地配置)。
|
|
43
|
+
|
|
41
44
|
> [!WARNING]
|
|
42
|
-
> 📌 **公告(v2.2.
|
|
45
|
+
> 📌 **公告(v2.2.5)**
|
|
43
46
|
>
|
|
44
|
-
> **v2.2.1
|
|
47
|
+
> **v2.2.5:新增 fail-closed 的「仅本地视觉」,并根修 DSH `0.1.7-rc.2` Windows Desktop 多插件环境下残留的 `Loading plugins…` 卡死——Vision Router 的 WebServer registrar 不再污染其他插件。公开最低 Host 仍保持 `0.1.0-rc.8`。** [查看完整更新 →](docs/releases/v2.2.5.md)
|
|
45
48
|
|
|
46
49
|
<p align="center">
|
|
47
50
|
<img src="assets/vision-demo.gif" width="640" alt="演示:粘贴图片,Agent 用 vision_ground / vision_crop / vision_pixel_diff 定位发送按钮并给出坐标" />
|
|
@@ -49,6 +52,7 @@
|
|
|
49
52
|
|
|
50
53
|
## 目录
|
|
51
54
|
|
|
55
|
+
- [数据流向与严格纯本地配置](#数据流向与严格纯本地配置)
|
|
52
56
|
- [为什么做这个](#为什么做这个)
|
|
53
57
|
- [对比同类插件](#对比同类插件)
|
|
54
58
|
- [设计来源](#设计来源)
|
|
@@ -62,6 +66,21 @@
|
|
|
62
66
|
- [安装与生命周期](#安装与生命周期)
|
|
63
67
|
- [故障排查](#故障排查)
|
|
64
68
|
|
|
69
|
+
## 数据流向与严格纯本地配置
|
|
70
|
+
|
|
71
|
+
Vision Router 在默认配置下不是完全离线工具。具体边界取决于操作和当前选择的模型:
|
|
72
|
+
|
|
73
|
+
| 操作 | 数据去向 |
|
|
74
|
+
|---|---|
|
|
75
|
+
| 裁剪、像素对比、取色、SVG 矢量化、抠图、文件实体化、HTML 截图等本地像素工具 | 在 DSH 所在机器处理,这些操作不调用视觉模型。 |
|
|
76
|
+
| 使用 Tesseract 的 `vision_ocr` | 在 DSH 所在机器处理。默认 `auto` 引擎在本地 OCR 不可用或结果为空时,可能回退到视觉模型。 |
|
|
77
|
+
| 图片轮路由及 describe、detect、ground、视觉 OCR 等模型工具 | 图片或局部裁剪、提示词和相关上下文会发送给当前视觉供应商。 |
|
|
78
|
+
| 内置免费兜底 | 以匿名方式(无 API Key)发往 OVHcloud AI Endpoints `oai.endpoints.kepler.ai.cloud.ovh.net`;服务仍会收到请求内容和源 IP 等网络元数据。 |
|
|
79
|
+
| 用户配置的云模型 / HTTP 供应商 | 发送到对应配置端点,并受该供应商的保留与隐私条款约束。 |
|
|
80
|
+
| 本地 Ollama / LM Studio | 图像像素发送到配置的本地端点;识图结果文本仍会回到当前聊天模型,而聊天模型自身可能仍在云端。 |
|
|
81
|
+
|
|
82
|
+
若要阻止 **Vision Router 本身**把图片发送给远程视觉端点,请开启 **设置 → Vision Router → 常规 → 仅本地视觉**。这是运行时策略,不会破坏性修改配置:已保存的云端识图行和 `freeFallback` 会保留,但开关开启期间无法执行;只有回环地址(`localhost`、`127.0.0.0/8`、`::1`)上的视觉端点可以运行,包括 Ollama、LM Studio 和自定义本机 HTTP 后端。若要求**整个工作流严格纯本地**,还必须同时使用本地聊天模型,因为识图结果文本仍会交给当前聊天模型。请在自己的环境中验证最终网络边界;Vision Router 无法把远程聊天模型、代理或 Host 集成变成本地服务。
|
|
83
|
+
|
|
65
84
|
## 为什么做这个
|
|
66
85
|
|
|
67
86
|
大多数 DSH 视觉插件把图片“翻译”成一段文字描述再喂给 DeepSeek——有损、一次性、看不见像素。本插件把**Host 规范化后的图像像素留在视觉模型侧**、把推理留在 DeepSeek 侧,并把“看图”变成一次**普通的工具调用**:
|
|
@@ -354,7 +373,8 @@ Web profile 现在提供一级 **设置 → Vision Router** 页面。常规页
|
|
|
354
373
|
| `tool` / `progressiveTools` / `autoActivateOnImage` | `true` / `false` / `true` | 视觉工具总开关 / 渐进式挂载(默认关闭以稳定工具 schema)/ 渐进模式下图片轮自动挂载;`progressiveTools` 为启动期配置 |
|
|
355
374
|
| `rewriteImages` | `true` | 模型输入层改写图片块(缓存描述或工具提示标记);界面日志保留图片 |
|
|
356
375
|
| `desktopScreenshot` | `false` | 模型可调用的 `vision_screenshot` 桌面截屏隐私开关;每次截屏前实时检查 |
|
|
357
|
-
| `
|
|
376
|
+
| `localOnlyVision` | `false` | 运行时隐私策略:开启后只有回环地址上的视觉端点可执行;云端/DSH Provider 与内置 OVH 兜底保留配置但 fail closed |
|
|
377
|
+
| `freeFallback` | `true` | 在显式本地/自定义 HTTP 后端之后追加匿名 OVH 模型;`localOnlyVision` 开启期间不会执行 |
|
|
358
378
|
| `localOllama` | `{ enabled: false, baseURL: 'http://127.0.0.1:11434/v1', model: 'qwen2.5vl', format: 'openai', maxTokens: 4096, reasoningEffort: 'none' }` | 本地视觉后端;OpenAI 模式默认关闭受支持模型的推理,把输出预算留给正文 |
|
|
359
379
|
| `localLmStudio` | `{ enabled: false, baseURL: 'http://localhost:1234/v1', model: '', format: 'openai', maxTokens: 4096, reasoningEffort: 'none' }` | Ollama 之后的本地 LM Studio 后端;LM Studio 0.4+ 可选 `format: 'lmstudio'` 使用官方原生推理控制 |
|
|
360
380
|
| `visionTurnBudgetMs` | `0` | 整轮视觉总墙钟预算;`0` = 不设整轮上限。具体 provider调用/工具仍有自己的硬超时 |
|
|
@@ -404,17 +424,17 @@ ollama pull qwen2.5vl
|
|
|
404
424
|
|
|
405
425
|
**3. 行为说明**
|
|
406
426
|
|
|
407
|
-
- 开启后 `local-ollama` 排在 HTTP
|
|
427
|
+
- 开启后 `local-ollama` 排在 HTTP 视觉链前部。若要在不删除已保存云端配置的情况下硬阻止远程视觉出网,请开启 **常规 → 仅本地视觉**;策略开启期间只有回环地址上的视觉端点可执行。
|
|
408
428
|
- 选中的本机 loopback Ollama 模型会通过原生 API 预热并保持 30 分钟驻留。如果模型在 Ollama 作为首个图片后端时已经冷却,加载会在正常视觉任务预算开始之前完成;短 `/api/ps` 探测保证服务未运行/挂死时仍快速进入 fallback。远程 Ollama URL 不会自动预热。
|
|
409
429
|
- **LM Studio 同理**——开启 `localLmStudio`,填 OpenAI 兼容端点(默认 `http://localhost:1234/v1`),并使用 Developer 页或 `/v1/models` 返回的真实模型标识。它排在 `local-ollama` 之后、自定义/云 HTTP 后端之前。
|
|
410
430
|
- 本地后端继续以 **OpenAI** 为兼容默认,也可选 **Anthropic**。LM Studio 额外提供 **LM Studio 原生**模式(`format: 'lmstudio'`,需 LM Studio 0.4+),走 `/api/v1/chat`;需要稳定关闭推理时推荐该模式,因为官方 API 明确支持 `reasoning: off`。`maxTokens` 可配置,默认 4096。
|
|
411
|
-
-
|
|
431
|
+
- 任一本地后端未运行或调用超时时自动跳过。正常模式下可继续降级到云链;开启 **仅本地视觉** 后,所有远程兜底继续保持阻断,视觉调用会 fail closed。
|
|
412
432
|
- `vision_screenshot` 默认关闭。单独开启「桌面截屏」隐私开关后,`identify=true` 使用同样的 Ollama → LM Studio 降级顺序。
|
|
413
433
|
|
|
414
434
|
## 环境要求
|
|
415
435
|
|
|
416
436
|
- DeepSeek Harness 的 Web profile。普通安装可用 `npx @deepseek-ai/dsh ...`;从源码仓库运行时用 `pnpm dsh ...`。只有 CLI 已经进入系统 `PATH` 时才能直接写 `dsh ...`。
|
|
417
|
-
- **DSH Host 支持策略:** DVR 2.2.x 的公开最低 Host 仍为 DSH `0.1.0-rc.8`,当前正式发布通道已验证并支持到 `0.1.5-rc.3
|
|
437
|
+
- **DSH Host 支持策略:** DVR 2.2.x 的公开最低 Host 仍为 DSH `0.1.0-rc.8`,当前正式发布通道已验证并支持到 `0.1.5-rc.3`。DSH `0.2.x` 版本线已在完成精确预发布 master、真实 Host、Windows Desktop、多插件隔离及打包后 ASAR 验证后提前准入(`^0.2.0`);上游正式发布不可变 `0.2.0` tag 和签名 Desktop 安装包后仍会立即复验。对 `0.1.7-rc.2`(`next`)的精确覆盖继续作为验证证据。DVR 2.0.x 是最后公开支持 rc.6/rc.7 的版本线。详见 [DSH Host 支持窗口](docs/architecture/dsh-support-window.md)。
|
|
418
438
|
- Node ≥ 22(宿主侧)。
|
|
419
439
|
- 默认免费链路无需 API Key;付费 `httpProviders` 只需一个凭据引用(`apiKeyEnv`)。
|
|
420
440
|
- 只有 `vision_html_screenshot` 需要 Chrome / Chromium / Edge;其余工具无浏览器也能用。
|
|
@@ -17,6 +17,12 @@ DSH `0.1.5-rc.1` / `0.1.5-rc.2` remain admitted for existing installations; curr
|
|
|
17
17
|
|
|
18
18
|
No later support-floor increase is currently announced.
|
|
19
19
|
|
|
20
|
+
### DSH 0.2.x forward admission
|
|
21
|
+
|
|
22
|
+
DVR 2.2.x peer-admits the DSH `0.2.x` train (`^0.2.0`) without raising the `0.1.0-rc.8` minimum Host floor. This forward admission is backed by the public pre-0.2.0 master at `21638c56315ae6a2b552d6091945d3144c9af32e`: three-OS source contracts, real Host + Chromium, Windows Node 22/24 Desktop authentication, the Node 24 multi-plugin isolation adversary, and an unsigned Windows Desktop built through the upstream release packager all passed. The packaged audit also exercised DVR from `resources/app.asar/dsh`, including authenticated index/API requests, DVR RPC, structured bootstrap injection, and foreign WebServer registrar isolation.
|
|
23
|
+
|
|
24
|
+
The immutable upstream `0.2.0` tag and signed official Desktop installer did not yet exist when this admission was added. They remain release-time revalidation targets; this forward admission prevents DSH's Host peer gate from rejecting otherwise-compatible DVR installs during that release transition.
|
|
25
|
+
|
|
20
26
|
## Verification evidence — not support policy
|
|
21
27
|
|
|
22
28
|
Compatibility evidence answers a different question: what exact upstream releases and moving channels have current CI proof? It must never be interpreted as a public support-floor change.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# v2.2.5
|
|
2
|
+
|
|
3
|
+
DSH Vision Router 2.2.5 is a privacy and Desktop multi-plugin isolation update for the DSH 0.1.7 line.
|
|
4
|
+
|
|
5
|
+
## Highlights
|
|
6
|
+
|
|
7
|
+
- Adds **Local-only vision / 仅本地视觉**. When enabled from the local Settings page, Vision Router permits only loopback visual endpoints (`localhost`, `127.0.0.0/8`, `::1`) and fails closed against configured cloud/DSH vision providers and the built-in anonymous OVHcloud fallback. Saved cloud configuration is preserved, and disabling the policy restores the previous chain. This policy covers Vision Router visual egress only; a remote chat model can still receive the resulting text.
|
|
8
|
+
- Fixes the remaining `dsh-vision-router@2.2.4` Windows Desktop multi-plugin startup hang reported in #551. The local/auth route boundary no longer leaves a replacement `webServer.register` on Cordis's shared WebServer service for the DVR fiber lifetime. DVR now shadows only its own injected child-context WebServer view, so unrelated plugins keep the original registrar while DVR-owned routes retain the same Host-auth/local capability checks.
|
|
9
|
+
- Strengthens the exact DSH `0.1.7-rc.2` Windows Desktop gate: it now exercises launch-token → cookie authentication, authenticated index and `/api` requests, DVR RPC, Node 22/24, and six deterministic Node 24 multi-plugin load orders with a foreign WebServer registrar observer.
|
|
10
|
+
- Keeps the public minimum supported DSH Host at `0.1.0-rc.8`; no settings migration is required.
|
|
11
|
+
|
|
12
|
+
## Validation
|
|
13
|
+
|
|
14
|
+
- Exact DSH `0.1.7-rc.2@477b4f420553e8a52c2fbccc464d7561b239c443` Windows Desktop Host passes on Node `22.19.0` and `24.21.0`.
|
|
15
|
+
- The Node 24 multi-plugin adversary passes all six DVR / foreign-RPC / connection-waterfall load orders; the foreign WebServer observer sees no leaked own `register` override.
|
|
16
|
+
- Real Host + Chromium passes bundle recompose, Desktop structured bootstrap, cold Vision toggle, mixed attachment lifecycle, and Settings mount/save/reload/readback.
|
|
17
|
+
- CI, DSH Contract, adversarial compatibility, HOL plugin security, dependency review, and native multimodal cold-resume gates pass on the regression PR.
|
|
18
|
+
- Local release-prep suite: `1498` passed, `1` intentional skip, `0` failed; the separate backend runtime-policy suite passed `9/9`.
|
|
19
|
+
|
|
20
|
+
## Upgrade
|
|
21
|
+
|
|
22
|
+
Users on DSH `0.1.7-rc.2` Desktop, especially those who still see `Loading plugins…` with Vision Router 2.2.4 in multi-plugin profiles, should upgrade to 2.2.5. The reporter's original issue remains open until a post-release field retest confirms the fix on the affected machine.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# v2.2.6
|
|
2
|
+
|
|
3
|
+
DSH Vision Router 2.2.6 is a DSH 0.2 compatibility and runtime-hardening update. It admits the validated DSH 0.2 release train without raising the public Host floor, reduces Settings startup amplification, adds the LM Studio native chat route, and bounds Doctor probe responses while streaming.
|
|
4
|
+
|
|
5
|
+
## Highlights
|
|
6
|
+
|
|
7
|
+
- **DSH 0.2 release-train compatibility:** both official DSH peers now admit `>=0.2.0-rc.1 <0.3.0-0`. This covers validated `0.2.0-rc.1` and later 0.2.x prerelease/stable versions without requiring a peer edit for every tag, while every 0.3 prerelease/stable remains excluded until a separate compatibility review.
|
|
8
|
+
- **Permanent DSH `0.2.0-rc.1` gates:** exact-source Windows/macOS/Linux checks, real Host + Chromium lifecycle coverage, Windows Desktop Node 22/24 authenticated-request checks, Node 24 multi-plugin ordering, and stable/next/preview release-channel canaries are now part of CI.
|
|
9
|
+
- **Settings startup amplification root fix (#577):** the DSH 0.1.7 Settings compatibility layer reuses a stable shared service/cache/listener/mutation queue behind the per-caller facade instead of multiplying full-profile recomposition work across child contexts. Exact `0.1.7-rc.2` multi-plugin measurements dropped from 59 configuration calls to roughly 8–10 in the tested profile. This addresses the second amplification mechanism found while investigating #551; the reporter's field A/B remains open and is not treated as closed by this release.
|
|
10
|
+
- **LM Studio native route (#579):** native LM Studio requests use `/api/v1/chat`, and the plugin maps disabled reasoning to the native `reasoning: "off"` behavior instead of sending an OpenAI-compatible-only field.
|
|
11
|
+
- **Doctor response hardening (#580):** runtime probes enforce the response ceiling while streaming and cancel oversized responses instead of buffering beyond the diagnostic boundary.
|
|
12
|
+
- Keeps the public minimum supported DSH Host at `0.1.0-rc.8`; no settings migration is required.
|
|
13
|
+
|
|
14
|
+
## Validation
|
|
15
|
+
|
|
16
|
+
- Exact DSH `0.2.0-rc.1@4878cdabd87d4041bdaff61d04c966883b9fd07a` passes Ubuntu/macOS/Windows source contracts, official Host peer evaluation, proxy/overlay contracts, real Host + Chromium Vision/mixed-attachment/Settings lifecycles, Windows Desktop Node 22/24, and the Node 24 multi-plugin adversary.
|
|
17
|
+
- The retained DSH `0.1.7-rc.2` real-Host/browser regression gates remain green.
|
|
18
|
+
- A physical macOS run using the published npm runtime `@deepseek-ai/dsh@0.2.0-rc.1` passed plugin admission, authentication boundaries, Settings save/reload/readback, enabled Vision composer flow, mixed attachment paste/upload, and three consecutive Host restarts with persisted settings and no compatibility-disable/uncaught/fatal signatures.
|
|
19
|
+
- Main CI, DSH Contract, Adversarial Fuzz, routing parity, dependency review, preview browser smoke, native multimodal cold-resume, and the DSH release-channel canary passed on the final compatibility head.
|
|
20
|
+
- Local release-prep suite: `1507` passed, `1` intentional skip, `0` failed; the separate backend runtime-policy suite passed `9/9`.
|
|
21
|
+
|
|
22
|
+
## Upgrade
|
|
23
|
+
|
|
24
|
+
Users moving to DSH `0.2.0-rc.1` should upgrade to Vision Router 2.2.6 so the Host compatibility preflight accepts the plugin. Existing supported 0.1.x users can also upgrade in place. Issue #551 remains open for the reporter's original `0.1.7-rc.2` 18-plugin field A/B and is tracked independently from this release.
|
package/index.js
CHANGED
|
@@ -258,6 +258,12 @@ export const Config = z.object({
|
|
|
258
258
|
// trusted-host transport fence. Only a loopback/local settings page may
|
|
259
259
|
// change this permission; the remote bridge rejects writes to the field.
|
|
260
260
|
allowRemoteSettings: z.boolean().default(false),
|
|
261
|
+
// Privacy policy switch: when enabled, Vision Router executes image-model
|
|
262
|
+
// requests only against loopback visual endpoints (Ollama, LM Studio, or
|
|
263
|
+
// custom local HTTP). Saved cloud
|
|
264
|
+
// rows remain intact but inactive, so disabling the policy restores the
|
|
265
|
+
// previous routing chain without destructive settings rewrites.
|
|
266
|
+
localOnlyVision: z.boolean().default(false),
|
|
261
267
|
freeFallback: z.boolean().default(true),
|
|
262
268
|
// 云端免费优先:开启后,云端后端先尝试内置 OVH 免费模型(免注册、免
|
|
263
269
|
// API Key),付费 httpProviders 仅在免费模型全部失败后作为兜底,尽量把
|
|
@@ -421,6 +427,8 @@ import {
|
|
|
421
427
|
DEFAULT_HTTP_PROVIDERS,
|
|
422
428
|
httpProviderFallbackWeight,
|
|
423
429
|
weightedFallbackBudget,
|
|
430
|
+
localOnlyVisionEnabled,
|
|
431
|
+
isLoopbackVisionBaseURL,
|
|
424
432
|
localOllamaProvidersOf,
|
|
425
433
|
localLmStudioProvidersOf,
|
|
426
434
|
localProvidersOf,
|
|
@@ -518,6 +526,8 @@ export {
|
|
|
518
526
|
DEFAULT_HTTP_PROVIDERS,
|
|
519
527
|
httpProviderFallbackWeight,
|
|
520
528
|
weightedFallbackBudget,
|
|
529
|
+
localOnlyVisionEnabled,
|
|
530
|
+
isLoopbackVisionBaseURL,
|
|
521
531
|
localOllamaProvidersOf,
|
|
522
532
|
localLmStudioProvidersOf,
|
|
523
533
|
localProvidersOf,
|
|
@@ -1154,8 +1164,9 @@ export function apply(ctx, config = {}, runtime = {}) {
|
|
|
1154
1164
|
// Local backends (format=anthropic etc.) go through their own
|
|
1155
1165
|
// dispatcher; regular HTTP providers keep the plain OpenAI call.
|
|
1156
1166
|
// Both consume the same pre-built OpenAI content blocks (image_url
|
|
1157
|
-
// data URIs); callLocalBackend converts them for the Anthropic
|
|
1158
|
-
|
|
1167
|
+
// data URIs); callLocalBackend converts them for the Anthropic and
|
|
1168
|
+
// LM Studio native wires.
|
|
1169
|
+
text = await ((entry.provider.format === 'anthropic' || entry.provider.format === 'lmstudio')
|
|
1159
1170
|
? callLocalBackend(entry.provider, openAIMessages, {
|
|
1160
1171
|
maxTokens: entry.provider.maxTokens ?? 4096,
|
|
1161
1172
|
signal: options.signal,
|
|
@@ -2003,6 +2014,13 @@ export function apply(ctx, config = {}, runtime = {}) {
|
|
|
2003
2014
|
add(HTTP_ROUTE, `${provider.name}/${provider.model}`)
|
|
2004
2015
|
}
|
|
2005
2016
|
|
|
2017
|
+
// Local-only vision is an execution policy, not a destructive settings
|
|
2018
|
+
// rewrite. Saved native/cloud rows stay visible for later restoration, but
|
|
2019
|
+
// they cannot participate in tool auto-discovery while the policy is on.
|
|
2020
|
+
if (localOnlyVisionEnabled(current())) {
|
|
2021
|
+
return applyVisionExecutionOrder(out, currentVisionExecutionOrder())
|
|
2022
|
+
}
|
|
2023
|
+
|
|
2006
2024
|
const capabilities = await collectVisionBackendCapabilities()
|
|
2007
2025
|
for (const [provider, models] of Object.entries(capabilities)) {
|
|
2008
2026
|
if (provider === HTTP_ROUTE || ownRoutes().has(provider)) continue
|
|
@@ -2975,6 +2993,10 @@ export function apply(ctx, config = {}, runtime = {}) {
|
|
|
2975
2993
|
)
|
|
2976
2994
|
|
|
2977
2995
|
for (const pair of usablePairs) {
|
|
2996
|
+
if (localOnlyVisionEnabled(current()) && !isLocalBackendPair(pair)) {
|
|
2997
|
+
errors.push(`${pair.provider}/${pair.model}: skipped (local-only vision policy)`)
|
|
2998
|
+
continue
|
|
2999
|
+
}
|
|
2978
3000
|
const candidateWeight = primaryWeight
|
|
2979
3001
|
const weightAtStart = Math.max(candidateWeight, remainingWeight)
|
|
2980
3002
|
remainingWeight = Math.max(0, remainingWeight - candidateWeight)
|
|
@@ -3106,6 +3128,10 @@ ctx.logger?.info(
|
|
|
3106
3128
|
// final fallbacks: they bypass the harness llm service entirely, so the
|
|
3107
3129
|
// anonymous free endpoint works without any credential.
|
|
3108
3130
|
for (const provider of httpFallbacks) {
|
|
3131
|
+
if (localOnlyVisionEnabled(current()) && !isLoopbackVisionBaseURL(provider?.baseURL)) {
|
|
3132
|
+
errors.push(`http:${provider?.name}/${provider?.model}: skipped (local-only vision policy)`)
|
|
3133
|
+
continue
|
|
3134
|
+
}
|
|
3109
3135
|
const candidateWeight = httpProviderFallbackWeight(provider)
|
|
3110
3136
|
const weightAtStart = Math.max(candidateWeight, remainingWeight)
|
|
3111
3137
|
remainingWeight = Math.max(0, remainingWeight - candidateWeight)
|
|
@@ -3611,6 +3637,10 @@ ctx.logger?.info(
|
|
|
3611
3637
|
errors.push(`${backendKey}: ${message}`)
|
|
3612
3638
|
}
|
|
3613
3639
|
for (const pair of usablePairs) {
|
|
3640
|
+
if (localOnlyVisionEnabled(current()) && !isLocalBackendPair(pair)) {
|
|
3641
|
+
errors.push(`${pair.provider}/${pair.model}: skipped (local-only vision policy)`)
|
|
3642
|
+
continue
|
|
3643
|
+
}
|
|
3614
3644
|
const candidateWeight = primaryWeight
|
|
3615
3645
|
const weightAtStart = Math.max(candidateWeight, remainingWeight)
|
|
3616
3646
|
remainingWeight = Math.max(0, remainingWeight - candidateWeight)
|
|
@@ -3671,6 +3701,10 @@ ctx.logger?.info(
|
|
|
3671
3701
|
}
|
|
3672
3702
|
const httpContent = toOpenAIContent([block], () => imageBytes)
|
|
3673
3703
|
for (const provider of httpFallbacks) {
|
|
3704
|
+
if (localOnlyVisionEnabled(current()) && !isLoopbackVisionBaseURL(provider?.baseURL)) {
|
|
3705
|
+
errors.push(`http:${provider?.name}/${provider?.model}: skipped (local-only vision policy)`)
|
|
3706
|
+
continue
|
|
3707
|
+
}
|
|
3674
3708
|
const candidateWeight = httpProviderFallbackWeight(provider)
|
|
3675
3709
|
const weightAtStart = Math.max(candidateWeight, remainingWeight)
|
|
3676
3710
|
remainingWeight = Math.max(0, remainingWeight - candidateWeight)
|
package/lib/client.js
CHANGED
|
@@ -215,6 +215,7 @@ window.__ModuleLoader__.load({
|
|
|
215
215
|
toggleRewriteImages: '保护纯文字模型',
|
|
216
216
|
toggleDownscale: '图片自动压缩',
|
|
217
217
|
toggleCache: '识图答案缓存',
|
|
218
|
+
toggleLocalOnlyVision: '仅本地视觉',
|
|
218
219
|
toggleFreeFallback: '内置免费兜底',
|
|
219
220
|
toggleFreeCloudFirst: '云端免费优先',
|
|
220
221
|
toggleDesktopScreenshot: '桌面截图识图',
|
|
@@ -229,6 +230,7 @@ window.__ModuleLoader__.load({
|
|
|
229
230
|
hintRewriteImages: '避免把无法读取的原始图片直接发送给纯文字模型。推荐保持开启。',
|
|
230
231
|
hintDownscale: '超过像素预算的图片先缩放再送视觉模型,降低延迟与成本;默认开启。',
|
|
231
232
|
hintCache: '缓存识图答案(按图片内容 + 问题);默认开启。',
|
|
233
|
+
hintLocalOnlyVision: '开启后仅允许回环地址上的本地视觉后端;云端/DSH Provider 与 OVH 免费兜底会被运行时硬阻止。主聊天模型是否联网不受此开关控制。',
|
|
232
234
|
hintFreeFallback: '当你选择的识图模型都不可用时,自动尝试内置 OVH 免费模型。免注册、免 API Key。',
|
|
233
235
|
hintFreeCloudFirst:
|
|
234
236
|
'默认关闭。开启后,云端后端先尝试内置 OVH 免费模型(免注册、免 API Key),' +
|
|
@@ -501,6 +503,7 @@ window.__ModuleLoader__.load({
|
|
|
501
503
|
toggleRewriteImages: 'Protect text-only models',
|
|
502
504
|
toggleDownscale: 'Auto downscale',
|
|
503
505
|
toggleCache: 'Vision answer cache',
|
|
506
|
+
toggleLocalOnlyVision: 'Local-only vision',
|
|
504
507
|
toggleFreeFallback: 'Built-in free fallback',
|
|
505
508
|
toggleFreeCloudFirst: 'Free cloud first',
|
|
506
509
|
toggleDesktopScreenshot: 'Desktop screenshot vision',
|
|
@@ -516,6 +519,7 @@ window.__ModuleLoader__.load({
|
|
|
516
519
|
hintRewriteImages: 'Prevents raw image content from being sent to a text-only model that cannot read it. Recommended on.',
|
|
517
520
|
hintDownscale: 'Images beyond the pixel budget are resized before the vision call, cutting latency and cost; on by default.',
|
|
518
521
|
hintCache: 'Caches vision answers (keyed by image content + question); on by default.',
|
|
522
|
+
hintLocalOnlyVision: 'When enabled, only visual backends on loopback addresses may run. Cloud/DSH providers and the OVH free fallback are blocked at runtime. This does not control whether the main chat model is remote.',
|
|
519
523
|
hintFreeFallback: 'If your selected vision models fail, automatically try the built-in OVH free models. No signup or API key required.',
|
|
520
524
|
hintFreeCloudFirst:
|
|
521
525
|
'Off by default. When enabled, cloud backends try the built-in OVH free models first (no signup, no API key), ' +
|
|
@@ -718,7 +722,7 @@ window.__ModuleLoader__.load({
|
|
|
718
722
|
const COST_TOGGLE_KEYS = ['freeCloudFirst']
|
|
719
723
|
const DEVELOPER_TOGGLE_KEYS = ['stealth']
|
|
720
724
|
const LOCAL_TOGGLE_KEYS = ['instantDescribe']
|
|
721
|
-
const PRIVACY_TOGGLE_KEYS = ['desktopScreenshot']
|
|
725
|
+
const PRIVACY_TOGGLE_KEYS = ['localOnlyVision', 'desktopScreenshot']
|
|
722
726
|
const ADVANCED_TOGGLE_KEYS = [...PERFORMANCE_TOGGLE_KEYS, ...COMPATIBILITY_TOGGLE_KEYS, ...COST_TOGGLE_KEYS, ...DEVELOPER_TOGGLE_KEYS]
|
|
723
727
|
const ALL_TOGGLE_KEYS = [...TOGGLE_KEYS, ...ADVANCED_TOGGLE_KEYS, ...LOCAL_TOGGLE_KEYS, ...PRIVACY_TOGGLE_KEYS]
|
|
724
728
|
const NUMBER_KEYS = ['timeoutMs', 'visionTaskTimeoutMs', 'ocrTimeoutMs', 'downscaleMaxPixels', 'cacheTtlSeconds', 'cacheMaxEntries']
|
|
@@ -2405,6 +2409,7 @@ window.__ModuleLoader__.load({
|
|
|
2405
2409
|
rewriteImages: 'toggleRewriteImages',
|
|
2406
2410
|
downscale: 'toggleDownscale',
|
|
2407
2411
|
cache: 'toggleCache',
|
|
2412
|
+
localOnlyVision: 'toggleLocalOnlyVision',
|
|
2408
2413
|
freeFallback: 'toggleFreeFallback',
|
|
2409
2414
|
freeCloudFirst: 'toggleFreeCloudFirst',
|
|
2410
2415
|
desktopScreenshot: 'toggleDesktopScreenshot',
|
|
@@ -2434,6 +2439,7 @@ window.__ModuleLoader__.load({
|
|
|
2434
2439
|
rewriteImages: 'hintRewriteImages',
|
|
2435
2440
|
downscale: 'hintDownscale',
|
|
2436
2441
|
cache: 'hintCache',
|
|
2442
|
+
localOnlyVision: 'hintLocalOnlyVision',
|
|
2437
2443
|
freeFallback: 'hintFreeFallback',
|
|
2438
2444
|
freeCloudFirst: 'hintFreeCloudFirst',
|
|
2439
2445
|
desktopScreenshot: 'hintDesktopScreenshot',
|
package/lib/core-primitives.js
CHANGED
|
@@ -205,8 +205,35 @@ export function eventHasImage(event) {
|
|
|
205
205
|
return false
|
|
206
206
|
}
|
|
207
207
|
|
|
208
|
+
export function localOnlyVisionEnabled(config = {}) {
|
|
209
|
+
return !!config && config.localOnlyVision === true
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function isLoopbackVisionBaseURL(value) {
|
|
213
|
+
try {
|
|
214
|
+
const url = new URL(String(value ?? ''))
|
|
215
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') return false
|
|
216
|
+
const host = String(url.hostname ?? '').toLowerCase().replace(/^\[|\]$/g, '')
|
|
217
|
+
if (host === 'localhost' || host === '::1') return true
|
|
218
|
+
const parts = host.split('.')
|
|
219
|
+
return parts.length === 4 && parts[0] === '127' && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255)
|
|
220
|
+
} catch {
|
|
221
|
+
return false
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
208
225
|
/** Flatten the single-provider shorthand and the multi-provider form into one ordered chain. */
|
|
209
226
|
export function providersOf(config = {}) {
|
|
227
|
+
const localOnly = localOnlyVisionEnabled(config)
|
|
228
|
+
const localHttpModels = localOnly
|
|
229
|
+
? new Set([
|
|
230
|
+
...localProvidersOf(config),
|
|
231
|
+
...httpProvidersOf(config, false),
|
|
232
|
+
].map((provider) => `${provider.name}/${provider.model}`))
|
|
233
|
+
: undefined
|
|
234
|
+
const allowed = (pair) =>
|
|
235
|
+
!localOnly ||
|
|
236
|
+
(pair && pair.provider === 'vision-http' && localHttpModels.has(pair.model))
|
|
210
237
|
const list = []
|
|
211
238
|
if (Array.isArray(config.providers)) {
|
|
212
239
|
for (const entry of config.providers) {
|
|
@@ -219,7 +246,7 @@ export function providersOf(config = {}) {
|
|
|
219
246
|
}
|
|
220
247
|
}
|
|
221
248
|
}
|
|
222
|
-
if (list.length > 0) return list
|
|
249
|
+
if (list.length > 0) return list.filter(allowed)
|
|
223
250
|
const provider =
|
|
224
251
|
typeof config.provider === 'string' && config.provider !== '' ? config.provider : 'vision-http'
|
|
225
252
|
const models = []
|
|
@@ -228,7 +255,7 @@ export function providersOf(config = {}) {
|
|
|
228
255
|
if (typeof fallback === 'string' && fallback !== '') models.push(fallback)
|
|
229
256
|
}
|
|
230
257
|
if (models.length === 0) models.push('ovh/Qwen3.5-397B-A17B')
|
|
231
|
-
return models.map((model) => ({ provider, model }))
|
|
258
|
+
return models.map((model) => ({ provider, model })).filter(allowed)
|
|
232
259
|
}
|
|
233
260
|
|
|
234
261
|
const FAILURE_ADVICE = {
|
|
@@ -1852,6 +1879,7 @@ export function localOllamaProvidersOf(config) {
|
|
|
1852
1879
|
typeof local.baseURL === 'string' && local.baseURL !== '' ? local.baseURL : 'http://127.0.0.1:11434/v1'
|
|
1853
1880
|
const model =
|
|
1854
1881
|
typeof local.model === 'string' && local.model !== '' ? local.model : 'qwen2.5vl'
|
|
1882
|
+
if (localOnlyVisionEnabled(config) && !isLoopbackVisionBaseURL(baseURL)) return []
|
|
1855
1883
|
return [
|
|
1856
1884
|
{
|
|
1857
1885
|
name: 'local-ollama',
|
|
@@ -1888,6 +1916,7 @@ export function localLmStudioProvidersOf(config) {
|
|
|
1888
1916
|
// 不注册一个注定 model_not_found 的后端;设置页会阻止启用后留空保存。
|
|
1889
1917
|
const model = typeof local.model === 'string' ? local.model.trim() : ''
|
|
1890
1918
|
if (model === '') return []
|
|
1919
|
+
if (localOnlyVisionEnabled(config) && !isLoopbackVisionBaseURL(baseURL)) return []
|
|
1891
1920
|
return [
|
|
1892
1921
|
{
|
|
1893
1922
|
name: 'local-lmstudio',
|
|
@@ -2100,6 +2129,9 @@ export function httpProvidersOf(config, allowDefault = true) {
|
|
|
2100
2129
|
(p) => p && typeof p.baseURL === 'string' && typeof p.model === 'string',
|
|
2101
2130
|
)
|
|
2102
2131
|
: []
|
|
2132
|
+
if (localOnlyVisionEnabled(config)) {
|
|
2133
|
+
return configured.filter((provider) => isLoopbackVisionBaseURL(provider.baseURL))
|
|
2134
|
+
}
|
|
2103
2135
|
if (!allowDefault) return configured
|
|
2104
2136
|
if (configured.length === 0) return DEFAULT_HTTP_PROVIDERS
|
|
2105
2137
|
const seen = new Set(configured.map((p) => `${p.name}/${p.model}`))
|
|
@@ -2126,6 +2158,7 @@ export function httpProvidersOf(config, allowDefault = true) {
|
|
|
2126
2158
|
*/
|
|
2127
2159
|
export function orderedHttpProviders(config = {}, freeFirst = false) {
|
|
2128
2160
|
const providers = httpProvidersOf(config, config.freeFallback !== false)
|
|
2161
|
+
if (localOnlyVisionEnabled(config)) return providers
|
|
2129
2162
|
if (!freeFirst) return providers
|
|
2130
2163
|
const identity = (p) =>
|
|
2131
2164
|
`${String(p.baseURL ?? '').replace(/\/$/, '')}\u0000${p.model}\u0000${p.apiKeyEnv ?? ''}`
|
package/lib/doctor-cli-p0.js
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
formatDshHostCapability,
|
|
8
8
|
normalizeDshHostCapabilities,
|
|
9
9
|
} from './dsh-host-capabilities.js'
|
|
10
|
+
import { DOCTOR_RESPONSE_MAX_BYTES, readResponseJsonBounded } from './http-body-limit.js'
|
|
10
11
|
import {
|
|
11
12
|
DSH_SUPPORT_WINDOW,
|
|
12
13
|
DSH_VERIFICATION_EVIDENCE,
|
|
@@ -63,7 +64,9 @@ export async function probeDoctorHostCapabilities({ baseUrl, fetchImpl = globalT
|
|
|
63
64
|
capabilities: unknownSnapshot(),
|
|
64
65
|
}
|
|
65
66
|
}
|
|
66
|
-
const body = await response
|
|
67
|
+
const body = await readResponseJsonBounded(response, DOCTOR_RESPONSE_MAX_BYTES, {
|
|
68
|
+
label: 'Doctor Host capability response',
|
|
69
|
+
})
|
|
67
70
|
return {
|
|
68
71
|
ok: true,
|
|
69
72
|
source: 'live-runtime',
|
package/lib/doctor-runtime.js
CHANGED
|
@@ -3,6 +3,7 @@ import { existsSync, readFileSync } from 'node:fs'
|
|
|
3
3
|
import os, { homedir } from 'node:os'
|
|
4
4
|
import path from 'node:path'
|
|
5
5
|
import { CURRENT_VERSION } from './update-check.js'
|
|
6
|
+
import { DOCTOR_RESPONSE_MAX_BYTES, readResponseTextBounded } from './http-body-limit.js'
|
|
6
7
|
|
|
7
8
|
export const DEFAULT_RUNTIME_URL = 'http://127.0.0.1:3080'
|
|
8
9
|
const SAFE_RUNTIME_ROUTES = [
|
|
@@ -111,17 +112,17 @@ export async function probeRuntime({
|
|
|
111
112
|
signal: timeoutSignal(timeoutMs),
|
|
112
113
|
})
|
|
113
114
|
if (response.status === 200) {
|
|
114
|
-
const text =
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
}
|
|
115
|
+
const text = await readResponseTextBounded(response, DOCTOR_RESPONSE_MAX_BYTES, {
|
|
116
|
+
label: 'Doctor runtime ownership response',
|
|
117
|
+
})
|
|
118
|
+
const body = text ? JSON.parse(text) : undefined
|
|
119
|
+
const expectedDirectory = path.resolve(dshHome, 'logs', 'vision-router')
|
|
120
|
+
const actualDirectory = typeof body?.directory === 'string' ? path.resolve(body.directory) : undefined
|
|
121
|
+
const candidates = [...new Set(applicableProfiles.filter((name) => typeof name === 'string'))]
|
|
122
|
+
if (body?.local === true && actualDirectory === expectedDirectory && candidates.length === 1 && candidates[0] === requestedProfile) {
|
|
123
|
+
ownership = { requestedProfile, verified: true, profile: requestedProfile, source: 'log-home+unique-profile' }
|
|
124
|
+
} else if (body?.local === true && actualDirectory === expectedDirectory) {
|
|
125
|
+
ownership = { requestedProfile, verified: false, reason: 'profile-ambiguous', candidateProfiles: candidates }
|
|
125
126
|
}
|
|
126
127
|
}
|
|
127
128
|
} catch {
|
|
@@ -110,6 +110,18 @@ function revisionFor(editor, namespace, current, inherited) {
|
|
|
110
110
|
return previous.revision
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
const CORDIS_ORIGINAL = Symbol.for('cordis.original')
|
|
114
|
+
|
|
115
|
+
function serviceIdentity(value) {
|
|
116
|
+
if (!value || (typeof value !== 'object' && typeof value !== 'function')) return value
|
|
117
|
+
try {
|
|
118
|
+
const original = value[CORDIS_ORIGINAL]
|
|
119
|
+
return original && (typeof original === 'object' || typeof original === 'function') ? original : value
|
|
120
|
+
} catch {
|
|
121
|
+
return value
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
113
125
|
function serviceOf(ctx, name) {
|
|
114
126
|
try {
|
|
115
127
|
const value = typeof ctx?.get === 'function' ? ctx.get(name) : undefined
|
|
@@ -176,17 +188,26 @@ function applyOperations(current, inherited, ops) {
|
|
|
176
188
|
return next
|
|
177
189
|
}
|
|
178
190
|
|
|
179
|
-
function createConfigEditorSettingsFacade(editor, entryConfig, namespace) {
|
|
180
|
-
const listeners =
|
|
191
|
+
function createConfigEditorSettingsFacade(editor, entryConfig, namespace, sharedState) {
|
|
192
|
+
const listeners = sharedState.listeners
|
|
181
193
|
|
|
182
|
-
|
|
194
|
+
// ConfigEditor.configuration() rebuilds the complete profile and, on 0.1.7,
|
|
195
|
+
// composes every entry through structuredClone(). Ordinary profile writes
|
|
196
|
+
// reload this plugin and therefore replace the facade; keep one immutable
|
|
197
|
+
// instance snapshot for runtime reads and refresh only at write boundaries.
|
|
198
|
+
function state(refresh = false) {
|
|
199
|
+
if (sharedState.stateLoaded && !refresh) return sharedState.cachedState
|
|
183
200
|
const row = configEditorRow(editor, namespace)
|
|
184
|
-
if (!row)
|
|
201
|
+
if (!row) {
|
|
202
|
+
sharedState.cachedState = undefined
|
|
203
|
+
sharedState.stateLoaded = true
|
|
204
|
+
return sharedState.cachedState
|
|
205
|
+
}
|
|
185
206
|
const current = objectLike(row.entry?.options?.config) ? row.entry.options.config : {}
|
|
186
207
|
const inherited = objectLike(row.inherited) ? row.inherited : {}
|
|
187
208
|
const base = mergeResolved(entryConfig, inherited)
|
|
188
209
|
const value = mergeResolved(entryConfig, current)
|
|
189
|
-
|
|
210
|
+
sharedState.cachedState = {
|
|
190
211
|
row,
|
|
191
212
|
descriptor: {
|
|
192
213
|
ns: namespace,
|
|
@@ -197,17 +218,25 @@ function createConfigEditorSettingsFacade(editor, entryConfig, namespace) {
|
|
|
197
218
|
applies: true,
|
|
198
219
|
},
|
|
199
220
|
}
|
|
221
|
+
sharedState.stateLoaded = true
|
|
222
|
+
return sharedState.cachedState
|
|
200
223
|
}
|
|
201
224
|
|
|
202
225
|
function notify(value) {
|
|
203
226
|
for (const listener of [...listeners]) {
|
|
204
|
-
try { listener(value, undefined) } catch {}
|
|
227
|
+
try { listener(clone(value), undefined) } catch {}
|
|
205
228
|
}
|
|
206
229
|
}
|
|
207
230
|
|
|
231
|
+
function enqueueMutation(task) {
|
|
232
|
+
const running = sharedState.mutationTail.then(task, task)
|
|
233
|
+
sharedState.mutationTail = running.catch(() => {})
|
|
234
|
+
return running
|
|
235
|
+
}
|
|
236
|
+
|
|
208
237
|
const scope = {
|
|
209
238
|
get() {
|
|
210
|
-
return state()?.descriptor.value ?? entryConfig
|
|
239
|
+
return clone(state()?.descriptor.value ?? entryConfig)
|
|
211
240
|
},
|
|
212
241
|
watch(listener) {
|
|
213
242
|
if (typeof listener !== 'function') return () => {}
|
|
@@ -234,23 +263,35 @@ function createConfigEditorSettingsFacade(editor, entryConfig, namespace) {
|
|
|
234
263
|
const current = state()
|
|
235
264
|
return current ? [clone(current.descriptor)] : []
|
|
236
265
|
},
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
266
|
+
mutate(namespaceName, operations, expectedRevision) {
|
|
267
|
+
let ops
|
|
268
|
+
try {
|
|
269
|
+
if (namespaceName !== namespace) throw new Error(`No configurable plugin entry ${JSON.stringify(namespaceName)}`)
|
|
270
|
+
if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0) {
|
|
271
|
+
throw admissionError('expectedRevision must be a non-negative safe integer')
|
|
272
|
+
}
|
|
273
|
+
// Validate and clone caller-owned values before this write waits behind
|
|
274
|
+
// another mutation, closing a queued-request time-of-check/use gap.
|
|
275
|
+
ops = validateOperations(operations)
|
|
276
|
+
} catch (error) {
|
|
277
|
+
return Promise.reject(error)
|
|
246
278
|
}
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
279
|
+
return enqueueMutation(async () => {
|
|
280
|
+
// Runtime reads use the facade snapshot. Writes deliberately cross the
|
|
281
|
+
// ConfigEditor boundary again so optimistic concurrency still observes
|
|
282
|
+
// changes that landed before a Host reload replaced this facade.
|
|
283
|
+
const before = state(true)
|
|
284
|
+
if (!before) throw new Error(`No configurable plugin entry ${JSON.stringify(namespace)}`)
|
|
285
|
+
if (expectedRevision !== before.descriptor.revision) {
|
|
286
|
+
throw settingsConflict(expectedRevision, before.descriptor.revision)
|
|
287
|
+
}
|
|
288
|
+
const entry = before.row.entry
|
|
289
|
+
await editor.edit(entry, (current, inherited) => applyOperations(current, inherited, ops))
|
|
290
|
+
const after = state(true)
|
|
291
|
+
if (!after) throw new Error('Vision Router configuration entry disappeared after the write')
|
|
292
|
+
notify(after.descriptor.value)
|
|
293
|
+
return clone(after.descriptor)
|
|
294
|
+
})
|
|
254
295
|
},
|
|
255
296
|
}
|
|
256
297
|
}
|
|
@@ -264,6 +305,7 @@ function createConfigEditorSettingsFacade(editor, entryConfig, namespace) {
|
|
|
264
305
|
function contextWithLazySettingsCompatibility(ctx, entryConfig, namespace) {
|
|
265
306
|
const wrappers = new WeakMap()
|
|
266
307
|
const facades = new WeakMap()
|
|
308
|
+
const sharedStates = new WeakMap()
|
|
267
309
|
let injectedEditor
|
|
268
310
|
|
|
269
311
|
try {
|
|
@@ -284,9 +326,24 @@ function contextWithLazySettingsCompatibility(ctx, entryConfig, namespace) {
|
|
|
284
326
|
|| !editor || typeof editor.edit !== 'function' || typeof editor.configuration !== 'function') {
|
|
285
327
|
return settings
|
|
286
328
|
}
|
|
329
|
+
const identity = serviceIdentity(editor)
|
|
330
|
+
let sharedState = sharedStates.get(identity)
|
|
331
|
+
if (!sharedState) {
|
|
332
|
+
sharedState = {
|
|
333
|
+
listeners: new Set(),
|
|
334
|
+
cachedState: undefined,
|
|
335
|
+
stateLoaded: false,
|
|
336
|
+
mutationTail: Promise.resolve(),
|
|
337
|
+
}
|
|
338
|
+
sharedStates.set(identity, sharedState)
|
|
339
|
+
}
|
|
287
340
|
let facade = facades.get(editor)
|
|
288
341
|
if (!facade) {
|
|
289
|
-
|
|
342
|
+
// Cordis may hand different traceable proxy views of the same service to
|
|
343
|
+
// different child contexts. Keep each facade bound to its caller-owned
|
|
344
|
+
// proxy for method ownership, but share the expensive immutable snapshot
|
|
345
|
+
// and write queue by the stable underlying service identity.
|
|
346
|
+
facade = createConfigEditorSettingsFacade(editor, entryConfig, namespace, sharedState)
|
|
290
347
|
facades.set(editor, facade)
|
|
291
348
|
}
|
|
292
349
|
return facade
|
package/lib/http-body-limit.js
CHANGED
|
@@ -3,6 +3,7 @@ const DEFAULT_FALLBACK_MAX_BYTES = 4 * 1024 * 1024
|
|
|
3
3
|
export const MODEL_RESPONSE_MAX_BYTES = 4 * 1024 * 1024
|
|
4
4
|
export const METADATA_RESPONSE_MAX_BYTES = 512 * 1024
|
|
5
5
|
export const ERROR_RESPONSE_MAX_BYTES = 64 * 1024
|
|
6
|
+
export const DOCTOR_RESPONSE_MAX_BYTES = 16 * 1024
|
|
6
7
|
|
|
7
8
|
function normalizedLimit(value) {
|
|
8
9
|
const number = Number(value)
|
|
@@ -34,7 +35,10 @@ export async function readResponseBytesBounded(response, maxBytes, options = {})
|
|
|
34
35
|
const limit = normalizedLimit(maxBytes)
|
|
35
36
|
const label = typeof options.label === 'string' && options.label !== '' ? options.label : 'HTTP response'
|
|
36
37
|
const declared = declaredLength(response)
|
|
37
|
-
if (declared !== undefined && declared > limit)
|
|
38
|
+
if (declared !== undefined && declared > limit) {
|
|
39
|
+
try { await response?.body?.cancel?.('response body limit exceeded') } catch { /* best effort */ }
|
|
40
|
+
throw responseTooLarge(label, limit, declared)
|
|
41
|
+
}
|
|
38
42
|
|
|
39
43
|
const body = response?.body
|
|
40
44
|
if (body && typeof body.getReader === 'function') {
|
|
@@ -63,15 +63,25 @@ export function installLocalVisionStabilizer(ctx, config = {}, core) {
|
|
|
63
63
|
const positive = (value, fallback) =>
|
|
64
64
|
Number.isFinite(value) && value > 0 ? Number(value) : fallback
|
|
65
65
|
|
|
66
|
-
|
|
66
|
+
let resolvedConfig = config
|
|
67
|
+
|
|
68
|
+
const captureConfig = (value, scope = rawScope) => {
|
|
67
69
|
try {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
+
let candidate = value
|
|
71
|
+
if ((!candidate || typeof candidate !== 'object') && scope && typeof scope.get === 'function') {
|
|
72
|
+
candidate = scope.get()
|
|
73
|
+
}
|
|
74
|
+
resolvedConfig = normalizeRuntimeVisionConfig(
|
|
75
|
+
candidate && typeof candidate === 'object' ? candidate : config,
|
|
76
|
+
)
|
|
70
77
|
} catch {
|
|
71
|
-
|
|
78
|
+
resolvedConfig = config
|
|
72
79
|
}
|
|
80
|
+
return resolvedConfig
|
|
73
81
|
}
|
|
74
82
|
|
|
83
|
+
const actualConfig = () => resolvedConfig
|
|
84
|
+
|
|
75
85
|
const localTaskBudget = (value = actualConfig()) =>
|
|
76
86
|
Math.max(
|
|
77
87
|
1000,
|
|
@@ -344,6 +354,7 @@ export function installLocalVisionStabilizer(ctx, config = {}, core) {
|
|
|
344
354
|
if (typeof watch !== 'function') return watch
|
|
345
355
|
return (listener) =>
|
|
346
356
|
watch.call(target, (...args) => {
|
|
357
|
+
if (rawScope === target) captureConfig(args[0], target)
|
|
347
358
|
syncScreenshot()
|
|
348
359
|
return listener(...args)
|
|
349
360
|
})
|
|
@@ -369,6 +380,7 @@ export function installLocalVisionStabilizer(ctx, config = {}, core) {
|
|
|
369
380
|
const scope = target.register(namespace, schema, fixedOptions)
|
|
370
381
|
if (namespace === 'vision-router') {
|
|
371
382
|
rawScope = scope
|
|
383
|
+
captureConfig(undefined, scope)
|
|
372
384
|
syncScreenshot()
|
|
373
385
|
// The Settings service is dynamic. Never keep its scope after the
|
|
374
386
|
// owning injection fiber unloads; during the gap fall back to the
|
|
@@ -378,6 +390,7 @@ export function installLocalVisionStabilizer(ctx, config = {}, core) {
|
|
|
378
390
|
() => () => {
|
|
379
391
|
if (rawScope !== scope) return
|
|
380
392
|
rawScope = undefined
|
|
393
|
+
resolvedConfig = config
|
|
381
394
|
syncScreenshot()
|
|
382
395
|
},
|
|
383
396
|
'vision-router: local settings scope lifecycle',
|
|
@@ -41,6 +41,7 @@ export const REMOTE_SETTINGS_READABLE_FIELDS = Object.freeze([
|
|
|
41
41
|
'visionTurnBudgetMs',
|
|
42
42
|
'ocrTimeoutMs',
|
|
43
43
|
'ocrEngine',
|
|
44
|
+
'localOnlyVision',
|
|
44
45
|
'freeFallback',
|
|
45
46
|
'freeCloudFirst',
|
|
46
47
|
'autoWrapProviders',
|
|
@@ -48,7 +49,12 @@ export const REMOTE_SETTINGS_READABLE_FIELDS = Object.freeze([
|
|
|
48
49
|
'onboardingSeen',
|
|
49
50
|
])
|
|
50
51
|
|
|
51
|
-
|
|
52
|
+
// Privacy-critical local-only policy is visible remotely for truthful status,
|
|
53
|
+
// but only a loopback/local Settings page may disable it. This prevents an
|
|
54
|
+
// already-authorized remote Settings session from re-enabling visual egress.
|
|
55
|
+
export const REMOTE_SETTINGS_MUTABLE_FIELDS = Object.freeze(
|
|
56
|
+
REMOTE_SETTINGS_READABLE_FIELDS.filter((field) => field !== 'localOnlyVision'),
|
|
57
|
+
)
|
|
52
58
|
|
|
53
59
|
const readable = new Set(REMOTE_SETTINGS_READABLE_FIELDS)
|
|
54
60
|
const mutable = new Set(REMOTE_SETTINGS_MUTABLE_FIELDS)
|
package/lib/replay-delegation.js
CHANGED
|
@@ -94,6 +94,7 @@ function effectiveVisionConfig(ctx, fallbackConfig) {
|
|
|
94
94
|
export function configuredVisionAdapterModels(config = {}) {
|
|
95
95
|
config = normalizeRuntimeVisionConfig(config)
|
|
96
96
|
const allowed = new Map()
|
|
97
|
+
if (config.localOnlyVision === true) return allowed
|
|
97
98
|
const add = (provider, model) => {
|
|
98
99
|
if (!isNonEmptyString(provider) || provider === VISION_HTTP_ROUTE || !isNonEmptyString(model)) return
|
|
99
100
|
let models = allowed.get(provider)
|
|
@@ -103,9 +103,9 @@ export function applyVisionRuntimeComposition(ctx, config = {}, core) {
|
|
|
103
103
|
|
|
104
104
|
// DSH's browser WebServer can intentionally bind 0.0.0.0 and carries no
|
|
105
105
|
// authentication layer of its own. Same-origin headers are only a CSRF
|
|
106
|
-
// signal, so install
|
|
107
|
-
//
|
|
108
|
-
//
|
|
106
|
+
// signal, so install the route boundary before DVR-owned routes register. The
|
|
107
|
+
// wrapper keeps the original injected child context identity, but shadows its
|
|
108
|
+
// webServer view privately so unrelated plugins never observe DVR's registrar.
|
|
109
109
|
const localMutationCtx = installLocalMutationRouteBoundary(ctx)
|
|
110
110
|
|
|
111
111
|
// Normalize DSH 0.1.1's prepareCall contract at the deepest private LLM
|
|
@@ -137,7 +137,7 @@ export const SETTINGS_IA_CLIENT_PRELUDE = String.raw`(function(){
|
|
|
137
137
|
function userHas(key){return hasOwn(user,key);}
|
|
138
138
|
|
|
139
139
|
var numberMeta={timeoutMs:[1000,600000],visionTaskTimeoutMs:[1000,180000],ocrTimeoutMs:[1000,120000],downscaleMaxPixels:[1000,100000000],cacheTtlSeconds:[0,31536000],cacheMaxEntries:[1,100000],visionDepthMaxCalls:[0,100],visionTurnBudgetMs:[0,600000]};
|
|
140
|
-
var boolKeys=new Set(['freeFallback','tool','structuredVisionBootstrap','desktopScreenshot','downscale','cache','freeCloudFirst','autoWrapProviders','allowRemoteSettings','rewriteImages','routing','reverseRouting','progressiveTools','stealth']);
|
|
140
|
+
var boolKeys=new Set(['localOnlyVision','freeFallback','tool','structuredVisionBootstrap','desktopScreenshot','downscale','cache','freeCloudFirst','autoWrapProviders','allowRemoteSettings','rewriteImages','routing','reverseRouting','progressiveTools','stealth']);
|
|
141
141
|
function numberRun(key,raw){if(raw===''||raw===null||raw===undefined)return undefined;var n=Number(raw),meta=numberMeta[key];if(!meta||!Number.isFinite(n)||!Number.isInteger(n)||n<meta[0]||n>meta[1])return undefined;if(key==='visionTurnBudgetMs'&&n!==0&&n<10000)return undefined;return {value:n};}
|
|
142
142
|
function draftRun(key,raw){
|
|
143
143
|
if(boolKeys.has(key)){var next=bool(raw);return {value:key==='allowRemoteSettings'&&snapshot.mode==='host'?(next?'true':'false'):next};}
|
|
@@ -180,7 +180,7 @@ export const SETTINGS_IA_CLIENT_PRELUDE = String.raw`(function(){
|
|
|
180
180
|
function modelOptions(provider,current,allowAll){var group=groupById.get(provider),models=arr(group&&group.models),listed=new Set(models.map(function(model){return model&&model.id;}).filter(Boolean)),nodes=[h('option',{value:'',key:'__empty'},allowAll?tx('全部模型','All models'):(provider?tx('选择模型…','Choose model…'):tx('先选供应商','Choose provider first')))];if(current&&!listed.has(current))nodes.push(h('option',{value:current,key:'__stale-'+current},current+' '+tx('(已保存)','(saved)')));models.forEach(function(model){if(model&&typeof model.id==='string'&&model.id)nodes.push(h('option',{value:model.id,key:model.id},modelName(group,model.id)));});return nodes;}
|
|
181
181
|
function modelRow(row,index,rows,setRows,allowAll){var ready=groups.length>0;function update(next){var copy=rows.map(function(item){return Object.assign({},item);});copy[index]=next;setRows(copy);setSaveState({status:'idle'});}function providerChanged(provider){var next=Object.assign({},row,{provider:provider,model:''});if(!allowAll)next.fallbacks=[];update(next);}function remove(){var copy=rows.filter(function(_,i){return i!==index;});setRows(copy.length?copy:[{provider:'',model:'',fallbacks:[]}]);setSaveState({status:'idle'});}return h('div',{className:'vr-chain-row',key:index},ready?h('select',{className:'vr-input vr-select',value:row.provider||'',disabled:!writable||saving,onChange:function(event){providerChanged(event.target.value);}},providerOptions(row.provider||'')):h('input',{className:'vr-input',placeholder:'provider',value:row.provider||'',disabled:!writable||saving,onChange:function(event){providerChanged(event.target.value);}}),ready?h('select',{className:'vr-input vr-select',value:row.model||'',disabled:!writable||saving||!row.provider,onChange:function(event){update(Object.assign({},row,{model:event.target.value}));}},modelOptions(row.provider||'',row.model||'',allowAll)):h('input',{className:'vr-input',placeholder:allowAll?tx('model(留空=全部)','model (empty = all)'):'model',value:row.model||'',disabled:!writable||saving,onChange:function(event){update(Object.assign({},row,{model:event.target.value}));}}),h('button',{type:'button',className:'vr-reset',disabled:!writable||saving,onClick:remove},tx('移除','Remove')));}
|
|
182
182
|
|
|
183
|
-
function generalPage(){var rows=chainRows(),configured=rows.filter(function(row){return row&&row.provider&&row.model;}),
|
|
183
|
+
function generalPage(){var rows=chainRows(),configured=rows.filter(function(row){return row&&row.provider&&row.model;}),localOnly=toggleValue('localOnlyVision',false),fallback=!localOnly&&toggleValue('freeFallback',true),status=localOnly?tx('仅本地视觉策略已启用','Local-only vision policy enabled'):configured.length?tx('识图已就绪','Vision ready'):fallback?tx('已可使用内置免费识图','Built-in free vision is ready'):tx('暂无可用识图模型','No vision model configured'),ordered=configured.map(function(row){return row.model;});if(fallback)ordered.push(tx('内置免费兜底','Built-in free fallback'));var stale=catalog.status==='ready'?configured.filter(function(row){var group=groupById.get(row.provider);return !group||!arr(group.models).some(function(model){return model&&model.id===row.model;});}):[];return h(React.Fragment,null,title(tx('常规','General'),tx('这里只设置“谁负责看图”。聊天模型仍在聊天页选择。','This page only decides who reads images; the chat model is still chosen in chat.')),card([toggle('localOnlyVision',tx('仅本地视觉','Local-only vision'),tx('开启后,Vision Router 只允许回环地址上的本地视觉后端(Ollama、LM Studio 或本机自定义 HTTP);所有云端/DSH Provider 和 OVH 免费兜底都会在运行时被硬阻止。已保存的云端配置不会删除,关闭后可恢复。主聊天模型是否联网不受此开关控制。此隐私开关只能在运行 DSH 的本机页面修改。','When enabled, Vision Router only allows visual backends on loopback addresses (Ollama, LM Studio, or custom local HTTP). Cloud/DSH providers and the OVH free fallback are blocked at runtime. Saved cloud settings are preserved for later restoration. This does not control whether the main chat model is remote. This privacy control can only be changed from the local DSH settings page.'),!local),h('div',{className:'vr-ia-status',key:'status'},h('strong',null,(localOnly?'🔒 ':configured.length||fallback?'● ':'⚠ ')+status),h('p',{className:'vr-hint'},tx('需要看图时,在聊天框旁点击「👁 识图」。','Use “👁 Vision” beside the chat box when an image needs inspection.')),h('p',{className:localOnly?'vr-hint':'vr-failed',key:'egress'},localOnly?tx('当前仅本地视觉策略正在生效:云端识图行和 OVH 免费兜底会保留配置但不会执行;只有回环地址上的本地视觉端点可接收图片。注意:当前聊天模型仍可能是云端模型。','Local-only vision is active: cloud vision rows and the OVH fallback remain saved but cannot execute; only visual endpoints on loopback addresses may receive images. The current chat model may still be remote.'):tx('数据流向:使用云端识图模型(包括默认 OVHcloud 匿名兜底)时,图片/裁剪、提示词和相关上下文会离开本机;免费或免 Key 不等于离线。若要阻止 Vision Router 将图片发送到远程视觉端点,请开启上方“仅本地视觉”。','Data flow: cloud vision models, including the default anonymous OVHcloud fallback, receive images/crops, prompts and related context; free or keyless does not mean offline. Enable Local-only vision above to prevent Vision Router from sending image data to remote visual endpoints.')),ordered.length?h('p',{className:'vr-ia-chain-summary'},ordered.join(' → ')):null,stale.length?h('p',{className:'vr-failed'},tx('有已保存的识图模型当前不在可调用目录中;请到“诊断”重新检测。','A saved vision model is not currently in the callable catalog; re-detect it in Diagnostics.')):null)]),card([h('div',{className:'vr-field',id:'vr-vision-backend-chain','data-vr-guide-target':'vision-backend',key:'chain'},fieldHead('providers',tx('识图模型','Vision models')),h('p',{className:'vr-hint'},tx('从上到下依次尝试;前一个失败时自动切换到下一个。','Models are tried from top to bottom and fall through automatically on failure.')),rows.map(function(row,index){return modelRow(row,index,rows,setChainDraft,false);}),h('button',{type:'button',className:'vr-btn',disabled:!writable||saving,onClick:function(){setChainDraft(rows.concat([{provider:'',model:'',fallbacks:[]}]))}},tx('+ 添加备用识图模型','+ Add fallback vision model'))),toggle('freeFallback',tx('内置免费兜底','Built-in free fallback'),localOnly?tx('仅本地视觉开启时,OVH 免费兜底由运行时强制禁用;这里保留原设置,关闭仅本地视觉后可恢复。','The OVH fallback is runtime-blocked while Local-only vision is enabled. Its saved value is preserved and becomes active again when Local-only vision is turned off.'):tx('这是远程 OVHcloud 服务;前面的模型都失败后会上传图片并自动尝试,无需 API Key。','This is a remote OVHcloud service. After earlier models fail, it uploads the image and tries automatically; no API key is required.'),localOnly)]));}
|
|
184
184
|
function strategyPage(){
|
|
185
185
|
var structured=toggleValue('structuredVisionBootstrap',false);
|
|
186
186
|
var rawDepth=String(value('visionDepth','standard')||'standard');
|
|
@@ -255,7 +255,7 @@ export const SETTINGS_IA_CLIENT_PRELUDE = String.raw`(function(){
|
|
|
255
255
|
);
|
|
256
256
|
}
|
|
257
257
|
function localProviderCard(key,label,defaults,which){var current=Object.assign({},defaults,obj(value(key,{}))),opened=which==='ollama'?open.ollama:open.lmstudio;function update(patch){setValue(key,Object.assign({},current,patch));}function sample(name,min,max){return h('div',{className:'vr-field'},h('span',{className:'vr-label'},name),h('input',{className:'vr-input',type:'number',step:'0.1',min:min,max:max,value:current[name]===undefined?'':current[name],disabled:!writable||saving,onChange:function(event){var patch={};patch[name]=event.target.value===''?undefined:Number(event.target.value);update(patch);}}));}return card([h('div',{className:'vr-ia-backend-head',key:'head'},h('div',null,h('div',{className:'vr-ia-field-head'},h('strong',null,label),override(key)),h('p',{className:'vr-hint'},current.enabled?tx('已启用','Enabled'):tx('未启用','Disabled'))),h('input',{type:'checkbox',checked:current.enabled===true,disabled:!writable||saving,onChange:function(event){update({enabled:event.target.checked});}})),h('div',{className:'vr-field',key:'model'},h('span',{className:'vr-label'},tx('模型','Model')),h('input',{className:'vr-input',value:String(current.model||''),disabled:!writable||saving,onChange:function(event){update({model:event.target.value});}}),key==='localLmStudio'&&invalidKeys.includes(key)?h('p',{className:'vr-failed'},tx('启用 LM Studio 时必须填写真实模型标识。','A real model id is required when LM Studio is enabled.')):null),h('div',{className:'vr-field',key:'url'},h('span',{className:'vr-label'},tx('服务地址','Service URL')),h('input',{className:'vr-input',value:String(current.baseURL||''),disabled:!writable||saving,onChange:function(event){update({baseURL:event.target.value});}})),h('button',{type:'button',className:'vr-btn',onClick:function(){setOpen(Object.assign({},open,which==='ollama'?{ollama:!opened}:{lmstudio:!opened}));}},opened?tx('收起高级连接设置','Hide advanced connection settings'):tx('高级连接设置','Advanced connection settings')),opened?h('div',{className:'vr-ia-subgrid',key:'advanced'},h('div',{className:'vr-field'},h('span',{className:'vr-label'},tx('请求协议','Request protocol')),h('select',{className:'vr-input',value:current.format==='anthropic'?'anthropic':current.format==='lmstudio'?'lmstudio':'openai',disabled:!writable||saving,onChange:function(event){update({format:event.target.value});}},h('option',{value:'openai'},'OpenAI'),h('option',{value:'anthropic'},'Anthropic'),which==='lmstudio'?h('option',{value:'lmstudio'},tx('LM Studio 原生','LM Studio native')):null)),h('div',{className:'vr-field'},h('span',{className:'vr-label'},tx('最大输出 token','Max output tokens')),h('input',{className:'vr-input',type:'number',step:'256',min:'256',max:'32768',value:String(current.maxTokens||4096),disabled:!writable||saving,onChange:function(event){update({maxTokens:Number(event.target.value)});}})),h('div',{className:'vr-field'},h('span',{className:'vr-label'},tx('推理强度','Reasoning effort')),h('select',{className:'vr-input',value:String(current.reasoningEffort||defaults.reasoningEffort||'provider_default'),disabled:!writable||saving||(which==='lmstudio'?current.format!=='lmstudio':current.format==='anthropic'),onChange:function(event){update({reasoningEffort:event.target.value});}},h('option',{value:'provider_default'},tx('服务端默认','Provider default')),h('option',{value:'none'},tx('关闭推理','Disable reasoning')),h('option',{value:'low'},'low'),h('option',{value:'medium'},'medium'),h('option',{value:'high'},'high'),h('option',{value:'max'},'max'))),sample('temperature',0,2),sample('top_p',0,1)):null]);}
|
|
258
|
-
function localPageContent(){if(!local)return h(React.Fragment,null,title(tx('本地与设备','Local & device')),card([h('p',{className:'vr-hint',key:'remote'},tx('本地视觉后端和桌面截图只能在运行 DSH 的机器上配置。','Local vision backends and desktop capture can only be configured on the DSH machine.'))]));return h(React.Fragment,null,title(tx('本地与设备','Local & device'),tx('
|
|
258
|
+
function localPageContent(){if(!local)return h(React.Fragment,null,title(tx('本地与设备','Local & device')),card([h('p',{className:'vr-hint',key:'remote'},tx('本地视觉后端和桌面截图只能在运行 DSH 的机器上配置。','Local vision backends and desktop capture can only be configured on the DSH machine.'))]));return h(React.Fragment,null,title(tx('本地与设备','Local & device'),tx('本地视觉可让图像像素留在本机;但若云端识图行或免费兜底仍开启,失败时仍可能出网,识图结果也会交给当前聊天模型。','Local vision can keep image pixels on this machine. Cloud vision rows or the free fallback may still send them off-device after a failure, and vision result text is still passed to the current chat model.')),localProviderCard('localOllama','Ollama',{enabled:false,baseURL:'http://127.0.0.1:11434/v1',model:'qwen2.5vl',format:'openai',maxTokens:4096,reasoningEffort:'none'},'ollama'),localProviderCard('localLmStudio','LM Studio',{enabled:false,baseURL:'http://localhost:1234/v1',model:'',format:'openai',maxTokens:4096,reasoningEffort:'none'},'lmstudio'),card([toggle('desktopScreenshot',tx('允许 Agent 读取桌面截图','Allow the agent to capture the desktop'),tx('这是独立的隐私权限;macOS 上保存为开启后会立即触发屏幕录制权限检查。','This is a separate privacy permission; on macOS saving it enabled immediately triggers the screen-recording permission check.'))]));}
|
|
259
259
|
function wrappersEditor(){var rows=wrapperRows();return h('div',{className:'vr-field'},fieldHead('wrappedProviders',tx('哪些聊天模型可以开启识图','Which chat models can use Vision mode')),h('p',{className:'vr-hint'},tx('通常无需修改;模型留空表示整个 Provider。','Usually leave this alone; an empty model means the whole provider.')),rows.map(function(row,index){return modelRow(row,index,rows,setWrapperDraft,true);}),h('button',{type:'button',className:'vr-btn',disabled:!writable||saving,onClick:function(){setWrapperDraft(rows.concat([{provider:'',model:''}]))}},tx('+ 添加范围','+ Add scope')));}
|
|
260
260
|
function textProviderEditor(){var current=Object.assign({provider:'',model:''},obj(value('textProvider',{}))),ready=groups.length>0;return h('div',{className:'vr-field'},fieldHead('textProvider',tx('文字回退模型','Text fallback model')),h('div',{className:'vr-chain-row'},ready?h('select',{className:'vr-input',value:current.provider||'',disabled:!writable||saving,onChange:function(event){setValue('textProvider',{provider:event.target.value,model:''});}},providerOptions(current.provider||'')):h('input',{className:'vr-input',value:current.provider||'',disabled:!writable||saving,onChange:function(event){setValue('textProvider',{provider:event.target.value,model:current.model||''});}}),ready?h('select',{className:'vr-input',value:current.model||'',disabled:!writable||saving||!current.provider,onChange:function(event){setValue('textProvider',{provider:current.provider||'',model:event.target.value});}},modelOptions(current.provider||'',current.model||'',false)):h('input',{className:'vr-input',value:current.model||'',disabled:!writable||saving,onChange:function(event){setValue('textProvider',{provider:current.provider||'',model:event.target.value});}})),invalidKeys.includes('textProvider')?h('p',{className:'vr-failed'},tx('Provider 和 model 必须同时填写,或同时留空恢复默认。','Provider and model must both be filled, or both left empty to restore the default.')):null);}
|
|
261
261
|
function advancedPage(){var routing=toggleValue('routing',false),turnBudget=Number(value('visionTurnBudgetMs',0))||0,customBudget=turnBudget>0;return h(React.Fragment,null,title(tx('高级','Advanced'),tx('这些默认值对大多数用户已经合适。','Defaults are suitable for most users.')),card([h('h4',{className:'vr-ia-subtitle',key:'p'},tx('性能与稳定性','Performance & stability')),toggle('downscale',tx('自动缩放','Auto downscale')),numberField('downscaleMaxPixels',tx('图片像素上限','Image pixel limit'),null,1000,100000000),toggle('cache',tx('识图缓存','Vision answer cache')),numberField('cacheTtlSeconds',tx('缓存有效期(秒)','Cache TTL (seconds)'),null,0,31536000),numberField('cacheMaxEntries',tx('最大缓存数量','Maximum cached answers'),null,1,100000),h('h4',{className:'vr-ia-subtitle',key:'t'},tx('超时','Timeouts')),numberField('timeoutMs',tx('单次模型请求','Single model request'),tx('毫秒。','Milliseconds.'),1000,600000),numberField('visionTaskTimeoutMs',tx('单个视觉任务','Single visual task'),tx('包含该任务内部的重试和备用模型;不是每个后端各自一份。','Includes retries and fallbacks inside the task; it is not a fresh budget per backend.'),1000,180000),numberField('ocrTimeoutMs',tx('OCR 任务','OCR task'),null,1000,120000),h('div',{className:'vr-field',key:'ocr-engine'},fieldHead('ocrEngine',tx('OCR 默认引擎','Default OCR engine')),h('select',{className:'vr-input',value:String(value('ocrEngine','auto')||'auto'),disabled:!writable||saving,onChange:function(event){setValue('ocrEngine',event.target.value);}},h('option',{value:'auto'},tx('自动(Tesseract → 视觉模型)','Auto (Tesseract → vision)')),h('option',{value:'tesseract'},tx('仅 Tesseract','Tesseract only')),h('option',{value:'vision'},tx('仅视觉模型','Vision model only'))),h('p',{className:'vr-hint'},tx('vision_ocr 未显式指定 engine 时使用;单次调用的 engine=tesseract/vision 始终优先。','Used when vision_ocr does not explicitly choose an engine; per-call engine=tesseract/vision always wins.'))),h('div',{className:'vr-field',key:'budget'},fieldHead('visionTurnBudgetMs',tx('整轮视觉工具上限','Whole-turn vision-tool limit')),h('select',{className:'vr-input',value:customBudget?'custom':'unlimited',disabled:!writable||saving,onChange:function(event){setValue('visionTurnBudgetMs',event.target.value==='unlimited'?0:(turnBudget>0?turnBudget:180000));}},h('option',{value:'unlimited'},tx('不限制(推荐)','Unlimited (recommended)')),h('option',{value:'custom'},tx('自定义','Custom'))),customBudget?numberField('visionTurnBudgetMs',tx('上限(毫秒)','Limit (ms)'),null,10000,600000):null)]),card([h('h4',{className:'vr-ia-subtitle',key:'cost'},tx('模型顺序与成本','Model order & cost')),toggle('freeCloudFirst',tx('免费云模型优先','Try free cloud models first'))]),card([h('h4',{className:'vr-ia-subtitle',key:'scope'},tx('识图模式范围','Vision mode scope')),toggle('autoWrapProviders',tx('自动允许已启用模型使用识图','Automatically allow enabled models to use Vision mode')),wrappersEditor()]),local?card([h('h4',{className:'vr-ia-subtitle',key:'network'},tx('网络与远程','Network & remote')),h('p',{className:'vr-hint',key:'network-authority'},tx('默认沿用 DSH/Host 的网络出口;只有需要让视觉请求走不同出口时才填写下方覆盖。','By default Vision Router follows the DSH/Host network path. Use the override below only when vision requests need a different egress route.')),toggle('allowRemoteSettings',tx('允许可信 Host 远程修改设置','Allow trusted-host remote settings'),tx('默认关闭;trustedHosts 不是身份认证。','Off by default; trustedHosts is not authentication.')),textField('proxy',tx('视觉专用代理覆盖','Vision-only proxy override'),tx('留空即沿用 DSH/Host;支持 http(s)://、socks5://,并兼容旧 socks5h://。','Leave empty to follow DSH/Host; supports http(s):// and socks5://, with legacy socks5h:// compatibility.')),textareaArray('proxyHosts',tx('视觉代理覆盖域名','Vision proxy override hosts'),tx('仅在上方覆盖非空时生效;每行一个,其余请求继续沿用 DSH/Host。','Used only when the override above is non-empty; one host per line. All other requests keep the DSH/Host path.'))]):null,card([h('h4',{className:'vr-ia-subtitle',key:'compat'},tx('兼容模式','Compatibility')),toggle('rewriteImages',tx('保护纯文本模型','Protect text-only models')),toggle('routing',tx('整轮视觉路由(旧工作流)','Whole-turn vision routing (legacy workflow)')),routing?toggle('reverseRouting',tx('纯文字消息继续使用聊天模型','Keep text-only messages on the chat model')):null,routing?textProviderEditor():null]),card([h('button',{type:'button',className:'vr-btn',key:'dev',onClick:function(){setOpen(Object.assign({},open,{developer:!open.developer}));}},open.developer?tx('隐藏开发者设置','Hide developer settings'):tx('显示开发者设置','Show developer settings')),open.developer?h('div',{className:'vr-ia-dev',key:'body'},toggle('progressiveTools',tx('渐进式工具暴露','Progressive tool exposure')),h('p',{className:'vr-hint',key:'progressive-restart'},tx('保存后需重启 DSH 才生效。','Restart DSH after saving for this change to take effect.')),local?toggle('stealth','Stealth'):null,local?textField('wrapperRoute',tx('包装路由名','Wrapper route name')):null,local?textField('chainRoute',tx('视觉链路由名','Vision chain route name')):null,textareaArray('extraVisionModels',tx('额外视觉能力标记','Extra vision capability labels'),tx('只有诊断发现模型未声明图片能力时才需要。','Needed only when diagnostics show missing image-capability metadata.'))):null]));}
|
|
@@ -80,7 +80,7 @@ export function transformSettingsIaToNativeCards(source = SETTINGS_IA_CLIENT_PRE
|
|
|
80
80
|
" var valid=validChain(chainRows())&&validWrapperRows(wrapperRows())&&validGuidance(guidanceRows())&&invalidKeys.length===0;",
|
|
81
81
|
` var valid=validChain(chainRows())&&validWrapperRows(wrapperRows())&&validGuidance(guidanceRows())&&invalidKeys.length===0;
|
|
82
82
|
var pageKeys={
|
|
83
|
-
general:new Set(['providers','freeFallback']),
|
|
83
|
+
general:new Set(['providers','localOnlyVision','freeFallback']),
|
|
84
84
|
strategy:new Set(['tool','structuredVisionBootstrap','visionDepth','visionDepthMaxCalls','guidanceOverrides']),
|
|
85
85
|
local:new Set(['localOllama','localLmStudio','desktopScreenshot']),
|
|
86
86
|
advanced:new Set(['wrappedProviders','downscale','downscaleMaxPixels','cache','cacheTtlSeconds','cacheMaxEntries','timeoutMs','visionTaskTimeoutMs','ocrTimeoutMs','ocrEngine','visionTurnBudgetMs','freeCloudFirst','autoWrapProviders','allowRemoteSettings','proxy','proxyHosts','rewriteImages','routing','reverseRouting','textProvider','progressiveTools','stealth','wrapperRoute','chainRoute','extraVisionModels']),
|
|
@@ -195,6 +195,15 @@ async function resolvedHostInfo(llm, call) {
|
|
|
195
195
|
return undefined
|
|
196
196
|
}
|
|
197
197
|
|
|
198
|
+
function localOnlyVisionCallAllowed(core, config, provider, model) {
|
|
199
|
+
if (config?.localOnlyVision !== true) return true
|
|
200
|
+
if (provider !== 'vision-http') return false
|
|
201
|
+
const allowed = []
|
|
202
|
+
try { allowed.push(...(core?.localProvidersOf?.(config) ?? [])) } catch {}
|
|
203
|
+
try { allowed.push(...(core?.httpProvidersOf?.(config) ?? [])) } catch {}
|
|
204
|
+
return allowed.some((entry) => `${entry?.name}/${entry?.model}` === model)
|
|
205
|
+
}
|
|
206
|
+
|
|
198
207
|
function failureStream(message, code = 'VISION_IMAGE_DELIVERY_UNAVAILABLE') {
|
|
199
208
|
return {
|
|
200
209
|
async *[Symbol.asyncIterator]() {
|
|
@@ -302,6 +311,21 @@ function llmWithRuntimePolicy(ctx, llm, options) {
|
|
|
302
311
|
: 'configured'
|
|
303
312
|
return {
|
|
304
313
|
async *[Symbol.asyncIterator]() {
|
|
314
|
+
if (!localOnlyVisionCallAllowed(core, config, provider, model)) {
|
|
315
|
+
safeLog(
|
|
316
|
+
logger,
|
|
317
|
+
'info',
|
|
318
|
+
'vision-router: local-only policy blocks visual backend [%s/%s] source=%s',
|
|
319
|
+
provider,
|
|
320
|
+
model,
|
|
321
|
+
source,
|
|
322
|
+
)
|
|
323
|
+
yield* failureStream(
|
|
324
|
+
`vision backend ${provider}/${model} is blocked by the local-only vision policy`,
|
|
325
|
+
'VISION_LOCAL_ONLY_POLICY',
|
|
326
|
+
)
|
|
327
|
+
return
|
|
328
|
+
}
|
|
305
329
|
const info = await resolvedHostInfo(target, call)
|
|
306
330
|
const delivery = hostImageDeliveryFromInfo(info)
|
|
307
331
|
const capability = typeof core?.decideVisionBackendCapability === 'function'
|
|
@@ -14,6 +14,7 @@ function addCandidate(out, seen, candidate) {
|
|
|
14
14
|
|
|
15
15
|
/** Configured provider/model pairs in exactly the order legacy execution sees them. */
|
|
16
16
|
export function configuredVisionPairs(config) {
|
|
17
|
+
if (config?.localOnlyVision === true) return []
|
|
17
18
|
const rows = Array.isArray(config?.providers) ? config.providers : []
|
|
18
19
|
const source = rows.length > 0
|
|
19
20
|
? rows
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
const wrappedContexts = new WeakMap()
|
|
2
|
-
const patchedWebServers = new WeakMap()
|
|
3
2
|
|
|
4
3
|
const VISION_ROUTER_WEB_PREFIX = '/_dsh/vision-router/'
|
|
5
4
|
|
|
@@ -274,37 +273,59 @@ function guardedRoute(route, getConnection) {
|
|
|
274
273
|
}
|
|
275
274
|
|
|
276
275
|
/**
|
|
277
|
-
*
|
|
278
|
-
*
|
|
279
|
-
*
|
|
276
|
+
* Scope DVR's WebServer registrar to one injected child context without ever
|
|
277
|
+
* mutating the shared WebServer implementation. Cordis service reads return a
|
|
278
|
+
* caller-scoped proxy, so shadowing `childCtx.webServer` preserves the original
|
|
279
|
+
* child identity/effect ownership while keeping unrelated plugin contexts on the
|
|
280
|
+
* untouched service. Async injection callbacks keep only this private shadow
|
|
281
|
+
* until they settle; they never hold a process-global registrar override.
|
|
280
282
|
*/
|
|
281
|
-
function
|
|
282
|
-
if (!
|
|
283
|
-
if (typeof webServer.register !== 'function') return () => {}
|
|
283
|
+
function runWithWebServerBoundary(childCtx, getConnection, callback) {
|
|
284
|
+
if (!childCtx || (typeof childCtx !== 'object' && typeof childCtx !== 'function')) return callback()
|
|
284
285
|
|
|
285
|
-
let
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
webServer
|
|
293
|
-
state = { count: 0, originalRegister, guardedRegister, deactivate: () => { active = false } }
|
|
294
|
-
patchedWebServers.set(webServer, state)
|
|
286
|
+
let webServer
|
|
287
|
+
try { webServer = childCtx.webServer } catch { return callback() }
|
|
288
|
+
if (!webServer || (typeof webServer !== 'object' && typeof webServer !== 'function')) return callback()
|
|
289
|
+
if (typeof webServer.register !== 'function') return callback()
|
|
290
|
+
|
|
291
|
+
const ownWebServer = Object.getOwnPropertyDescriptor(childCtx, 'webServer')
|
|
292
|
+
if (ownWebServer && (!('value' in ownWebServer) || (ownWebServer.configurable === false && ownWebServer.writable !== true))) {
|
|
293
|
+
throw new TypeError('vision-router: cannot scope the injected webServer service safely')
|
|
295
294
|
}
|
|
296
|
-
state.count += 1
|
|
297
295
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
released = true
|
|
302
|
-
state.count = Math.max(0, state.count - 1)
|
|
303
|
-
if (state.count !== 0) return
|
|
304
|
-
state.deactivate()
|
|
305
|
-
if (webServer.register === state.guardedRegister) webServer.register = state.originalRegister
|
|
306
|
-
patchedWebServers.delete(webServer)
|
|
296
|
+
const originalRegister = webServer.register
|
|
297
|
+
const guardedRegister = function registerWithLocalMutationBoundary(route) {
|
|
298
|
+
return originalRegister.call(webServer, guardedRoute(route, getConnection))
|
|
307
299
|
}
|
|
300
|
+
const scopedWebServer = new Proxy(webServer, {
|
|
301
|
+
get(target, property, receiver) {
|
|
302
|
+
if (property === 'register') return guardedRegister
|
|
303
|
+
return Reflect.get(target, property, receiver)
|
|
304
|
+
},
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
Object.defineProperty(childCtx, 'webServer', ownWebServer
|
|
308
|
+
? { ...ownWebServer, value: scopedWebServer }
|
|
309
|
+
: { value: scopedWebServer, configurable: true, enumerable: false, writable: true })
|
|
310
|
+
|
|
311
|
+
const restore = () => {
|
|
312
|
+
if (Object.getOwnPropertyDescriptor(childCtx, 'webServer')?.value !== scopedWebServer) return
|
|
313
|
+
if (ownWebServer) Object.defineProperty(childCtx, 'webServer', ownWebServer)
|
|
314
|
+
else delete childCtx.webServer
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
let result
|
|
318
|
+
try {
|
|
319
|
+
result = callback()
|
|
320
|
+
} catch (error) {
|
|
321
|
+
restore()
|
|
322
|
+
throw error
|
|
323
|
+
}
|
|
324
|
+
if (result && typeof result.then === 'function') {
|
|
325
|
+
return Promise.resolve(result).finally(restore)
|
|
326
|
+
}
|
|
327
|
+
restore()
|
|
328
|
+
return result
|
|
308
329
|
}
|
|
309
330
|
|
|
310
331
|
/**
|
|
@@ -339,25 +360,13 @@ export function installLocalMutationRouteBoundary(ctx) {
|
|
|
339
360
|
} catch {}
|
|
340
361
|
return childCtx?.connection ?? target?.connection
|
|
341
362
|
}
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
} catch {
|
|
350
|
-
/* fall back to the parent lifetime below */
|
|
351
|
-
}
|
|
352
|
-
if (!owned) {
|
|
353
|
-
try {
|
|
354
|
-
target.effect?.(() => release, 'vision-router: local mutation transport boundary fallback')
|
|
355
|
-
} catch {
|
|
356
|
-
/* service replacement still drops its registered routes */
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
// Preserve the ORIGINAL child context identity for rc.6 ownership.
|
|
360
|
-
return callback(childCtx)
|
|
363
|
+
// Preserve the ORIGINAL child context identity for rc.6 ownership,
|
|
364
|
+
// but never leave our registrar wrapper visible to other plugins.
|
|
365
|
+
return runWithWebServerBoundary(
|
|
366
|
+
childCtx,
|
|
367
|
+
getConnection,
|
|
368
|
+
() => callback(childCtx),
|
|
369
|
+
)
|
|
361
370
|
}, ...rest)
|
|
362
371
|
}
|
|
363
372
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-vision-router",
|
|
3
|
-
"version": "2.2.
|
|
4
|
-
"description": "Eyes for text-only DeepSeek Harness agents: built-in free vision chain
|
|
3
|
+
"version": "2.2.6",
|
|
4
|
+
"description": "Eyes for text-only DeepSeek Harness agents: built-in free vision chain + pixel-level tools. Remote vision providers, including the default free fallback, receive image content unless strict local-only use is configured.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -54,8 +54,8 @@
|
|
|
54
54
|
"undici": "^7.29.0"
|
|
55
55
|
},
|
|
56
56
|
"peerDependencies": {
|
|
57
|
-
"@deepseek-ai/dsh-anonymous-user-id": "^0.1.0-rc.8 || ^0.1.1-rc.1 || ^0.1.3-alpha.2 || 0.1.5-alpha.1 || 0.1.5-alpha.2 || 0.1.5-rc.1 || 0.1.5-rc.2 || 0.1.5-rc.3 || 0.1.6-alpha.1 || 0.1.7-rc.2",
|
|
58
|
-
"@deepseek-ai/dsh-llm-deepseek": "^0.1.0-rc.8 || ^0.1.1-rc.1 || ^0.1.3-alpha.2 || 0.1.5-alpha.1 || 0.1.5-alpha.2 || 0.1.5-rc.1 || 0.1.5-rc.2 || 0.1.5-rc.3 || 0.1.6-alpha.1 || 0.1.7-rc.2",
|
|
57
|
+
"@deepseek-ai/dsh-anonymous-user-id": "^0.1.0-rc.8 || ^0.1.1-rc.1 || ^0.1.3-alpha.2 || 0.1.5-alpha.1 || 0.1.5-alpha.2 || 0.1.5-rc.1 || 0.1.5-rc.2 || 0.1.5-rc.3 || 0.1.6-alpha.1 || 0.1.7-rc.2 || >=0.2.0-rc.1 <0.3.0-0",
|
|
58
|
+
"@deepseek-ai/dsh-llm-deepseek": "^0.1.0-rc.8 || ^0.1.1-rc.1 || ^0.1.3-alpha.2 || 0.1.5-alpha.1 || 0.1.5-alpha.2 || 0.1.5-rc.1 || 0.1.5-rc.2 || 0.1.5-rc.3 || 0.1.6-alpha.1 || 0.1.7-rc.2 || >=0.2.0-rc.1 <0.3.0-0",
|
|
59
59
|
"sharp": ">=0.35.3 <1"
|
|
60
60
|
},
|
|
61
61
|
"peerDependenciesMeta": {
|
|
@@ -70,7 +70,7 @@
|
|
|
70
70
|
"sharp": "^0.35.4"
|
|
71
71
|
},
|
|
72
72
|
"scripts": {
|
|
73
|
-
"test": "node --test tests/index-modularization.test.js tests/adapter-prepare-call-compat.test.js tests/core.test.js tests/issue-374-delegated-call-config.test.js tests/grounding-coordinate-frame.test.js tests/grounding-coordinate-runtime.test.js tests/capability-advisory.test.js tests/vision-execution-policy.test.js tests/vision-backend-diagnostics.test.js tests/live-model-discovery.test.js tests/live-model-credential-resolution.test.js tests/vision-model-registry.test.js tests/live-model-client-stability.test.js tests/providers-persistence.test.js tests/provider-directory-fallback.test.js tests/model-catalog-policy.test.js tests/settings-ia-client-prelude.test.js tests/settings-ia-fj.test.js tests/settings-ia-acceptance-regression.test.js tests/settings-migration.test.js tests/settings-section-order.test.js tests/attachment-admission-policy.test.js tests/vision-resilience.test.js tests/degraded-local-evidence.test.js tests/vision-resilience-lifecycle.test.js tests/vision-breaker-readonly.test.js tests/alpha1-browser-lifecycle-integration.test.js tests/issue-367-remote-session-inject.test.js tests/vision-mode-toggle-injection.test.js tests/client.test.js tests/issue-307-regression.test.js tests/issue-284-vision-mode-toggle.test.js tests/issue-284-vision-mode-hardening.test.js tests/issue-284-attachment-id-hint.test.js tests/issue-284-model-visibility.test.js tests/issue-284-vision-selection-effort.test.js tests/client-presentation-boundary.test.js tests/clipboard-image-paste-compat.test.js tests/remote-settings-bridge.test.js tests/remote-settings-v2-contract.test.js tests/remote-settings-risk-confirmation.test.js tests/local-remote-settings-permission.test.js tests/rc6-real-settings-persistence.test.js tests/artifact-path-security.test.js tests/http-compat.test.js tests/catalog-corrections.test.js tests/release-registry-identity.test.js tests/update-check.test.js tests/update-check-signal-lifecycle.test.js tests/self-update.test.js tests/doctor.test.js tests/doctor-cli.test.js tests/legacy-session-repair.test.js tests/profile-pnpm-diagnostics.test.js tests/doctor-v2.test.js tests/doctor-cli-v2.test.js tests/doctor-runtime.test.js tests/dsh-host-capabilities.test.js tests/doctor-host-capabilities.test.js tests/session-repair-v2.test.js tests/bundle-defaults.test.js tests/file-logger.test.js tests/official-deepseek-catalog.test.js tests/replay-delegation.test.js tests/twin-image-capability-fallback.test.js tests/twin-image-custom-wrapper.test.js tests/adversarial-hardening.test.js tests/vision-adversarial-hardening-v2.test.js tests/vision-capability-benchmark-visual-proof.test.js tests/wrapper-directory.test.js tests/logging-ui.test.js tests/manifest-dependencies.test.js tests/structured-bootstrap.test.js tests/structured-bootstrap-gate.test.js tests/vision-model-guidance-contract.test.js tests/local-ollama.test.js tests/ollama-cold-start.test.js tests/ollama-cold-start-loaded.test.js tests/zero-regression-gate.test.js tests/runtime-e2e.test.js tests/local-vision-stabilizer.test.js tests/local-connection-probe.test.js tests/repetition-guard.test.js tests/guide-scroll-gate.test.js tests/settings-scroll-jank-regression.test.js tests/android-attachment-compat.test.js tests/free-cloud-first.test.js tests/rc6-rc7-compat.test.js tests/native-image-coexistence.test.js tests/issue-276-session-image-ownership.test.js tests/issue-276-entry-policy-bridge.test.js tests/issue-276-policy-hardening.test.js tests/issue-289-native-nonintervention.test.js tests/issue-512-tool-restriction.test.js tests/pi-ai-bridge-wire-compat.test.js tests/fetch-wrapper-lifecycle.test.js tests/fetch-wrapper-composition.test.js tests/mixed-router.test.js tests/depth-tier.test.js tests/depth-quota-behavior.test.js tests/structured-flow-hardening.test.js tests/structured-flow-adversarial.test.js tests/vision-budget-activation.test.js tests/runtime-boundary-fixes.test.js tests/security-resource-boundaries.test.js tests/security-property-fuzz.test.js tests/security-adversarial-fuzz-contract.test.js tests/runtime-i18n.test.js tests/long-ocr-empty-retry.test.js tests/tesseract-adaptive-ocr.test.js tests/tesseract-node24-boot.test.js tests/session-vision-state.test.js tests/session-vision-state-integration.test.js tests/session-vision-event-feed.test.js tests/image-resource-governor.test.js tests/resource-retention-lifecycle.test.js tests/pixel-diff-stream.test.js tests/large-image-resource-integration.test.js tests/structured-guard-idempotency.test.js tests/vision-routing-product.test.js tests/vision-runtime-performance.test.js tests/vision-routing-settings-prelude.test.js tests/v2-settings-ia-integration.test.js tests/vision-capability-router.test.js tests/vision-capability-reference.test.js tests/vision-capability-benchmark.test.js tests/vision-capability-grounding-proof-contract.test.js tests/vision-capability-probe.test.js tests/vision-capability-axis-freshness.test.js tests/vision-capability-shadow-health.test.js tests/vision-capability-benchmark-service.test.js tests/vision-capability-preflight-hardening.test.js tests/vision-capability-benchmark-contract.test.js tests/vision-capability-benchmark-client.test.js tests/vision-capability-benchmark-client-productization.test.js tests/vision-capability-adapter-route.test.js tests/vision-background-benchmark.test.js tests/vision-background-lifecycle.test.js tests/vision-background-benchmark-productization.test.js tests/vision-capability-benchmark-budget.test.js tests/vision-image-input-verdict.test.js tests/vision-capability-failure-classification.test.js tests/vision-capability-gate-client.test.js tests/v2-product-review-fixes.test.js tests/qa-top5-reliability.test.js tests/qa-screenshot-runtime.test.js tests/qa-runtime-identity.test.js tests/qa-turn-budget-cancellation.test.js tests/qa-cancellation-publication.test.js tests/qa-reliability-tail.test.js tests/qa-endpoint-route-alias.test.js tests/settings-native-card-semantics.test.js tests/settings-native-card-lazy-data.test.js tests/settings-invalid-staged-dirty.test.js tests/vision-toggle-root-hardening.test.js && node --test tests/vision-backend-runtime-policy.test.js",
|
|
73
|
+
"test": "node --test tests/index-modularization.test.js tests/adapter-prepare-call-compat.test.js tests/core.test.js tests/issue-374-delegated-call-config.test.js tests/grounding-coordinate-frame.test.js tests/grounding-coordinate-runtime.test.js tests/capability-advisory.test.js tests/vision-execution-policy.test.js tests/vision-backend-diagnostics.test.js tests/live-model-discovery.test.js tests/live-model-credential-resolution.test.js tests/vision-model-registry.test.js tests/live-model-client-stability.test.js tests/providers-persistence.test.js tests/provider-directory-fallback.test.js tests/model-catalog-policy.test.js tests/settings-ia-client-prelude.test.js tests/settings-ia-fj.test.js tests/settings-ia-acceptance-regression.test.js tests/settings-migration.test.js tests/settings-section-order.test.js tests/attachment-admission-policy.test.js tests/vision-resilience.test.js tests/degraded-local-evidence.test.js tests/vision-resilience-lifecycle.test.js tests/vision-breaker-readonly.test.js tests/alpha1-browser-lifecycle-integration.test.js tests/issue-367-remote-session-inject.test.js tests/vision-mode-toggle-injection.test.js tests/client.test.js tests/issue-307-regression.test.js tests/issue-284-vision-mode-toggle.test.js tests/issue-284-vision-mode-hardening.test.js tests/issue-284-attachment-id-hint.test.js tests/issue-284-model-visibility.test.js tests/issue-284-vision-selection-effort.test.js tests/client-presentation-boundary.test.js tests/clipboard-image-paste-compat.test.js tests/remote-settings-bridge.test.js tests/remote-settings-v2-contract.test.js tests/remote-settings-risk-confirmation.test.js tests/local-remote-settings-permission.test.js tests/rc6-real-settings-persistence.test.js tests/artifact-path-security.test.js tests/http-compat.test.js tests/catalog-corrections.test.js tests/release-registry-identity.test.js tests/update-check.test.js tests/update-check-signal-lifecycle.test.js tests/self-update.test.js tests/doctor.test.js tests/doctor-cli.test.js tests/legacy-session-repair.test.js tests/profile-pnpm-diagnostics.test.js tests/doctor-v2.test.js tests/doctor-cli-v2.test.js tests/doctor-runtime.test.js tests/dsh-host-capabilities.test.js tests/doctor-host-capabilities.test.js tests/session-repair-v2.test.js tests/bundle-defaults.test.js tests/file-logger.test.js tests/official-deepseek-catalog.test.js tests/replay-delegation.test.js tests/twin-image-capability-fallback.test.js tests/twin-image-custom-wrapper.test.js tests/adversarial-hardening.test.js tests/vision-adversarial-hardening-v2.test.js tests/vision-capability-benchmark-visual-proof.test.js tests/wrapper-directory.test.js tests/logging-ui.test.js tests/manifest-dependencies.test.js tests/structured-bootstrap.test.js tests/structured-bootstrap-gate.test.js tests/vision-model-guidance-contract.test.js tests/local-ollama.test.js tests/local-only-vision.test.js tests/ollama-cold-start.test.js tests/ollama-cold-start-loaded.test.js tests/zero-regression-gate.test.js tests/runtime-e2e.test.js tests/local-vision-stabilizer.test.js tests/local-connection-probe.test.js tests/repetition-guard.test.js tests/guide-scroll-gate.test.js tests/settings-scroll-jank-regression.test.js tests/android-attachment-compat.test.js tests/free-cloud-first.test.js tests/rc6-rc7-compat.test.js tests/native-image-coexistence.test.js tests/issue-276-session-image-ownership.test.js tests/issue-276-entry-policy-bridge.test.js tests/issue-276-policy-hardening.test.js tests/issue-289-native-nonintervention.test.js tests/issue-512-tool-restriction.test.js tests/pi-ai-bridge-wire-compat.test.js tests/fetch-wrapper-lifecycle.test.js tests/fetch-wrapper-composition.test.js tests/mixed-router.test.js tests/depth-tier.test.js tests/depth-quota-behavior.test.js tests/structured-flow-hardening.test.js tests/structured-flow-adversarial.test.js tests/vision-budget-activation.test.js tests/runtime-boundary-fixes.test.js tests/security-resource-boundaries.test.js tests/security-property-fuzz.test.js tests/security-adversarial-fuzz-contract.test.js tests/runtime-i18n.test.js tests/long-ocr-empty-retry.test.js tests/tesseract-adaptive-ocr.test.js tests/tesseract-node24-boot.test.js tests/session-vision-state.test.js tests/session-vision-state-integration.test.js tests/session-vision-event-feed.test.js tests/image-resource-governor.test.js tests/resource-retention-lifecycle.test.js tests/pixel-diff-stream.test.js tests/large-image-resource-integration.test.js tests/structured-guard-idempotency.test.js tests/vision-routing-product.test.js tests/vision-runtime-performance.test.js tests/vision-routing-settings-prelude.test.js tests/v2-settings-ia-integration.test.js tests/vision-capability-router.test.js tests/vision-capability-reference.test.js tests/vision-capability-benchmark.test.js tests/vision-capability-grounding-proof-contract.test.js tests/vision-capability-probe.test.js tests/vision-capability-axis-freshness.test.js tests/vision-capability-shadow-health.test.js tests/vision-capability-benchmark-service.test.js tests/vision-capability-preflight-hardening.test.js tests/vision-capability-benchmark-contract.test.js tests/vision-capability-benchmark-client.test.js tests/vision-capability-benchmark-client-productization.test.js tests/vision-capability-adapter-route.test.js tests/vision-background-benchmark.test.js tests/vision-background-lifecycle.test.js tests/vision-background-benchmark-productization.test.js tests/vision-capability-benchmark-budget.test.js tests/vision-image-input-verdict.test.js tests/vision-capability-failure-classification.test.js tests/vision-capability-gate-client.test.js tests/v2-product-review-fixes.test.js tests/qa-top5-reliability.test.js tests/qa-screenshot-runtime.test.js tests/qa-runtime-identity.test.js tests/qa-turn-budget-cancellation.test.js tests/qa-cancellation-publication.test.js tests/qa-reliability-tail.test.js tests/qa-endpoint-route-alias.test.js tests/settings-native-card-semantics.test.js tests/settings-native-card-lazy-data.test.js tests/settings-invalid-staged-dirty.test.js tests/vision-toggle-root-hardening.test.js && node --test tests/vision-backend-runtime-policy.test.js",
|
|
74
74
|
"test:core": "node --test tests/core.test.js tests/degraded-local-evidence.test.js tests/runtime-e2e.test.js tests/structured-bootstrap.test.js tests/structured-flow-hardening.test.js tests/structured-flow-adversarial.test.js tests/vision-execution-policy.test.js tests/vision-backend-runtime-policy.test.js",
|
|
75
75
|
"test:routing": "node --test tests/issue-374-delegated-call-config.test.js tests/twin-image-capability-fallback.test.js tests/twin-image-custom-wrapper.test.js tests/vision-routing-product.test.js tests/vision-capability-router.test.js tests/vision-capability-probe.test.js tests/vision-capability-shadow-health.test.js tests/vision-runtime-performance.test.js tests/vision-background-benchmark.test.js tests/vision-image-input-verdict.test.js",
|
|
76
76
|
"test:session": "node --test tests/session-vision-state.test.js tests/session-vision-state-integration.test.js tests/replay-delegation.test.js tests/legacy-session-repair.test.js tests/session-repair-v2.test.js tests/issue-276-session-image-ownership.test.js",
|
|
@@ -104,4 +104,4 @@
|
|
|
104
104
|
},
|
|
105
105
|
"bundle": { "patch": "./cordis.patch.yml" }
|
|
106
106
|
}
|
|
107
|
-
}
|
|
107
|
+
}
|