dsh-vision-router 1.2.2 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -15,7 +15,7 @@
15
15
  </p>
16
16
 
17
17
  <p align="center">
18
- <a href="https://github.com/ysr666/dsh-vision-router/releases/tag/v1.2.1"><img src="https://img.shields.io/badge/release-v1.2.1-5B4CF0?style=flat-square" alt="Release v1.2.1" /></a>
18
+ <a href="https://github.com/ysr666/dsh-vision-router/releases/tag/v1.3.0"><img src="https://img.shields.io/badge/release-v1.3.0-5B4CF0?style=flat-square" alt="Release v1.3.0" /></a>
19
19
  <a href="tests"><img src="https://img.shields.io/badge/verified-149%20tests-2EA44F?style=flat-square" alt="Verified: 149 tests" /></a>
20
20
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-2EA44F?style=flat-square" alt="License: MIT" /></a>
21
21
  <a href="package.json"><img src="https://img.shields.io/badge/Node.js-%3E%3D22-339933?style=flat-square&amp;logo=nodedotjs&amp;logoColor=white" alt="Node.js >=22" /></a>
@@ -28,9 +28,13 @@
28
28
  <p align="center">💬 <strong>QQ community group: 1105463028</strong></p>
29
29
 
30
30
  > [!WARNING]
31
- > 📌 **Announcement (v1.2.2)**
31
+ > 📌 **Announcement (v1.3.0)**
32
32
  >
33
- > v1.2.2 closes the last attachment-id gap — ids announced for images the host persisted itself (e.g. `read_image` re-uploads, `sha256:…`) now resolve in `vision_describe` and every pixel tool (issue #72) — stops `vision_present` and other tool-result image blocks from ever locking a text-model session with `UNSUPPORTED_CONTENT` (issue #74; already-locked sessions heal after upgrading), and warns loudly when a stale sharp left over from a pre-v1.2 upgrade would break the pixel tools with `colourspace: parameter space not set` (issue #75).
33
+ > **v1.3.0** makes long sessions and mixed-plugin setups safer: the vision-tool schema is stable by default to protect prefix/KV caches, diagnostics can be persisted and opened from Settings, custom vision-model filtering explains missing image metadata and supports re-detection, host-provided DSH capability packages use the correct peer contract, and Vision Router tool cards can coexist with other vision plugins while keeping Vision Router rendering precedence.
34
+ >
35
+ > v1.2.3 fixes DSH Desktop's re-appearing first-run dialog: the onboarding "seen" flag and the model-guide step now persist in the profile settings file instead of origin-scoped `localStorage`, which a random per-launch port (`--port 0`) wiped on every boot (issue #78).
36
+ >
37
+ > v1.2.2 closed the last attachment-id gap — ids announced for images the host persisted itself (e.g. `read_image` re-uploads, `sha256:…`) now resolve in `vision_describe` and every pixel tool (issue #72) — stopped `vision_present` and other tool-result image blocks from ever locking a text-model session with `UNSUPPORTED_CONTENT` (issue #74; already-locked sessions heal after upgrading), and warned loudly when a stale sharp left over from a pre-v1.2 upgrade would break the pixel tools with `colourspace: parameter space not set` (issue #75).
34
38
  >
35
39
  > v1.2.1 hardened the pixel loop: all eleven pixel tools now accept uploaded-image attachment ids directly (no more `cannot read …/sha256:…` round trips), artifact filenames carry collision-free fingerprints, `vision_ground` retries degenerate boxes, the model guide replays fully from step 1 (leaving the settings first), and the settings card scrolls smoothly even with hundreds of models per provider.
36
40
 
@@ -108,7 +112,7 @@ The auto-vision group follows the live DSH model catalog. Adding models or chang
108
112
 
109
113
  ### 3. Paste or upload the image
110
114
 
111
- After choosing the “+ Auto Vision” group, paste or upload an image normally. The agent auto-mounts the vision tools and can use `vision_describe`, `vision_ground`, `vision_crop`, and the rest across multiple steps when needed.
115
+ After choosing the “+ Auto Vision” group, paste or upload an image normally. By default the complete vision tool schema is stable from session start, so the agent can immediately use `vision_describe`, `vision_ground`, `vision_crop`, and the rest across multiple steps when needed.
112
116
 
113
117
  The built-in anonymous OVH vision fallback is already configured, so normal image use needs no signup or API key. **The lower-right chat picker selects only the brain/conversation model**; vision backends do not belong there. Advanced options live under **Settings → Plugins → Plugin config → 视觉路由(自动识图)**: each vision-backend row selects one image-capable user model already configured under **Settings → Models**. Leaving every user row empty is valid; the OVH chain remains the final fallback. `Vision HTTP` is an internal transport route, not a model group users should select.
114
118
 
@@ -127,7 +131,7 @@ The built-in anonymous OVH vision fallback is already configured, so normal imag
127
131
  - **Automatic failover with classified errors.** Region blocks, ToS filtering, 402 quota, 429 rate limits (with Retry-After backoff), context overflow, network failures — the chain walks providers one by one and only reports after all of them failed, with actionable advice.
128
132
  - **Image memory.** Vision answers are cached by attachment content hash; later text turns substitute the recorded description (marked as untrusted evidence), so DeepSeek genuinely remembers earlier images without re-spending vision calls.
129
133
  - **A verifiable pixel loop.** Reference → `vision_html_screenshot` → `vision_pixel_diff` (ratio + red heatmap + worst-region ranking) → fix → repeat until the mismatch converges. UI restoration becomes measurable instead of eyeballed.
130
- - **Progressive schema exposure.** Only a zero-arg `vision_activate` bootstrap is always visible; image turns auto-mount all eleven deep tools with a one-time usage note, and a `vision-tools` skill is registered for text-only turns.
134
+ - **Stable tool schema.** All eleven deep tools are registered from session start by default, avoiding a mid-conversation tool-list expansion that can invalidate long-context KV/prefix caches. `progressiveTools: true` remains an advanced boot-time opt-in; only then does `vision_activate` mount the tools on demand. See [`docs/progressive-tools-cache.md`](docs/progressive-tools-cache.md).
131
135
  - **Selective proxy.** Only the configured vision provider hosts go through your local proxy; DeepSeek stays direct.
132
136
 
133
137
  ### Pixel loop in practice
@@ -148,7 +152,7 @@ The vision model is **only the eyes**; DeepSeek is **always the brain**. An imag
148
152
 
149
153
  ## Tools
150
154
 
151
- All eleven deep tools mount automatically on image turns (`autoActivateOnImage`); text turns can mount them via `vision_activate` or the `/vision-tools` skill. Built on sharp / potrace / tesseract / system Chrome — no Python:
155
+ Default `progressiveTools: false`: all eleven deep tools stay registered from plugin startup, so text and image turns can call them immediately. If you explicitly set `progressiveTools: true` in the profile/composition `cordis.patch.yml`, progressive mode is restored: only `vision_activate` is exposed initially, the full tool set mounts on first use, and the `vision-tools` skill is registered. This is a boot-time switch; restart DSH after changing it. Built on sharp / potrace / tesseract / system Chrome — no Python:
152
156
 
153
157
  <p align="center">
154
158
  <img src="assets/vision-tools.svg" width="100%" alt="Eleven vision tools available in DSH Vision Router." />
@@ -264,7 +268,7 @@ Everything is optional; defaults work out of the box. Edit via the Web card or a
264
268
  | `wrapperRoute` / `chainRoute` | `deepseek-vision` / `vision-chain` | admission wrapper route name / fallback chain route name (empty disables) |
265
269
  | `stealth` | `false` | take over the official `deepseek-official` route (official row only; custom routes are auto-wrapped by default) |
266
270
  | `textProvider` | `deepseek-official` / `deepseek-v4-pro` | the model that reasons (your daily model) |
267
- | `tool` / `progressiveTools` / `autoActivateOnImage` | `true` ×3 | vision tools on / progressive mounting / auto-mount on image turns |
271
+ | `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 |
268
272
  | `rewriteImages` | `true` | rewrite image blocks in the model input (cached description or tool-hint marker); the UI log keeps images |
269
273
  | `downscale` / `downscaleMaxPixels` | `true` / `4000000` | pre-call downscale and its pixel budget (latency guard) |
270
274
  | `cache` / `cacheTtlSeconds` / `cacheMaxEntries` | `true` / `3600` / `200` | vision answer cache |
package/README.zh.md CHANGED
@@ -15,7 +15,7 @@
15
15
  </p>
16
16
 
17
17
  <p align="center">
18
- <a href="https://github.com/ysr666/dsh-vision-router/releases/tag/v1.2.1"><img src="https://img.shields.io/badge/release-v1.2.1-5B4CF0?style=flat-square" alt="Release v1.2.1" /></a>
18
+ <a href="https://github.com/ysr666/dsh-vision-router/releases/tag/v1.3.0"><img src="https://img.shields.io/badge/release-v1.3.0-5B4CF0?style=flat-square" alt="Release v1.3.0" /></a>
19
19
  <a href="tests"><img src="https://img.shields.io/badge/verified-149%20tests-2EA44F?style=flat-square" alt="Verified: 149 tests" /></a>
20
20
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-2EA44F?style=flat-square" alt="License: MIT" /></a>
21
21
  <a href="package.json"><img src="https://img.shields.io/badge/Node.js-%3E%3D22-339933?style=flat-square&amp;logo=nodedotjs&amp;logoColor=white" alt="Node.js >=22" /></a>
@@ -28,7 +28,11 @@
28
28
  <p align="center">💬 <strong>QQ 用户交流群:1105463028</strong></p>
29
29
 
30
30
  > [!WARNING]
31
- > 📌 **公告(v1.2.2)**
31
+ > 📌 **公告(v1.3.0)**
32
+ >
33
+ > **v1.3.0** 重点加固长会话稳定性与插件共存:视觉工具 schema 默认从会话开始保持稳定,减少 prefix/KV cache 失效;诊断日志可持久化并从设置页直接打开;自定义视觉模型缺失 image 元数据时会明确解释并支持重新检测;宿主提供的 DSH capability 包使用正确的 peer 依赖契约;与其他视觉插件共装时,同名工具卡不再导致启动失败,同时保留 Vision Router 自己的渲染优先级。
34
+ >
35
+ > v1.2.3 现已支持:修复 DSH Desktop 每次启动都重复弹出首次引导的问题——引导「已读」标记与模型引导步骤改存 profile 设置文件(不再依赖按 origin 隔离、每次随机端口重启即清零的 `localStorage`)(issue #78)。
32
36
  >
33
37
  > v1.2.2 现已支持:补上最后一处附件 ID 缺口——宿主 `read_image` 回挂图片公布的 `sha256:…` ID 现在可被 `vision_describe` 与全部像素工具解析(issue #72);`vision_present` 等工具结果里的图像块不再把文本模型会话锁死在 `UNSUPPORTED_CONTENT`(issue #74,已锁死的历史会话升级后自动修复);检测到 v1.1.x 升级残留的旧版 sharp 时明确告警,把玄学的 `colourspace` 报错变成一眼可见的修复指引(issue #75)。
34
38
  >
@@ -108,7 +112,7 @@ opencode-go + 自动识图 ← 发图片时选这个
108
112
 
109
113
  ### 3. 直接粘贴或上传图片
110
114
 
111
- 选好「+ 自动识图」模型组后,直接往对话里贴图即可。Agent 会自动挂载视觉工具,通过 `vision_describe`、`vision_ground`、`vision_crop` 等工具看图,需要时连续多步操作。
115
+ 选好「+ 自动识图」模型组后,直接往对话里贴图即可。默认情况下完整视觉工具表从会话开始就保持稳定,Agent 可直接调用 `vision_describe`、`vision_ground`、`vision_crop` 等工具看图,需要时连续多步操作。
112
116
 
113
117
  默认已经有内置 OVH 匿名视觉兜底,无需注册、无需 Key。**聊天页右下角只选择“脑子/会话模型”**;视觉模型不要在那里选。高级配置在 **设置 → 插件 → 插件配置 → 视觉路由(自动识图)**:视觉后端链每一行只选择一个你在 **设置 → 模型** 中已经配置且支持图片输入的用户模型;一行都不填也可以,OVH 免费链会固定在最后兜底。插件内部的 `Vision HTTP` 只是传输实现,不是用户需要选择的模型组。
114
118
 
@@ -127,7 +131,7 @@ opencode-go + 自动识图 ← 发图片时选这个
127
131
  - **自动降级 + 分类报错。** 地区限制、ToS 风控、402 额度、429 限流(尊重 Retry-After 退避重试)、上下文超长、网络故障——链路逐供应商尝试,全部失败才报错并给出可操作的建议。
128
132
  - **图片记忆。** 视觉答案按附件内容哈希缓存;后续文字轮用记录的描述替换历史图片(标注为不可信证据),DeepSeek 真正“记得”之前发过的图,且不重复消耗视觉调用。
129
133
  - **可验证的像素闭环。** 参照图 → `vision_html_screenshot` → `vision_pixel_diff`(差异率 + 红色热力图 + 最差区域排行)→ 修复 → 再对比,直到差异收敛。UI 还原从“目测”变成“实测”。
130
- - **渐进式 schema 暴露。** 平时只有一个零参引导工具 `vision_activate`;图片轮自动挂载全部 11 个深看工具(附一次性使用提示),并为纯文字轮注册 `vision-tools` 技能。
134
+ - **稳定工具 schema。** 默认从会话开始就注册完整 11 个深看工具,避免图片轮中途扩展工具列表导致长上下文的 KV / prefix cache 失效。仍保留 `progressiveTools: true` 作为高级启动期 opt-in;开启后才使用 `vision_activate` 按需挂载。详见 [`docs/progressive-tools-cache.md`](docs/progressive-tools-cache.md)。
131
135
  - **选择性代理。** 只有配置的视觉供应商域名走本地代理;DeepSeek 保持直连。
132
136
 
133
137
  ### 像素闭环实测
@@ -148,7 +152,7 @@ Agent 仅根据参考图复刻 UI,再用 `vision_pixel_diff` 验证最终结
148
152
 
149
153
  ## 工具
150
154
 
151
- 11 个深看工具在图片轮自动挂载(`autoActivateOnImage`);文字轮可通过 `vision_activate` 或 `/vision-tools` 技能挂载。全部基于 sharp / potrace / tesseract / 系统 Chrome——无 Python:
155
+ 默认 `progressiveTools: false`:11 个深看工具从插件启动时就保持常驻,文本轮和图片轮都可直接调用。若你在 profile / composition 的 `cordis.patch.yml` 中显式开启 `progressiveTools: true`,才会恢复渐进模式:初始只暴露 `vision_activate`,首次需要时再挂载完整工具,并注册 `vision-tools` 技能。该开关是启动期配置,修改后需重启 DSH。全部工具基于 sharp / potrace / tesseract / 系统 Chrome——无 Python:
152
156
 
153
157
  <p align="center">
154
158
  <img src="assets/vision-tools-zh.svg" width="100%" alt="DSH Vision Router 的 11 个视觉工具。" />
@@ -264,7 +268,7 @@ Web 配置页在 **设置 → 插件 → 插件配置** 下注册「视觉路由
264
268
  | `wrapperRoute` / `chainRoute` | `deepseek-vision` / `vision-chain` | 准入包装路由名 / 降级链路由名(置空关闭) |
265
269
  | `stealth` | `false` | 接管官方 `deepseek-official` 路由(仅官方行;自定义路由默认由自动包装处理) |
266
270
  | `textProvider` | `deepseek-official` / `deepseek-v4-pro` | 负责思考的模型(你的日常模型) |
267
- | `tool` / `progressiveTools` / `autoActivateOnImage` | `true` ×3 | 视觉工具开关 / 渐进式挂载 / 图片轮自动挂载 |
271
+ | `tool` / `progressiveTools` / `autoActivateOnImage` | `true` / `false` / `true` | 视觉工具总开关 / 渐进式挂载(默认关闭以稳定工具 schema)/ 渐进模式下图片轮自动挂载;`progressiveTools` 为启动期配置 |
268
272
  | `rewriteImages` | `true` | 模型输入层改写图片块(缓存描述或工具提示标记);界面日志保留图片 |
269
273
  | `downscale` / `downscaleMaxPixels` | `true` / `4000000` | 调用前压缩及其像素预算(延迟保护) |
270
274
  | `cache` / `cacheTtlSeconds` / `cacheMaxEntries` | `true` / `3600` / `200` | 视觉答案缓存 |
package/cordis.patch.yml CHANGED
@@ -8,11 +8,15 @@
8
8
  # 挂载后是否接管官方 deepseek-official 路由由设置里的「隐身模式」开关决定
9
9
  # (默认关;开启后需自行在 profile 补丁层禁用 llm-deepseek 行)。
10
10
 
11
- # 挂载插件行。配置全部可选(默认即内置免费视觉端点 + 工具优先的图片流程),
12
- # 可在 Web 设置 > 插件 > 插件配置 的「视觉路由」卡片里实时修改。
11
+ # 挂载插件行。默认保持完整视觉工具表常驻(issue #81):虽然渐进挂载可以
12
+ # 少发一小段工具 schema,但图片轮首次扩展工具列表会改变请求前缀,可能让
13
+ # 长会话的 provider KV/prefix cache 整体失效。高级用户仍可在 profile /
14
+ # composition 补丁中显式把 progressiveTools 改回 true(启动期配置)。
13
15
  - insert:
14
16
  - id: vision-router
15
17
  name: dsh-vision-router
18
+ config:
19
+ progressiveTools: false
16
20
 
17
21
  # 放宽附件图片限制(部署默认 5MB / 4000 万像素 → 20MB / 1 亿像素),
18
22
  # 大尺寸设计稿/扫描图可过审。字段可选,不需要可在 profile 补丁层覆写。
@@ -0,0 +1,57 @@
1
+ # Progressive tools and prefix cache
2
+
3
+ 中文 | English
4
+
5
+ ## 中文
6
+
7
+ Vision Router 默认让完整视觉工具表从会话开始就保持稳定。正常通过 DSH bundle 安装时,组合层会提供:
8
+
9
+ ```yaml
10
+ - id: vision-router
11
+ name: dsh-vision-router
12
+ config:
13
+ progressiveTools: false
14
+ ```
15
+
16
+ 插件入口本身也把 `progressiveTools` 的 schema 默认值设为 `false`,因此即使后续 profile patch 整块覆盖了 bundle 的 `config`、但没有重述这个字段,也不会意外回到渐进模式。
17
+
18
+ 原因是很多模型服务的 KV / prefix cache 会把工具 schema 视为请求前缀的一部分。若长会话一开始只暴露 `vision_activate`,第一次图片轮再挂载完整视觉工具,工具列表发生变化后可能导致此前的大段会话前缀无法命中缓存。
19
+
20
+ 渐进式暴露仍然保留为高级 opt-in。如果你更在意平时请求中少携带工具 schema,而不依赖长会话前缀缓存,请在 **profile / composition 的 `cordis.patch.yml`** 中显式给 `vision-router` 设置:
21
+
22
+ ```yaml
23
+ - id: vision-router
24
+ config:
25
+ progressiveTools: true
26
+ ```
27
+
28
+ `progressiveTools` 决定插件启动时注册哪组工具,因此它是**启动期配置**;不要依赖运行中的 Web 设置热切换这个字段。修改 profile patch 后重启 DSH。
29
+
30
+ 开启后,平时只暴露 `vision_activate`,首次需要时再挂载完整视觉工具;默认关闭时,完整视觉工具从一开始就常驻。视觉工具本身的能力不变。
31
+
32
+ ## English
33
+
34
+ Vision Router keeps the complete vision tool schema stable from the beginning of a session by default. Normal DSH bundle installs provide this composition layer:
35
+
36
+ ```yaml
37
+ - id: vision-router
38
+ name: dsh-vision-router
39
+ config:
40
+ progressiveTools: false
41
+ ```
42
+
43
+ The public plugin entrypoint also makes `false` the schema default. Therefore a later profile patch that replaces the bundle `config` without restating this field cannot accidentally fall back to progressive mode.
44
+
45
+ Many model providers include tool schemas in the KV/prefix-cacheable request prefix. If a long conversation starts with only `vision_activate` and the first image turn later mounts the complete vision tool set, that prefix change can invalidate a large cached history.
46
+
47
+ Progressive exposure remains available as an advanced opt-in. If minimizing the always-present tool schema matters more than long-context prefix-cache stability, explicitly set it in the **profile/composition `cordis.patch.yml`**:
48
+
49
+ ```yaml
50
+ - id: vision-router
51
+ config:
52
+ progressiveTools: true
53
+ ```
54
+
55
+ `progressiveTools` determines which tools are registered at plugin startup, so it is a **boot-time setting**; do not rely on changing this field through live Web settings. Restart DSH after changing the profile patch.
56
+
57
+ When enabled, only `vision_activate` is exposed initially and the complete vision tool set mounts on first use. With the default disabled mode, the complete vision tool set is present from the start. The vision capabilities themselves are unchanged.
package/entry.js ADDED
@@ -0,0 +1,51 @@
1
+ // Public plugin entrypoint.
2
+ //
3
+ // Keep the large implementation in index.js, but normalize the progressive
4
+ // tools switch here before Cordis reads the exported Config. Issue #81 showed
5
+ // that changing the tool schema mid-session can invalidate provider prefix/KV
6
+ // caches for very long conversations, so progressive exposure is now an
7
+ // explicit opt-in rather than the implicit fallback.
8
+
9
+ import z from '@deepseek-ai/schemastery'
10
+ import * as core from './index.js'
11
+ import { installVisionRouterFileLogging } from './lib/file-logger.js'
12
+
13
+ // Schemastery object schemas expose set() as the supported way to replace a
14
+ // field schema. This mutates the Config object that index.js itself later uses
15
+ // for the settings namespace, so composition config and settings validation
16
+ // agree on the same default.
17
+ core.Config.set('progressiveTools', z.boolean().default(false))
18
+
19
+ export * from './index.js'
20
+ export const Config = core.Config
21
+
22
+ // Defense in depth for direct/programmatic callers that invoke apply() without
23
+ // first running the Cordis Config resolver: only an explicit true enables the
24
+ // schema-changing progressive mode. The wrapped context changes only logger:
25
+ // every existing vision-router diagnostic still reaches the host logger and is
26
+ // also persisted to ~/.dsh/logs/vision-router/vision-router.log.
27
+ export function apply(ctx, config = {}) {
28
+ const logging = installVisionRouterFileLogging(ctx)
29
+ try {
30
+ const result = core.apply(logging.ctx, {
31
+ ...config,
32
+ progressiveTools: config.progressiveTools === true,
33
+ })
34
+ if (result && typeof result.then === 'function') {
35
+ return result.catch((error) => {
36
+ logging.logger.error(
37
+ 'vision-router: plugin apply failed: %s',
38
+ error && error.stack ? error.stack : error && error.message ? error.message : String(error),
39
+ )
40
+ throw error
41
+ })
42
+ }
43
+ return result
44
+ } catch (error) {
45
+ logging.logger.error(
46
+ 'vision-router: plugin apply failed: %s',
47
+ error && error.stack ? error.stack : error && error.message ? error.message : String(error),
48
+ )
49
+ throw error
50
+ }
51
+ }
package/index.js CHANGED
@@ -233,6 +233,15 @@ export const Config = z.object({
233
233
  tool: z.boolean().default(true),
234
234
  progressiveTools: z.boolean().default(true),
235
235
  autoActivateOnImage: z.boolean().default(true),
236
+ // Client-persisted UI state (issue #78): DSH Desktop serves the Web UI from
237
+ // a random port on every launch, so origin-scoped localStorage forgets the
238
+ // first-run onboarding dialog and it re-appeared on every boot. These keys
239
+ // live in the settings section (the profile settings file) instead; the
240
+ // client still mirrors best-effort copies into localStorage for downgrades.
241
+ // Both are internal to the client bundle — the settings card renders them
242
+ // nowhere, and the server half never reads them.
243
+ onboardingSeen: z.boolean().default(false),
244
+ visionGuideStep: z.string().default(''),
236
245
  artifactsDir: z.string().default('.dsh-vision-router/artifacts'),
237
246
  rewriteImages: z.boolean().default(true),
238
247
  downscale: z.boolean().default(true),
package/lib/client.js CHANGED
@@ -77,6 +77,14 @@ window.__ModuleLoader__.load({
77
77
  visionCapsLoading: '正在验证哪些模型真正支持图片输入…',
78
78
  visionCapsError: '视觉能力元数据暂时不可用;为防止误选,暂不提供用户视觉模型下拉。内置 OVH 免费兜底仍可用。',
79
79
  visionCapsFiltered: '视觉后端下拉只显示明确声明 image 输入的模型。',
80
+ visionCapsEmptyTitle: '没有可选的用户视觉模型',
81
+ visionCapsEmptyBody: '检测到 {count} 个用户模型,但它们都没有被 DSH 明确标记为支持图片,因此已被安全隐藏。',
82
+ visionCapsHiddenPrefix: '被隐藏的模型:',
83
+ visionCapsReasonMissingImage: '未声明 image',
84
+ visionCapsReasonUnverified: '无法验证图片能力',
85
+ visionCapsHiddenMore: '另有 {count} 个模型未显示',
86
+ visionCapsMissingImageHint: '如果这里有你刚在「设置 → 模型 → 添加自定义提供方」中添加的视觉模型,请在 $DSH_HOME/settings.yaml 为该模型补上 input: [text, image],或为整个提供方补上 defaultInput: [text, image]。DSH 当前 Web 表单不会写入这个字段。',
87
+ visionCapsRetry: '重新检测模型',
80
88
  chainInvalidCurrent: '当前保存的视觉后端不支持图片或无法验证,已从下拉列表隐藏,运行时也会跳过:',
81
89
  retryCatalog: '重试加载目录',
82
90
  advanced: '高级设置',
@@ -98,6 +106,8 @@ window.__ModuleLoader__.load({
98
106
  testConnecting: '测试中…',
99
107
  testOk: '连接正常',
100
108
  testFailed: '连接失败',
109
+ openLogFolder: '打开日志文件夹',
110
+ openLogFolderFailed: '无法打开日志文件夹',
101
111
  updateTitle: '版本更新',
102
112
  checkUpdate: '检查更新',
103
113
  updateChecking: '检查中…',
@@ -248,6 +258,14 @@ window.__ModuleLoader__.load({
248
258
  visionCapsLoading: 'Checking which models genuinely accept image input…',
249
259
  visionCapsError: 'Vision capability metadata is unavailable; user vision-model choices are hidden to prevent bad selections. The built-in OVH fallback still works.',
250
260
  visionCapsFiltered: 'The vision-backend dropdown only shows models that explicitly declare image input.',
261
+ visionCapsEmptyTitle: 'No selectable user vision models',
262
+ visionCapsEmptyBody: 'DSH reported {count} user models, but none are explicitly marked as accepting images, so Vision Router hid them safely.',
263
+ visionCapsHiddenPrefix: 'Hidden models:',
264
+ visionCapsReasonMissingImage: 'image input not declared',
265
+ visionCapsReasonUnverified: 'image capability could not be verified',
266
+ visionCapsHiddenMore: '{count} more models not shown',
267
+ visionCapsMissingImageHint: 'If one of these is a vision model you just added through Settings → Models → Add custom provider, add input: [text, image] to that model in $DSH_HOME/settings.yaml, or defaultInput: [text, image] to the provider. The current DSH Web form does not write this field.',
268
+ visionCapsRetry: 'Re-detect models',
251
269
  chainInvalidCurrent: 'This saved vision backend does not support images or could not be verified. It is hidden from the dropdown and skipped at runtime: ',
252
270
  retryCatalog: 'Retry catalog',
253
271
  advanced: 'Advanced settings',
@@ -269,6 +287,8 @@ window.__ModuleLoader__.load({
269
287
  testConnecting: 'Testing…',
270
288
  testOk: 'Connected',
271
289
  testFailed: 'Connection failed',
290
+ openLogFolder: 'Open logs folder',
291
+ openLogFolderFailed: 'Could not open logs folder',
272
292
  updateTitle: 'Updates',
273
293
  checkUpdate: 'Check for updates',
274
294
  updateChecking: 'Checking…',
@@ -385,9 +405,6 @@ window.__ModuleLoader__.load({
385
405
  .map((group) => {
386
406
  const models = (group && Array.isArray(group.models) ? group.models : []).filter((model) => {
387
407
  if (!model || typeof model.id !== 'string') return false
388
- // The built-in backend is defined by this plugin and always
389
- // declares image input. Keeping it visible while the capability
390
- // request is still loading avoids a blank default editor.
391
408
  if (group.id === 'vision-http') return false
392
409
  return !!(caps[group.id] && caps[group.id][model.id] && caps[group.id][model.id].image === true)
393
410
  })
@@ -396,6 +413,33 @@ window.__ModuleLoader__.load({
396
413
  .filter((group) => group && group.models.length > 0)
397
414
  }
398
415
 
416
+ function collectFilteredVisionBackends(groups, capabilities) {
417
+ const caps = capabilities && typeof capabilities === 'object' ? capabilities : {}
418
+ const hidden = []
419
+ for (const group of Array.isArray(groups) ? groups : []) {
420
+ if (
421
+ !group ||
422
+ typeof group.id !== 'string' ||
423
+ group.id === 'vision-http' ||
424
+ group.id === 'vision-chain' ||
425
+ group.id.endsWith('-vision')
426
+ ) continue
427
+ for (const model of Array.isArray(group.models) ? group.models : []) {
428
+ if (!model || typeof model.id !== 'string' || model.id === '') continue
429
+ const capability = caps[group.id] && caps[group.id][model.id]
430
+ if (capability && capability.image === true) continue
431
+ hidden.push({
432
+ provider: group.id,
433
+ model: model.id,
434
+ reason: capability && typeof capability.reason === 'string' ? capability.reason : undefined,
435
+ missingImageDeclaration:
436
+ !!capability && capability.reason === 'model metadata does not declare image input',
437
+ })
438
+ }
439
+ }
440
+ return hidden
441
+ }
442
+
399
443
  // ── field specs ──────────────────────────────────────────────────────────
400
444
  const TOGGLE_KEYS = ['routing', 'tool', 'autoWrapProviders', 'stealth']
401
445
  const ADVANCED_TOGGLE_KEYS = ['reverseRouting', 'rewriteImages', 'downscale', 'cache', 'freeFallback']
@@ -495,6 +539,10 @@ window.__ModuleLoader__.load({
495
539
  '.vr-chain-row{display:flex;align-items:center;gap:8px;margin:6px 0}' +
496
540
  '.vr-stealth-notice{color:var(--dsw-alias-label-warning,var(--dsw-alias-label-secondary))}' +
497
541
  '.vr-catalog-error{display:flex;align-items:center;gap:10px;flex-wrap:wrap}' +
542
+ '.vr-vision-empty{margin:8px 0 2px;padding:11px 12px;border:1px solid var(--dsw-alias-label-warning,var(--dsw-alias-border-l2));border-radius:9px;background:var(--dsw-alias-bg-module-platform);display:flex;flex-direction:column;gap:7px}' +
543
+ '.vr-vision-empty-title{font-size:12px;font-weight:650;color:var(--dsw-alias-label-primary);margin:0}' +
544
+ '.vr-vision-empty-list{margin:0;padding-left:18px;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1.6;word-break:break-word}' +
545
+ '.vr-vision-empty-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}' +
498
546
  '.vr-subheader{display:flex;align-items:center;justify-content:space-between;gap:8px;width:100%;padding:8px 0;border:none;background:none;cursor:pointer;font:inherit;color:var(--dsw-alias-label-primary);text-align:left}' +
499
547
  '.vr-group{content-visibility:auto;contain-intrinsic-size:auto 96px;border-top:1px solid var(--dsw-alias-border-l2);padding:10px 0 2px;display:flex;flex-direction:column;gap:8px}' +
500
548
  '.vr-group-title{font-size:12px;font-weight:600;color:var(--dsw-alias-label-tertiary);margin:0}' +
@@ -554,20 +602,116 @@ window.__ModuleLoader__.load({
554
602
  }
555
603
 
556
604
  const ONBOARDING_STORAGE_KEY = 'dsh-vision-router:onboarding:model-guide-v2'
605
+ // issue #78: DSH Desktop serves the Web UI from a random port on every
606
+ // launch (--port 0), so origin-scoped localStorage forgets everything
607
+ // between restarts and the first-run dialog re-appeared on every boot.
608
+ // The durable source of truth is now the `vision-router` settings section
609
+ // (the profile settings file, synced through ctx.settingsScope);
610
+ // localStorage survives only as a legacy/migration fallback for
611
+ // pre-v1.2.3 browsers and as a best-effort downgrade channel.
612
+ const ONBOARDING_SETTINGS_KEY = 'onboardingSeen'
557
613
  // The walkthrough has three steps: step1 (the session/text model selector
558
614
  // on the chat page), step2 (open Settings → Plugins → Vision Router), and
559
615
  // step3 (the highlighted vision chain, rendered by the settings card as a
560
616
  // callout). Only step1/step2 need persistence; step3 re-derives from the
561
617
  // card being on screen.
562
618
  const VISION_GUIDE_STORAGE_KEY = 'dsh-vision-router:guide:vision-backend-v2'
619
+ const VISION_GUIDE_SETTINGS_KEY = 'visionGuideStep'
563
620
  const VISION_GUIDE_EVENT = 'dsh-vision-router:vision-settings-guide'
564
621
  let visionGuidePrompt
565
622
  let visionGuideStepMemory
623
+ // Installed by apply(): a narrow, defensive view of the settings scope.
624
+ // { get, set, unset } never throw, and subscribe returns a disposer (or
625
+ // undefined when the scope is unavailable — the legacy paths take over).
626
+ let settingsPersistence
627
+ function installSettingsPersistence(scope) {
628
+ const readSection = () => {
629
+ try {
630
+ const snapshot = scope && scope.getSnapshot && scope.getSnapshot()
631
+ return snapshot && snapshot.value ? snapshot.value : undefined
632
+ } catch {
633
+ return undefined
634
+ }
635
+ }
636
+ settingsPersistence = {
637
+ get(field) {
638
+ const section = readSection()
639
+ return section ? section[field] : undefined
640
+ },
641
+ set(field, value) {
642
+ try {
643
+ const snapshot = scope && scope.getSnapshot && scope.getSnapshot()
644
+ if (!snapshot || !snapshot.writable || typeof scope.set !== 'function') return
645
+ void scope.set(field, value)
646
+ } catch {
647
+ // Read-only provider or unavailable scope: the localStorage
648
+ // fallback keeps this load functional.
649
+ }
650
+ },
651
+ unset(field) {
652
+ try {
653
+ const snapshot = scope && scope.getSnapshot && scope.getSnapshot()
654
+ if (!snapshot || !snapshot.writable || typeof scope.unset !== 'function') return
655
+ void scope.unset(field)
656
+ } catch {
657
+ // Same fallback reasoning as set().
658
+ }
659
+ },
660
+ subscribe(listener) {
661
+ try {
662
+ return scope && typeof scope.subscribe === 'function' ? scope.subscribe(listener) : undefined
663
+ } catch {
664
+ return undefined
665
+ }
666
+ },
667
+ }
668
+ }
669
+
670
+ function readOnboardingSeen() {
671
+ try {
672
+ if (settingsPersistence && settingsPersistence.get(ONBOARDING_SETTINGS_KEY) === true) return true
673
+ } catch {
674
+ // Fall through to the legacy origin-scoped marker.
675
+ }
676
+ try {
677
+ if (window.localStorage && window.localStorage.getItem(ONBOARDING_STORAGE_KEY) === 'seen') {
678
+ // Legacy value from before the settings-backed flag: migrate it so
679
+ // it survives desktop restarts, then honor it for this load.
680
+ if (settingsPersistence) settingsPersistence.set(ONBOARDING_SETTINGS_KEY, true)
681
+ return true
682
+ }
683
+ } catch {
684
+ // Best effort only.
685
+ }
686
+ return false
687
+ }
688
+ function rememberOnboardingSeen() {
689
+ try {
690
+ if (settingsPersistence) settingsPersistence.set(ONBOARDING_SETTINGS_KEY, true)
691
+ } catch {
692
+ // The dialog can still be dismissed for this page load.
693
+ }
694
+ try {
695
+ if (window.localStorage) window.localStorage.setItem(ONBOARDING_STORAGE_KEY, 'seen')
696
+ } catch {
697
+ // Best effort: the dialog can still be dismissed for this page load.
698
+ }
699
+ }
566
700
 
567
701
  function readVisionGuideStep() {
568
702
  try {
569
- const value = window.localStorage && window.localStorage.getItem(VISION_GUIDE_STORAGE_KEY)
703
+ const value = settingsPersistence && settingsPersistence.get(VISION_GUIDE_SETTINGS_KEY)
570
704
  if (value === 'step1' || value === 'step2') return value
705
+ } catch {
706
+ // Fall through to the legacy origin-scoped marker.
707
+ }
708
+ try {
709
+ const value = window.localStorage && window.localStorage.getItem(VISION_GUIDE_STORAGE_KEY)
710
+ if (value === 'step1' || value === 'step2') {
711
+ // Legacy value: migrate into settings for desktop durability.
712
+ if (settingsPersistence) settingsPersistence.set(VISION_GUIDE_SETTINGS_KEY, value)
713
+ return value
714
+ }
571
715
  } catch {
572
716
  // Fall through to page-memory state when storage access is blocked.
573
717
  }
@@ -575,6 +719,14 @@ window.__ModuleLoader__.load({
575
719
  }
576
720
  function writeVisionGuideStep(step) {
577
721
  visionGuideStepMemory = step === 'step1' || step === 'step2' ? step : undefined
722
+ try {
723
+ if (settingsPersistence) {
724
+ if (visionGuideStepMemory) settingsPersistence.set(VISION_GUIDE_SETTINGS_KEY, visionGuideStepMemory)
725
+ else settingsPersistence.unset(VISION_GUIDE_SETTINGS_KEY)
726
+ }
727
+ } catch {
728
+ // Page-memory state still keeps the guide functional for this load.
729
+ }
578
730
  try {
579
731
  if (!window.localStorage) return
580
732
  if (visionGuideStepMemory) window.localStorage.setItem(VISION_GUIDE_STORAGE_KEY, visionGuideStepMemory)
@@ -677,9 +829,13 @@ window.__ModuleLoader__.load({
677
829
  syncVisionGuidePrompt(t)
678
830
  window.addEventListener(VISION_GUIDE_EVENT, sync)
679
831
  window.addEventListener('popstate', sync)
832
+ // The durable step now lives in the settings section; re-sync when its
833
+ // snapshot changes (e.g. the first read resolves after page load).
834
+ const unsubscribe = settingsPersistence && settingsPersistence.subscribe(sync)
680
835
  return () => {
681
836
  window.removeEventListener(VISION_GUIDE_EVENT, sync)
682
837
  window.removeEventListener('popstate', sync)
838
+ if (typeof unsubscribe === 'function') unsubscribe()
683
839
  removeVisionGuidePrompt()
684
840
  }
685
841
  }
@@ -687,13 +843,6 @@ window.__ModuleLoader__.load({
687
843
  let onboardingOverlay
688
844
  let onboardingKeyDown
689
845
 
690
- function rememberOnboardingSeen() {
691
- try {
692
- if (window.localStorage) window.localStorage.setItem(ONBOARDING_STORAGE_KEY, 'seen')
693
- } catch {
694
- // Best effort: the dialog can still be dismissed for this page load.
695
- }
696
- }
697
846
  function dismissOnboarding(remember = true) {
698
847
  if (remember) rememberOnboardingSeen()
699
848
  if (typeof document !== 'undefined' && onboardingKeyDown) {
@@ -779,16 +928,33 @@ window.__ModuleLoader__.load({
779
928
  }
780
929
  function installOnboarding(t) {
781
930
  if (typeof document === 'undefined' || typeof window === 'undefined') return
782
- try {
783
- if (window.localStorage && window.localStorage.getItem(ONBOARDING_STORAGE_KEY) === 'seen') return
784
- } catch {
785
- // Privacy/storage restrictions should not prevent the guidance itself.
931
+ let timer
932
+ const clearTimer = () => {
933
+ if (timer !== undefined) window.clearTimeout(timer)
934
+ timer = undefined
786
935
  }
787
-
788
- const timer = window.setTimeout(() => showOnboarding(t), 650)
789
-
936
+ // Reactive against the settings scope: the settings snapshot arrives
937
+ // asynchronously on a cold page load, so a one-shot localStorage check
938
+ // could show the dialog before the durable "seen" flag lands. Re-sync
939
+ // on every snapshot change and dismiss the overlay if the flag appears.
940
+ const sync = () => {
941
+ if (readOnboardingSeen()) {
942
+ clearTimer()
943
+ if (onboardingOverlay) dismissOnboarding(false)
944
+ return
945
+ }
946
+ if (timer === undefined && !onboardingOverlay) {
947
+ timer = window.setTimeout(() => {
948
+ timer = undefined
949
+ if (!readOnboardingSeen()) showOnboarding(t)
950
+ }, 650)
951
+ }
952
+ }
953
+ sync()
954
+ const unsubscribe = settingsPersistence && settingsPersistence.subscribe(sync)
790
955
  return () => {
791
- if (timer !== undefined) window.clearTimeout(timer)
956
+ clearTimer()
957
+ if (typeof unsubscribe === 'function') unsubscribe()
792
958
  // Unmounting is not a user choice: drop the dialog without marking
793
959
  // the guide seen, so the first-run overview can still auto-open.
794
960
  if (onboardingOverlay) dismissOnboarding(false)
@@ -867,6 +1033,8 @@ window.__ModuleLoader__.load({
867
1033
  const [visionCaps, setVisionCaps] = useState({ status: 'idle', capabilities: {}, builtinFallback: [], anonymousRpmPerModel: 2, error: undefined })
868
1034
  const catalogReady = catalog.status === 'ready' && catalog.groups.length > 0
869
1035
  const visionGroups = filterVisionBackendGroups(catalog.groups, visionCaps.capabilities)
1036
+ const hiddenVisionBackends =
1037
+ visionCaps.status === 'ready' ? collectFilteredVisionBackends(catalog.groups, visionCaps.capabilities) : []
870
1038
  const visionModelsFor = (providerId) => {
871
1039
  const group = visionGroups.find((entry) => entry.id === providerId)
872
1040
  return group && Array.isArray(group.models) ? group.models : []
@@ -895,8 +1063,8 @@ window.__ModuleLoader__.load({
895
1063
  }, 220)
896
1064
  return () => window.clearTimeout(timer)
897
1065
  }, [guideActive, open, catalog.status, visionCaps.status])
898
- const loadCatalog = () => {
899
- if (catalog.status === 'loading' || catalog.status === 'ready') return
1066
+ const loadCatalog = (force = false) => {
1067
+ if (!force && (catalog.status === 'loading' || catalog.status === 'ready')) return
900
1068
  setCatalog({ status: 'loading', groups: [], error: undefined })
901
1069
  try {
902
1070
  const connection = props.getConnection ? props.getConnection() : undefined
@@ -943,8 +1111,8 @@ window.__ModuleLoader__.load({
943
1111
  })
944
1112
  }
945
1113
  }
946
- const loadVisionCapabilities = () => {
947
- if (visionCaps.status === 'loading' || visionCaps.status === 'ready') return
1114
+ const loadVisionCapabilities = (force = false) => {
1115
+ if (!force && (visionCaps.status === 'loading' || visionCaps.status === 'ready')) return
948
1116
  setVisionCaps({ status: 'loading', capabilities: {}, builtinFallback: [], anonymousRpmPerModel: 2, error: undefined })
949
1117
  fetch('/_dsh/vision-router/model-capabilities')
950
1118
  .then(async (response) => {
@@ -973,6 +1141,12 @@ window.__ModuleLoader__.load({
973
1141
  }),
974
1142
  )
975
1143
  }
1144
+ const retryVisionModels = () => {
1145
+ setCatalog({ status: 'idle', groups: [], error: undefined })
1146
+ setVisionCaps({ status: 'idle', capabilities: {}, builtinFallback: [], anonymousRpmPerModel: 2, error: undefined })
1147
+ loadCatalog(true)
1148
+ loadVisionCapabilities(true)
1149
+ }
976
1150
  let snapshot
977
1151
  let renderError
978
1152
  try {
@@ -1413,6 +1587,37 @@ window.__ModuleLoader__.load({
1413
1587
  h('p', { className: 'vr-hint' }, t('chainHint')),
1414
1588
  )
1415
1589
  }
1590
+ const emptyVisionModelsPanel = () => {
1591
+ if (!(catalogReady && visionCaps.status === 'ready' && visionGroups.length === 0 && hiddenVisionBackends.length > 0)) {
1592
+ return null
1593
+ }
1594
+ const preview = hiddenVisionBackends.slice(0, 8)
1595
+ const remaining = hiddenVisionBackends.length - preview.length
1596
+ const hasMissingDeclaration = hiddenVisionBackends.some((entry) => entry.missingImageDeclaration)
1597
+ return h('div', { className: 'vr-vision-empty' },
1598
+ h('p', { className: 'vr-vision-empty-title' }, t('visionCapsEmptyTitle')),
1599
+ h('p', { className: 'vr-hint' }, t('visionCapsEmptyBody', { count: hiddenVisionBackends.length })),
1600
+ h('p', { className: 'vr-hint' }, t('visionCapsHiddenPrefix')),
1601
+ h('ul', { className: 'vr-vision-empty-list' },
1602
+ preview.map((entry) =>
1603
+ h('li', { key: entry.provider + '/' + entry.model },
1604
+ entry.provider + '/' + entry.model + ' — ' +
1605
+ t(entry.missingImageDeclaration ? 'visionCapsReasonMissingImage' : 'visionCapsReasonUnverified')),
1606
+ ),
1607
+ remaining > 0 ? h('li', { key: 'more' }, t('visionCapsHiddenMore', { count: remaining })) : null,
1608
+ ),
1609
+ hasMissingDeclaration
1610
+ ? h('p', { className: 'vr-hint vr-stealth-notice' }, t('visionCapsMissingImageHint'))
1611
+ : null,
1612
+ h('div', { className: 'vr-vision-empty-actions' },
1613
+ h('button', {
1614
+ type: 'button', className: 'vr-btn',
1615
+ disabled: catalog.status === 'loading' || visionCaps.status === 'loading',
1616
+ onClick: retryVisionModels,
1617
+ }, t('visionCapsRetry')),
1618
+ ),
1619
+ )
1620
+ }
1416
1621
  const builtinFallbackPanel = () => {
1417
1622
  const list = Array.isArray(visionCaps.builtinFallback) ? visionCaps.builtinFallback : []
1418
1623
  const enabled = format('freeFallback') !== false
@@ -1708,6 +1913,7 @@ window.__ModuleLoader__.load({
1708
1913
  : visionCaps.status === 'ready'
1709
1914
  ? h('p', { className: 'vr-hint' }, t('visionCapsFiltered'))
1710
1915
  : null,
1916
+ emptyVisionModelsPanel(),
1711
1917
  catalogReady
1712
1918
  ? chainEditor()
1713
1919
  : h('div', {
@@ -1781,6 +1987,28 @@ window.__ModuleLoader__.load({
1781
1987
  ? `${t('testOk')}(${typeof testState.result.latencyMs === 'number' ? testState.result.latencyMs + 'ms' : 'ok'})`
1782
1988
  : `${t('testFailed')}:${testState.result && testState.result.error ? testState.result.error : 'unknown'}`)
1783
1989
  : null,
1990
+ h('button', {
1991
+ type: 'button', className: 'vr-btn',
1992
+ onClick: async () => {
1993
+ try {
1994
+ const response = await fetch('/_dsh/vision-router/logs', {
1995
+ method: 'POST',
1996
+ cache: 'no-store',
1997
+ })
1998
+ const result = await response.json().catch(() => undefined)
1999
+ if (!response.ok || !result || result.ok !== true) {
2000
+ throw new Error(result && result.error ? result.error : `HTTP ${response.status}`)
2001
+ }
2002
+ } catch (error) {
2003
+ if (typeof window.alert === 'function') {
2004
+ window.alert(
2005
+ t('openLogFolderFailed') + ':' +
2006
+ (error && error.message ? error.message : String(error)),
2007
+ )
2008
+ }
2009
+ }
2010
+ },
2011
+ }, t('openLogFolder')),
1784
2012
  h('button', {
1785
2013
  type: 'button', className: 'vr-btn', disabled: testState.status === 'running',
1786
2014
  onClick: runTestConnection,
@@ -1894,6 +2122,10 @@ window.__ModuleLoader__.load({
1894
2122
 
1895
2123
  function apply(ctx) {
1896
2124
  const scope = ctx.settingsScope.bind({ namespace: 'vision-router' })
2125
+ // issue #78: route the onboarding/guide durability through the settings
2126
+ // section (profile settings file) so it survives DSH Desktop's random
2127
+ // per-launch port. Installed before the effects below run.
2128
+ installSettingsPersistence(scope)
1897
2129
  const presentedImageUrls = new Map()
1898
2130
  const createdImageUrls = new Set()
1899
2131
  const loadPresentedImage = (sessionId, attachment) => {
@@ -2019,12 +2251,12 @@ window.__ModuleLoader__.load({
2019
2251
  () =>
2020
2252
  ctx.slots.inject('tool.call.toolview', function* () {
2021
2253
  yield ctx.slots.register(
2022
- { name: 'tool.call.toolview', key: 'vision_present', inject: () => ({}) },
2254
+ { name: 'tool.call.toolview', key: 'vision_present', priority: -10, inject: () => ({}) },
2023
2255
  VisionPresentCard,
2024
2256
  )
2025
2257
  for (const key of ARTIFACT_TOOL_KEYS) {
2026
2258
  yield ctx.slots.register(
2027
- { name: 'tool.call.toolview', key, inject: () => ({}) },
2259
+ { name: 'tool.call.toolview', key, priority: -10, inject: () => ({}) },
2028
2260
  ArtifactCard,
2029
2261
  )
2030
2262
  }
@@ -2037,6 +2269,7 @@ window.__ModuleLoader__.load({
2037
2269
  exports.inject = ['settingsScope', 'slots', 'locale', 'sessions']
2038
2270
  exports.unwrapModelsResult = unwrapModelsResult
2039
2271
  exports.filterVisionBackendGroups = filterVisionBackendGroups
2272
+ exports.collectFilteredVisionBackends = collectFilteredVisionBackends
2040
2273
  return module.exports
2041
2274
  },
2042
2275
  })
@@ -0,0 +1,273 @@
1
+ import { appendFile, mkdir, rename, rm, stat } from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import process from 'node:process'
4
+ import { execFile } from 'node:child_process'
5
+ import { format as formatArgs, promisify } from 'node:util'
6
+ import { createRequire } from 'node:module'
7
+ import { resolveDshHome } from './doctor.js'
8
+
9
+ export const LOG_FILE_NAME = 'vision-router.log'
10
+ export const LOG_BACKUP_FILE_NAME = 'vision-router.1.log'
11
+ export const DEFAULT_LOG_MAX_BYTES = 2 * 1024 * 1024
12
+
13
+ const execFileAsync = promisify(execFile)
14
+ const installs = new WeakMap()
15
+
16
+ export function resolveVisionRouterLogPaths(dshHome = resolveDshHome()) {
17
+ const directory = path.join(dshHome, 'logs', 'vision-router')
18
+ return {
19
+ directory,
20
+ file: path.join(directory, LOG_FILE_NAME),
21
+ backup: path.join(directory, LOG_BACKUP_FILE_NAME),
22
+ }
23
+ }
24
+
25
+ /**
26
+ * Diagnostics are intended to be shareable in bug reports. Existing runtime
27
+ * messages should not contain secrets, but redact common credential shapes as
28
+ * defense in depth before anything reaches disk.
29
+ */
30
+ export function sanitizeLogText(value) {
31
+ let text = String(value ?? '')
32
+ text = text.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]{8,}/gi, 'Bearer [REDACTED]')
33
+ text = text.replace(/\bsk-(?:proj-)?[A-Za-z0-9_-]{8,}\b/g, '[REDACTED_KEY]')
34
+ text = text.replace(
35
+ /([?&](?:api[_-]?key|access[_-]?token|token|key|auth)=)[^&\s]+/gi,
36
+ '$1[REDACTED]',
37
+ )
38
+ text = text.replace(
39
+ /\b(authorization|api[_-]?key|access[_-]?token)\s*[:=]\s*["']?[^\s"',}]+/gi,
40
+ '$1=[REDACTED]',
41
+ )
42
+ return text
43
+ }
44
+
45
+ export function createFileLogSink({
46
+ file,
47
+ backup,
48
+ maxBytes = DEFAULT_LOG_MAX_BYTES,
49
+ onError = () => {},
50
+ } = {}) {
51
+ let queue = Promise.resolve()
52
+ let initialized = false
53
+ let size = 0
54
+ let disabled = false
55
+ let reportedError = false
56
+
57
+ const reportError = (error) => {
58
+ if (reportedError) return
59
+ reportedError = true
60
+ try {
61
+ onError(error)
62
+ } catch {
63
+ // A diagnostics failure must never affect the plugin runtime.
64
+ }
65
+ }
66
+
67
+ const prepare = async () => {
68
+ if (initialized || disabled) return
69
+ await mkdir(path.dirname(file), { recursive: true })
70
+ try {
71
+ size = (await stat(file)).size
72
+ } catch (error) {
73
+ if (error && error.code !== 'ENOENT') throw error
74
+ size = 0
75
+ }
76
+ initialized = true
77
+ }
78
+
79
+ const rotate = async () => {
80
+ try {
81
+ await rm(backup, { force: true })
82
+ await rename(file, backup)
83
+ } catch (error) {
84
+ if (error && error.code !== 'ENOENT') throw error
85
+ }
86
+ size = 0
87
+ }
88
+
89
+ const write = (level, args) => {
90
+ if (disabled) return queue
91
+ const rendered = sanitizeLogText(formatArgs(...args))
92
+ const line = `[${new Date().toISOString()}] [${String(level).toUpperCase()}] ${rendered}\n`
93
+ const bytes = Buffer.byteLength(line)
94
+ queue = queue.then(async () => {
95
+ try {
96
+ await prepare()
97
+ if (size > 0 && size + bytes > maxBytes) await rotate()
98
+ await appendFile(file, line, { encoding: 'utf8', mode: 0o600 })
99
+ size += bytes
100
+ } catch (error) {
101
+ disabled = true
102
+ reportError(error)
103
+ }
104
+ })
105
+ return queue
106
+ }
107
+
108
+ return {
109
+ write,
110
+ flush: () => queue,
111
+ get disabled() {
112
+ return disabled
113
+ },
114
+ }
115
+ }
116
+
117
+ function packageVersion() {
118
+ try {
119
+ const require = createRequire(import.meta.url)
120
+ const manifest = require('../package.json')
121
+ return typeof manifest?.version === 'string' ? manifest.version : 'unknown'
122
+ } catch {
123
+ return 'unknown'
124
+ }
125
+ }
126
+
127
+ function teeLogger(baseLogger, sink) {
128
+ const levels = new Set(['debug', 'info', 'warn', 'error'])
129
+ return new Proxy(baseLogger ?? {}, {
130
+ get(target, property) {
131
+ if (levels.has(property)) {
132
+ return (...args) => {
133
+ const method = target && typeof target[property] === 'function' ? target[property] : undefined
134
+ if (method) {
135
+ try {
136
+ method.apply(target, args)
137
+ } catch {
138
+ // Keep the file logger from changing host logger semantics.
139
+ }
140
+ }
141
+ void sink.write(property, args)
142
+ }
143
+ }
144
+ const value = Reflect.get(target, property, target)
145
+ return typeof value === 'function' ? value.bind(target) : value
146
+ },
147
+ })
148
+ }
149
+
150
+ function contextWithLogger(ctx, logger) {
151
+ return new Proxy(ctx, {
152
+ get(target, property) {
153
+ if (property === 'logger') return logger
154
+ const value = Reflect.get(target, property, target)
155
+ return typeof value === 'function' ? value.bind(target) : value
156
+ },
157
+ })
158
+ }
159
+
160
+ export async function openLogDirectory(directory, {
161
+ platform = process.platform,
162
+ exec = execFileAsync,
163
+ } = {}) {
164
+ await mkdir(directory, { recursive: true })
165
+ if (platform === 'darwin') {
166
+ await exec('open', [directory], { timeout: 10_000, windowsHide: true })
167
+ return
168
+ }
169
+ if (platform === 'win32') {
170
+ await exec('explorer.exe', [directory], { timeout: 10_000, windowsHide: true })
171
+ return
172
+ }
173
+ await exec('xdg-open', [directory], { timeout: 10_000, windowsHide: true })
174
+ }
175
+
176
+ function sameOrigin(req) {
177
+ const origin = req?.headers?.origin
178
+ const host = req?.headers?.host
179
+ const fetchSite = req?.headers?.['sec-fetch-site']
180
+ if (fetchSite && fetchSite !== 'same-origin' && fetchSite !== 'none') return false
181
+ if (!origin) return true
182
+ if (!host) return false
183
+ try {
184
+ return new URL(origin).host === host
185
+ } catch {
186
+ return false
187
+ }
188
+ }
189
+
190
+ function sendJson(res, status, body) {
191
+ res.writeHead(status, {
192
+ 'content-type': 'application/json; charset=utf-8',
193
+ 'cache-control': 'no-store',
194
+ })
195
+ res.end(JSON.stringify(body))
196
+ }
197
+
198
+ function installLogRoute(ctx, paths) {
199
+ ctx.inject(['webServer'], (webCtx) => {
200
+ webCtx.effect(() => webCtx.webServer.register({
201
+ kind: 'exact',
202
+ path: '/_dsh/vision-router/logs',
203
+ handler: async (req, res) => {
204
+ if (req.method === 'GET') {
205
+ sendJson(res, 200, { ok: true, directory: paths.directory, file: paths.file })
206
+ return
207
+ }
208
+ if (req.method !== 'POST') {
209
+ res.setHeader('Allow', 'GET, POST')
210
+ sendJson(res, 405, { ok: false, error: 'method not allowed' })
211
+ return
212
+ }
213
+ if (!sameOrigin(req)) {
214
+ sendJson(res, 403, { ok: false, error: 'cross-origin request rejected' })
215
+ return
216
+ }
217
+ try {
218
+ await openLogDirectory(paths.directory)
219
+ sendJson(res, 200, { ok: true, directory: paths.directory, file: paths.file })
220
+ } catch (error) {
221
+ sendJson(res, 500, {
222
+ ok: false,
223
+ directory: paths.directory,
224
+ file: paths.file,
225
+ error: error && error.message ? error.message : String(error),
226
+ })
227
+ }
228
+ },
229
+ }), 'vision-router: diagnostics log route')
230
+ })
231
+ }
232
+
233
+ /**
234
+ * Return a Cordis context whose logger tees this plugin's existing diagnostics
235
+ * to a small rotating file while preserving the host logger. The original
236
+ * context is otherwise untouched.
237
+ */
238
+ export function installVisionRouterFileLogging(ctx, options = {}) {
239
+ if (ctx && typeof ctx === 'object' && installs.has(ctx)) return installs.get(ctx)
240
+
241
+ const paths = resolveVisionRouterLogPaths(options.dshHome)
242
+ const baseLogger = ctx?.logger
243
+ const sink = createFileLogSink({
244
+ file: paths.file,
245
+ backup: paths.backup,
246
+ maxBytes: options.maxBytes ?? DEFAULT_LOG_MAX_BYTES,
247
+ onError: (error) => {
248
+ try {
249
+ baseLogger?.warn?.(
250
+ 'vision-router: diagnostics file logging disabled: %s',
251
+ error && error.message ? error.message : String(error),
252
+ )
253
+ } catch {
254
+ // Logging failure remains non-fatal.
255
+ }
256
+ },
257
+ })
258
+ const logger = teeLogger(baseLogger, sink)
259
+ const wrappedCtx = contextWithLogger(ctx, logger)
260
+ const installed = { ctx: wrappedCtx, logger, sink, ...paths }
261
+ if (ctx && typeof ctx === 'object') installs.set(ctx, installed)
262
+
263
+ installLogRoute(ctx, paths)
264
+ logger.info(
265
+ 'vision-router: diagnostics log enabled at %s (plugin=%s node=%s platform=%s/%s)',
266
+ paths.file,
267
+ packageVersion(),
268
+ process.version,
269
+ process.platform,
270
+ process.arch,
271
+ )
272
+ return installed
273
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-vision-router",
3
- "version": "1.2.2",
3
+ "version": "1.3.0",
4
4
  "description": "Eyes for text-only DeepSeek Harness agents: built-in free vision chain (no key) + pixel-level vision tools (Q&A, grounding, crop, pixel diff, colors, OCR, SVG trace, cutout, screenshots). One-command install, no Python, image turns work like ordinary tool-calling turns.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -9,12 +9,12 @@
9
9
  },
10
10
  "homepage": "https://github.com/ysr666/dsh-vision-router",
11
11
  "type": "module",
12
- "main": "index.js",
12
+ "main": "entry.js",
13
13
  "bin": {
14
14
  "dsh-vision-router": "./lib/doctor-cli.js"
15
15
  },
16
16
  "exports": {
17
- ".": "./index.js",
17
+ ".": "./entry.js",
18
18
  "./client": "./lib/client.js",
19
19
  "./package.json": "./package.json",
20
20
  "./cordis.patch.yml": "./cordis.patch.yml"
@@ -26,6 +26,7 @@
26
26
  "assets",
27
27
  "cordis.patch.yml",
28
28
  "docs",
29
+ "entry.js",
29
30
  "index.js",
30
31
  "lib",
31
32
  "presets"
@@ -50,18 +51,20 @@
50
51
  "@deepseek-ai/schemastery": ">=3.18.0",
51
52
  "potrace": "^2.1.8",
52
53
  "puppeteer-core": "^25.6.0",
53
- "undici": ">=6.15.0",
54
- "@deepseek-ai/dsh-llm-deepseek": "^0.1.0-rc.6",
55
- "@deepseek-ai/dsh-anonymous-user-id": "^0.1.0-rc.6"
54
+ "undici": ">=6.15.0"
56
55
  },
57
56
  "peerDependencies": {
57
+ "@deepseek-ai/dsh-anonymous-user-id": "^0.1.0-rc.6",
58
+ "@deepseek-ai/dsh-llm-deepseek": "^0.1.0-rc.6",
58
59
  "sharp": ">=0.35.3 <1"
59
60
  },
60
61
  "devDependencies": {
62
+ "@deepseek-ai/dsh-anonymous-user-id": "^0.1.0-rc.6",
63
+ "@deepseek-ai/dsh-llm-deepseek": "^0.1.0-rc.6",
61
64
  "sharp": "^0.35.3"
62
65
  },
63
66
  "scripts": {
64
- "test": "node --test tests/core.test.js tests/client.test.js tests/http-compat.test.js tests/update-check.test.js tests/self-update.test.js tests/doctor.test.js"
67
+ "test": "node --test tests/core.test.js tests/client.test.js tests/http-compat.test.js tests/update-check.test.js tests/self-update.test.js tests/doctor.test.js tests/bundle-defaults.test.js tests/file-logger.test.js tests/logging-ui.test.js tests/manifest-dependencies.test.js"
65
68
  },
66
69
  "pnpm": {
67
70
  "onlyBuiltDependencies": [
package/presets/README.md CHANGED
@@ -4,6 +4,9 @@
4
4
  把它合并进你自己的 `settings.yaml`(或复制整个段),填一个 `apiKeyEnv` 对应的 Key 即可。
5
5
  **仓库绝不内置任何第三方 Key**。
6
6
 
7
+ > [!IMPORTANT]
8
+ > DSH 当前的 Web「设置 → 模型 → 添加自定义提供方」表单不会写入图片输入能力元数据。即使端点实际是智谱 GLM-4.6V、Qwen-VL 等视觉模型,如果模型没有显式的 `input: [text, image]`(或提供方没有 `defaultInput: [text, image]`),DSH 仍会把它报告为纯文本模型,Vision Router 会为安全起见从视觉后端下拉中隐藏它。下面这些 preset 已经带好 `input` 声明;如果你是通过 Web 手动添加的自定义视觉提供方,请在 `$DSH_HOME/settings.yaml` 补上该字段。
9
+
7
10
  - `dashscope.yaml` —— 阿里云百炼,大陆直连,新用户每系列 100 万 token/90 天(推荐首选)
8
11
  - `zhipu.yaml` —— 智谱 bigmodel.cn,`glm-4.6v-flash` 永久免费(provider key 用 `zhipu`,避免与 pi-ai 内置目录里的 `zai` 编码端点冲突)
9
12
  - `siliconflow.yaml` —— 硅基流动,¥14 赠金覆盖 Qwen2.5-VL