dsh-vision-router 1.2.1 → 1.2.3

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
@@ -6,7 +6,7 @@
6
6
 
7
7
  <p align="center"><strong>Paste an image and it just works — eyes for text-only agents on DeepSeek Harness. Free out of the box, no key, no Python, one command.</strong></p>
8
8
 
9
- <p align="center">DeepSeek keeps thinking; the built-in free vision chain and ten pixel-level tools do the seeing. Image turns behave like ordinary tool-calling turns — grounded, measurable, repeatable.</p>
9
+ <p align="center">DeepSeek keeps thinking; the built-in free vision chain and eleven pixel-level tools do the seeing. Image turns behave like ordinary tool-calling turns — grounded, measurable, repeatable.</p>
10
10
 
11
11
  <p align="center">
12
12
  <a href="https://awesome-dsh-plugin.com"><img src="https://awesome-dsh-plugin.com/badge.svg" alt="awesome · DSH plugin" /></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.1)**
31
+ > 📌 **Announcement (v1.2.3)**
32
32
  >
33
- > v1.2.1 hardens the pixel loop: all ten 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.
33
+ > 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).
34
+ >
35
+ > 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).
36
+ >
37
+ > 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.
34
38
 
35
39
  <p align="center">
36
40
  <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" />
@@ -125,7 +129,7 @@ The built-in anonymous OVH vision fallback is already configured, so normal imag
125
129
  - **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.
126
130
  - **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.
127
131
  - **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.
128
- - **Progressive schema exposure.** Only a zero-arg `vision_activate` bootstrap is always visible; image turns auto-mount all ten deep tools with a one-time usage note, and a `vision-tools` skill is registered for text-only turns.
132
+ - **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.
129
133
  - **Selective proxy.** Only the configured vision provider hosts go through your local proxy; DeepSeek stays direct.
130
134
 
131
135
  ### Pixel loop in practice
@@ -146,10 +150,10 @@ The vision model is **only the eyes**; DeepSeek is **always the brain**. An imag
146
150
 
147
151
  ## Tools
148
152
 
149
- All ten 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:
153
+ 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:
150
154
 
151
155
  <p align="center">
152
- <img src="assets/vision-tools.svg" width="100%" alt="Nine vision tools available in DSH Vision Router." />
156
+ <img src="assets/vision-tools.svg" width="100%" alt="Eleven vision tools available in DSH Vision Router." />
153
157
  </p>
154
158
 
155
159
  | Tool | What it does | Artifact |
@@ -340,6 +344,15 @@ Settings live in the profile's settings provider and survive upgrades.
340
344
  > # your overrides …
341
345
  > ```
342
346
 
347
+ > **After upgrading from v1.1.x, pixel tools fail with
348
+ > `colourspace: parameter space not set`:** a stale sharp 0.34.0 from the
349
+ > v1.1.0 era still sits in the profile and its libvips DLL conflicts with the
350
+ > host's sharp 0.35.3 in the same process (issues #42 / #75). Delete
351
+ > `~/.dsh/profiles/<profile>/node_modules/sharp` and
352
+ > `~/.dsh/profiles/<profile>/node_modules/@img` and restart, or run
353
+ > `pnpm install` inside the profile. Since v1.2.2 the plugin detects the
354
+ > stale version at runtime and prints the same guidance itself.
355
+
343
356
  ### Uninstall
344
357
 
345
358
  ```sh
package/README.zh.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  <p align="center"><strong>图片粘贴即用:给 DeepSeek Harness 的纯文本 Agent 装上“眼睛”——开箱免费、免 Key、无 Python、一条命令安装。</strong></p>
8
8
 
9
- <p align="center">DeepSeek 只负责思考,内置免费视觉链 + 10 个像素级工具负责“看”;图片轮次就像普通工具调用一样自然、可定位、可验证。</p>
9
+ <p align="center">DeepSeek 只负责思考,内置免费视觉链 + 11 个像素级工具负责“看”;图片轮次就像普通工具调用一样自然、可定位、可验证。</p>
10
10
 
11
11
  <p align="center">
12
12
  <a href="https://awesome-dsh-plugin.com"><img src="https://awesome-dsh-plugin.com/badge.svg" alt="awesome · DSH plugin" /></a>
@@ -28,9 +28,13 @@
28
28
  <p align="center">💬 <strong>QQ 用户交流群:1105463028</strong></p>
29
29
 
30
30
  > [!WARNING]
31
- > 📌 **公告(v1.2.1)**
31
+ > 📌 **公告(v1.2.3)**
32
32
  >
33
- > v1.2.1 加固像素闭环:十个像素工具可直接接受上传图片的附件 ID(告别 `cannot read …/sha256:…` 的绕路),产物文件名带指纹不再互相覆盖,`vision_ground` 对退化框自动重试,模型引导支持从第 1 步完整重放(先退出设置页),设置卡片在数百模型目录下滚动依然流畅。
33
+ > v1.2.3 现已支持:修复 DSH Desktop 每次启动都重复弹出首次引导的问题——引导「已读」标记与模型引导步骤改存 profile 设置文件(不再依赖按 origin 隔离、每次随机端口重启即清零的 `localStorage`)(issue #78)。
34
+ >
35
+ > 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)。
36
+ >
37
+ > v1.2.1 加固像素闭环:十一个像素工具可直接接受上传图片的附件 ID(告别 `cannot read …/sha256:…` 的绕路),产物文件名带指纹不再互相覆盖,`vision_ground` 对退化框自动重试,模型引导支持从第 1 步完整重放(先退出设置页),设置卡片在数百模型目录下滚动依然流畅。
34
38
 
35
39
  <p align="center">
36
40
  <img src="assets/vision-demo.gif" width="640" alt="演示:粘贴图片,Agent 用 vision_ground / vision_crop / vision_pixel_diff 定位发送按钮并给出坐标" />
@@ -125,7 +129,7 @@ opencode-go + 自动识图 ← 发图片时选这个
125
129
  - **自动降级 + 分类报错。** 地区限制、ToS 风控、402 额度、429 限流(尊重 Retry-After 退避重试)、上下文超长、网络故障——链路逐供应商尝试,全部失败才报错并给出可操作的建议。
126
130
  - **图片记忆。** 视觉答案按附件内容哈希缓存;后续文字轮用记录的描述替换历史图片(标注为不可信证据),DeepSeek 真正“记得”之前发过的图,且不重复消耗视觉调用。
127
131
  - **可验证的像素闭环。** 参照图 → `vision_html_screenshot` → `vision_pixel_diff`(差异率 + 红色热力图 + 最差区域排行)→ 修复 → 再对比,直到差异收敛。UI 还原从“目测”变成“实测”。
128
- - **渐进式 schema 暴露。** 平时只有一个零参引导工具 `vision_activate`;图片轮自动挂载全部 10 个深看工具(附一次性使用提示),并为纯文字轮注册 `vision-tools` 技能。
132
+ - **渐进式 schema 暴露。** 平时只有一个零参引导工具 `vision_activate`;图片轮自动挂载全部 11 个深看工具(附一次性使用提示),并为纯文字轮注册 `vision-tools` 技能。
129
133
  - **选择性代理。** 只有配置的视觉供应商域名走本地代理;DeepSeek 保持直连。
130
134
 
131
135
  ### 像素闭环实测
@@ -146,10 +150,10 @@ Agent 仅根据参考图复刻 UI,再用 `vision_pixel_diff` 验证最终结
146
150
 
147
151
  ## 工具
148
152
 
149
- 10 个深看工具在图片轮自动挂载(`autoActivateOnImage`);文字轮可通过 `vision_activate` 或 `/vision-tools` 技能挂载。全部基于 sharp / potrace / tesseract / 系统 Chrome——无 Python:
153
+ 11 个深看工具在图片轮自动挂载(`autoActivateOnImage`);文字轮可通过 `vision_activate` 或 `/vision-tools` 技能挂载。全部基于 sharp / potrace / tesseract / 系统 Chrome——无 Python:
150
154
 
151
155
  <p align="center">
152
- <img src="assets/vision-tools-zh.svg" width="100%" alt="DSH Vision Router 的 10 个视觉工具。" />
156
+ <img src="assets/vision-tools-zh.svg" width="100%" alt="DSH Vision Router 的 11 个视觉工具。" />
153
157
  </p>
154
158
 
155
159
  | 工具 | 作用 | 产物 |
@@ -338,6 +342,14 @@ pnpm dsh plugin --profile web update dsh-vision-router
338
342
  > # 你的配置…
339
343
  > ```
340
344
 
345
+ > **从 v1.1.x 升级后像素工具报 `colourspace: parameter space not set`:**
346
+ > 这是 v1.1.0 时代自带的 sharp 0.34.0 残留在 profile 里、与宿主 sharp 0.35.3
347
+ > 同进程 DLL 冲突所致(issue #42 / #75)。删除
348
+ > `~/.dsh/profiles/<profile>/node_modules/sharp` 与
349
+ > `~/.dsh/profiles/<profile>/node_modules/@img` 后重启,或在 profile 目录执行
350
+ > `pnpm install` 重装依赖即可。v1.2.2 起插件会在检测到残留版本时直接告警并
351
+ > 给出同样的指引。
352
+
341
353
  ### 卸载
342
354
 
343
355
  ```sh
@@ -1,4 +1,4 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" width="1440" height="1080" viewBox="0 0 1440 1080">
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="1440" height="1140" viewBox="0 0 1440 1140">
2
2
  <defs><linearGradient id="bg" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#070914"/><stop offset="1" stop-color="#09111a"/></linearGradient><linearGradient id="a" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#8a68ff"/><stop offset="1" stop-color="#39d6ff"/></linearGradient><pattern id="d" width="28" height="28" patternUnits="userSpaceOnUse"><circle cx="2" cy="2" r="1" fill="#73809a" opacity=".1"/></pattern></defs>
3
- <rect width="1440" height="1080" rx="28" fill="url(#bg)"/><rect width="1440" height="1080" rx="28" fill="url(#d)"/><rect x="1" y="1" width="1438" height="1078" rx="27" fill="none" stroke="#6554ff" opacity=".32"/>
4
- <g font-family="system-ui,Segoe UI,Microsoft YaHei,Arial,sans-serif"><text x="72" y="75" fill="#f5f7fc" font-size="42" font-weight="800">DSH <tspan fill="#8768ff">Vision</tspan> <tspan fill="#4acfff">Router</tspan></text><text x="72" y="108" fill="#818ba0" font-size="15">github.com/ysr666/dsh-vision-router</text><text x="720" y="170" text-anchor="middle" fill="url(#a)" font-size="52" font-weight="800">10 个视觉工具</text><text x="720" y="205" text-anchor="middle" fill="#8d97aa" font-size="17">基于 sharp · potrace · tesseract · system Chrome</text><rect x="50" y="245" width="430" height="240" rx="20" fill="#0c111c" stroke="#44356f"/><circle cx="72" cy="279" r="18" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="72" y="279" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-size="17" font-weight="800">1</text><text x="118" y="286" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="18" font-weight="700">vision_describe</text><rect x="505" y="245" width="430" height="240" rx="20" fill="#0c111c" stroke="#265b73"/><circle cx="527" cy="279" r="18" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="527" y="279" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-size="17" font-weight="800">2</text><text x="573" y="286" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="18" font-weight="700">vision_ground</text><rect x="960" y="245" width="430" height="240" rx="20" fill="#0c111c" stroke="#265b73"/><circle cx="982" cy="279" r="18" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="982" y="279" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-size="17" font-weight="800">3</text><text x="1028" y="286" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="18" font-weight="700">vision_crop</text><rect x="50" y="505" width="430" height="240" rx="20" fill="#0c111c" stroke="#44356f"/><circle cx="72" cy="539" r="18" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="72" y="539" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-size="17" font-weight="800">4</text><text x="118" y="546" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="18" font-weight="700">vision_pixel_diff</text><rect x="505" y="505" width="430" height="240" rx="20" fill="#0c111c" stroke="#265b73"/><circle cx="527" cy="539" r="18" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="527" y="539" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-size="17" font-weight="800">5</text><text x="573" y="546" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="18" font-weight="700">vision_colors</text><rect x="960" y="505" width="430" height="240" rx="20" fill="#0c111c" stroke="#265b73"/><circle cx="982" cy="539" r="18" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="982" y="539" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-size="17" font-weight="800">6</text><text x="1028" y="546" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="18" font-weight="700">vision_ocr</text><rect x="50" y="765" width="430" height="240" rx="20" fill="#0c111c" stroke="#44356f"/><circle cx="72" cy="799" r="18" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="72" y="799" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-size="17" font-weight="800">7</text><text x="118" y="806" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="18" font-weight="700">vision_trace</text><rect x="505" y="765" width="430" height="240" rx="20" fill="#0c111c" stroke="#265b73"/><circle cx="527" cy="799" r="18" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="527" y="799" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-size="17" font-weight="800">8</text><text x="573" y="806" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="18" font-weight="700">vision_extract_foreground</text><rect x="960" y="765" width="430" height="240" rx="20" fill="#0c111c" stroke="#265b73"/><circle cx="982" cy="799" r="18" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="982" y="799" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-size="17" font-weight="800">9</text><text x="1028" y="806" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="18" font-weight="700">vision_html_screenshot</text><rect x="75" y="315" width="205" height="140" rx="10" fill="#102437"/><circle cx="225" cy="347" r="22" fill="#f0d38b"/><path d="M75 431l70-66 45 39 31-30 59 57" fill="#1d5264"/><path d="M75 445l77-62 35 30 42-38 51 70" fill="#18344d"/><rect x="300" y="320" width="150" height="50" rx="10" fill="#151525" stroke="#42366e"/><text x="314" y="342" fill="#a98cff" font-size="12">问 这张图片里</text><text x="314" y="359" fill="#c8cede" font-size="12"> 有什么?</text><rect x="300" y="382" width="150" height="68" rx="10" fill="#111925" stroke="#334054"/><text x="314" y="405" fill="#7fe0bd" font-size="12">答 山间湖泊</text><text x="314" y="423" fill="#bfc7d6" font-size="12"> 松树与云</text><rect x="535" y="315" width="370" height="140" rx="10" fill="#101620" stroke="#293244"/><rect x="550" y="330" width="100" height="110" rx="7" fill="#151b27"/><text x="565" y="354" fill="#7f899c" font-size="11">仪表盘</text><rect x="558" y="368" width="80" height="27" rx="6" fill="#251d45"/><text x="570" y="386" fill="#b99cff" font-size="11">项目</text><text x="565" y="414" fill="#7f899c" font-size="11">分析</text><rect x="670" y="337" width="205" height="94" rx="8" fill="#111824"/><text x="686" y="360" fill="#e5e9f1" font-size="15" font-weight="700">项目</text><rect x="720" y="374" width="115" height="40" rx="6" fill="#161d29" stroke="#48dc8e" stroke-width="2"/><circle cx="720" cy="374" r="4" fill="#48dc8e"/><circle cx="835" cy="374" r="4" fill="#48dc8e"/><circle cx="720" cy="414" r="4" fill="#48dc8e"/><circle cx="835" cy="414" r="4" fill="#48dc8e"/><text x="746" y="399" fill="#c7a6ff" font-size="11">新建项目</text><rect x="990" y="315" width="175" height="140" rx="10" fill="#161c2b"/><path d="M1000 435l55-87 45 68 55-45" fill="none" stroke="#d37e46" stroke-width="5"/><rect x="1030" y="335" width="90" height="95" fill="none" stroke="#a987ff" stroke-width="3" stroke-dasharray="8 6"/><circle cx="1030" cy="335" r="5" fill="#b293ff"/><circle cx="1120" cy="335" r="5" fill="#b293ff"/><circle cx="1030" cy="430" r="5" fill="#b293ff"/><circle cx="1120" cy="430" r="5" fill="#b293ff"/><text x="1182" y="392" fill="#886cff" font-size="28">→</text><rect x="1220" y="315" width="145" height="140" rx="10" fill="#201d25"/><path d="M1230 440l45-100 50 83 30-50" fill="none" stroke="#d37e46" stroke-width="6"/><rect x="75" y="575" width="125" height="135" rx="9" fill="#121824" stroke="#344052"/><rect x="215" y="575" width="125" height="135" rx="9" fill="#121824" stroke="#344052"/><text x="92" y="603" fill="#d8deea" font-size="12">基线图像</text><text x="92" y="635" fill="#fff" font-size="23">$29/月</text><rect x="91" y="662" width="92" height="26" rx="6" fill="#226bd1"/><text x="115" y="680" fill="#fff" font-size="10">开始使用</text><text x="232" y="603" fill="#d8deea" font-size="12">当前图像</text><text x="232" y="635" fill="#fff" font-size="23">$29/月</text><rect x="231" y="662" width="92" height="26" rx="6" fill="#3eaa67"/><text x="255" y="680" fill="#fff" font-size="10">开始使用</text><text x="355" y="649" fill="#886cff" font-size="28">→</text><rect x="390" y="575" width="65" height="135" rx="8" fill="#0f1118" stroke="#553445"/><g fill="#f0444f"><rect x="400" y="590" width="14" height="18"/><rect x="430" y="612" width="17" height="20"/><rect x="408" y="645" width="22" height="22"/><rect x="435" y="670" width="12" height="27"/></g><rect x="535" y="575" width="370" height="75" rx="10" fill="#35203c"/><circle cx="840" cy="610" r="30" fill="#f2a34e"/><path d="M535 640l85-48 72 40 74-55 139 63" fill="#1e2a44"/><g><rect x="545" y="668" width="62" height="42" rx="6" fill="#1e2a44"/><rect x="616" y="668" width="62" height="42" rx="6" fill="#3b4d7a"/><rect x="687" y="668" width="62" height="42" rx="6" fill="#6b7fcb"/><rect x="758" y="668" width="62" height="42" rx="6" fill="#f29d49"/><rect x="829" y="668" width="62" height="42" rx="6" fill="#ffd6a6"/></g><g fill="#9da6b8" font-family="monospace" font-size="9"><text x="548" y="724">#1E2A44</text><text x="619" y="724">#3B4D7A</text><text x="690" y="724">#6B7FCB</text><text x="761" y="724">#F29D49</text><text x="832" y="724">#FFD6A6</text></g><rect x="990" y="575" width="175" height="135" rx="10" fill="#e8e8e5" stroke="#5f6670"/><text x="1077" y="607" text-anchor="middle" fill="#111827" font-size="18" font-weight="800">PARKING</text><text x="1077" y="633" text-anchor="middle" fill="#111827" font-size="18" font-weight="800">RESERVED</text><text x="1077" y="659" text-anchor="middle" fill="#111827" font-size="20" font-weight="800">24/7</text><text x="1182" y="646" fill="#886cff" font-size="28">→</text><rect x="1220" y="575" width="145" height="135" rx="10" fill="#0d1518" stroke="#28483a"/><text x="1235" y="610" fill="#58d99a" font-family="monospace" font-size="14">PARKING</text><text x="1235" y="635" fill="#58d99a" font-family="monospace" font-size="14">RESERVED</text><text x="1235" y="660" fill="#58d99a" font-family="monospace" font-size="14">24/7</text><rect x="75" y="835" width="165" height="140" rx="10" fill="#111827" stroke="#2d3b55"/><path d="M101 940l45-82 59 40-58 61z" fill="#d8e1f2"/><circle cx="165" cy="890" r="12" fill="#3d87c7"/><path d="M118 949l-13 19 21-10" fill="#ff633f"/><text x="255" y="920" fill="#886cff" font-size="28">→</text><rect x="300" y="835" width="150" height="140" rx="10" fill="#0f1420" stroke="#2d3b55"/><path d="M322 948l45-91 57 42-62 63z" fill="none" stroke="url(#a)" stroke-width="3"/><circle cx="386" cy="889" r="13" fill="none" stroke="#43d6ff" stroke-width="3"/><rect x="535" y="835" width="170" height="140" rx="10" fill="#262a36"/><path d="M590 890h62l13 52h-90z" fill="#606672"/><path d="M580 942v25M658 942v25" stroke="#7b533a" stroke-width="8"/><text x="720" y="920" fill="#886cff" font-size="28">→</text><rect x="760" y="835" width="145" height="140" rx="10" fill="#ddd"/><g fill="#bfc3c8"><rect x="760" y="835" width="29" height="28"/><rect x="818" y="835" width="29" height="28"/><rect x="876" y="835" width="29" height="28"/><rect x="789" y="863" width="29" height="28"/><rect x="847" y="863" width="29" height="28"/></g><path d="M803 890h60l12 52h-86z" fill="#555b67"/><path d="M793 942v25M868 942v25" stroke="#7b533a" stroke-width="8"/><rect x="990" y="835" width="375" height="140" rx="10" fill="#101621" stroke="#344054"/><rect x="990" y="835" width="375" height="25" rx="10" fill="#171d2a"/><circle cx="1005" cy="847" r="4" fill="#ff665a"/><circle cx="1018" cy="847" r="4" fill="#f0b85b"/><circle cx="1031" cy="847" r="4" fill="#50d890"/><text x="1060" y="851" fill="#8c96a9" font-family="monospace" font-size="10">http://localhost:8080</text><rect x="1004" y="873" width="95" height="86" rx="6" fill="#17132b"/><text x="1018" y="894" fill="#9e8cff" font-size="10">概览</text><text x="1018" y="918" fill="#8d96a8" font-size="10">报表</text><text x="1018" y="942" fill="#8d96a8" font-size="10">设置</text><rect x="1113" y="873" width="105" height="62" rx="6" fill="#151b28"/><rect x="1231" y="873" width="118" height="62" rx="6" fill="#151b28"/><text x="1125" y="895" fill="#7f899d" font-size="9">用户数</text><text x="1125" y="920" fill="#f4f7fb" font-size="18">12,540</text><path d="M1120 956c50-23 82 6 126-22 34-21 56 4 96-36" fill="none" stroke="url(#a)" stroke-width="3"/><rect x="50" y="1018" width="1340" height="52" rx="14" fill="#0c111c" stroke="#44356f"/><circle cx="72" cy="1044" r="16" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="72" y="1044" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-size="15" font-weight="800">10</text><text x="108" y="1050" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="16" font-weight="700">vision_detect</text><text x="420" y="1050" fill="#818ba0" font-size="14">元素清单:按类盘点页面元素,编号 + 原图像素框</text></g></svg>
3
+ <rect width="1440" height="1140" rx="28" fill="url(#bg)"/><rect width="1440" height="1140" rx="28" fill="url(#d)"/><rect x="1" y="1" width="1438" height="1138" rx="27" fill="none" stroke="#6554ff" opacity=".32"/>
4
+ <g font-family="system-ui,Segoe UI,Microsoft YaHei,Arial,sans-serif"><text x="72" y="75" fill="#f5f7fc" font-size="42" font-weight="800">DSH <tspan fill="#8768ff">Vision</tspan> <tspan fill="#4acfff">Router</tspan></text><text x="72" y="108" fill="#818ba0" font-size="15">github.com/ysr666/dsh-vision-router</text><text x="720" y="170" text-anchor="middle" fill="url(#a)" font-size="52" font-weight="800">11 个视觉工具</text><text x="720" y="205" text-anchor="middle" fill="#8d97aa" font-size="17">基于 sharp · potrace · tesseract · system Chrome</text><rect x="50" y="245" width="430" height="240" rx="20" fill="#0c111c" stroke="#44356f"/><circle cx="72" cy="279" r="18" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="72" y="279" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-size="17" font-weight="800">1</text><text x="118" y="286" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="18" font-weight="700">vision_describe</text><rect x="505" y="245" width="430" height="240" rx="20" fill="#0c111c" stroke="#265b73"/><circle cx="527" cy="279" r="18" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="527" y="279" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-size="17" font-weight="800">2</text><text x="573" y="286" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="18" font-weight="700">vision_ground</text><rect x="960" y="245" width="430" height="240" rx="20" fill="#0c111c" stroke="#265b73"/><circle cx="982" cy="279" r="18" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="982" y="279" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-size="17" font-weight="800">3</text><text x="1028" y="286" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="18" font-weight="700">vision_crop</text><rect x="50" y="505" width="430" height="240" rx="20" fill="#0c111c" stroke="#44356f"/><circle cx="72" cy="539" r="18" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="72" y="539" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-size="17" font-weight="800">4</text><text x="118" y="546" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="18" font-weight="700">vision_pixel_diff</text><rect x="505" y="505" width="430" height="240" rx="20" fill="#0c111c" stroke="#265b73"/><circle cx="527" cy="539" r="18" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="527" y="539" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-size="17" font-weight="800">5</text><text x="573" y="546" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="18" font-weight="700">vision_colors</text><rect x="960" y="505" width="430" height="240" rx="20" fill="#0c111c" stroke="#265b73"/><circle cx="982" cy="539" r="18" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="982" y="539" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-size="17" font-weight="800">6</text><text x="1028" y="546" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="18" font-weight="700">vision_ocr</text><rect x="50" y="765" width="430" height="240" rx="20" fill="#0c111c" stroke="#44356f"/><circle cx="72" cy="799" r="18" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="72" y="799" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-size="17" font-weight="800">7</text><text x="118" y="806" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="18" font-weight="700">vision_trace</text><rect x="505" y="765" width="430" height="240" rx="20" fill="#0c111c" stroke="#265b73"/><circle cx="527" cy="799" r="18" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="527" y="799" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-size="17" font-weight="800">8</text><text x="573" y="806" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="18" font-weight="700">vision_extract_foreground</text><rect x="960" y="765" width="430" height="240" rx="20" fill="#0c111c" stroke="#265b73"/><circle cx="982" cy="799" r="18" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="982" y="799" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-size="17" font-weight="800">9</text><text x="1028" y="806" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="18" font-weight="700">vision_html_screenshot</text><rect x="75" y="315" width="205" height="140" rx="10" fill="#102437"/><circle cx="225" cy="347" r="22" fill="#f0d38b"/><path d="M75 431l70-66 45 39 31-30 59 57" fill="#1d5264"/><path d="M75 445l77-62 35 30 42-38 51 70" fill="#18344d"/><rect x="300" y="320" width="150" height="50" rx="10" fill="#151525" stroke="#42366e"/><text x="314" y="342" fill="#a98cff" font-size="12">问 这张图片里</text><text x="314" y="359" fill="#c8cede" font-size="12"> 有什么?</text><rect x="300" y="382" width="150" height="68" rx="10" fill="#111925" stroke="#334054"/><text x="314" y="405" fill="#7fe0bd" font-size="12">答 山间湖泊</text><text x="314" y="423" fill="#bfc7d6" font-size="12"> 松树与云</text><rect x="535" y="315" width="370" height="140" rx="10" fill="#101620" stroke="#293244"/><rect x="550" y="330" width="100" height="110" rx="7" fill="#151b27"/><text x="565" y="354" fill="#7f899c" font-size="11">仪表盘</text><rect x="558" y="368" width="80" height="27" rx="6" fill="#251d45"/><text x="570" y="386" fill="#b99cff" font-size="11">项目</text><text x="565" y="414" fill="#7f899c" font-size="11">分析</text><rect x="670" y="337" width="205" height="94" rx="8" fill="#111824"/><text x="686" y="360" fill="#e5e9f1" font-size="15" font-weight="700">项目</text><rect x="720" y="374" width="115" height="40" rx="6" fill="#161d29" stroke="#48dc8e" stroke-width="2"/><circle cx="720" cy="374" r="4" fill="#48dc8e"/><circle cx="835" cy="374" r="4" fill="#48dc8e"/><circle cx="720" cy="414" r="4" fill="#48dc8e"/><circle cx="835" cy="414" r="4" fill="#48dc8e"/><text x="746" y="399" fill="#c7a6ff" font-size="11">新建项目</text><rect x="990" y="315" width="175" height="140" rx="10" fill="#161c2b"/><path d="M1000 435l55-87 45 68 55-45" fill="none" stroke="#d37e46" stroke-width="5"/><rect x="1030" y="335" width="90" height="95" fill="none" stroke="#a987ff" stroke-width="3" stroke-dasharray="8 6"/><circle cx="1030" cy="335" r="5" fill="#b293ff"/><circle cx="1120" cy="335" r="5" fill="#b293ff"/><circle cx="1030" cy="430" r="5" fill="#b293ff"/><circle cx="1120" cy="430" r="5" fill="#b293ff"/><text x="1182" y="392" fill="#886cff" font-size="28">→</text><rect x="1220" y="315" width="145" height="140" rx="10" fill="#201d25"/><path d="M1230 440l45-100 50 83 30-50" fill="none" stroke="#d37e46" stroke-width="6"/><rect x="75" y="575" width="125" height="135" rx="9" fill="#121824" stroke="#344052"/><rect x="215" y="575" width="125" height="135" rx="9" fill="#121824" stroke="#344052"/><text x="92" y="603" fill="#d8deea" font-size="12">基线图像</text><text x="92" y="635" fill="#fff" font-size="23">$29/月</text><rect x="91" y="662" width="92" height="26" rx="6" fill="#226bd1"/><text x="115" y="680" fill="#fff" font-size="10">开始使用</text><text x="232" y="603" fill="#d8deea" font-size="12">当前图像</text><text x="232" y="635" fill="#fff" font-size="23">$29/月</text><rect x="231" y="662" width="92" height="26" rx="6" fill="#3eaa67"/><text x="255" y="680" fill="#fff" font-size="10">开始使用</text><text x="355" y="649" fill="#886cff" font-size="28">→</text><rect x="390" y="575" width="65" height="135" rx="8" fill="#0f1118" stroke="#553445"/><g fill="#f0444f"><rect x="400" y="590" width="14" height="18"/><rect x="430" y="612" width="17" height="20"/><rect x="408" y="645" width="22" height="22"/><rect x="435" y="670" width="12" height="27"/></g><rect x="535" y="575" width="370" height="75" rx="10" fill="#35203c"/><circle cx="840" cy="610" r="30" fill="#f2a34e"/><path d="M535 640l85-48 72 40 74-55 139 63" fill="#1e2a44"/><g><rect x="545" y="668" width="62" height="42" rx="6" fill="#1e2a44"/><rect x="616" y="668" width="62" height="42" rx="6" fill="#3b4d7a"/><rect x="687" y="668" width="62" height="42" rx="6" fill="#6b7fcb"/><rect x="758" y="668" width="62" height="42" rx="6" fill="#f29d49"/><rect x="829" y="668" width="62" height="42" rx="6" fill="#ffd6a6"/></g><g fill="#9da6b8" font-family="monospace" font-size="9"><text x="548" y="724">#1E2A44</text><text x="619" y="724">#3B4D7A</text><text x="690" y="724">#6B7FCB</text><text x="761" y="724">#F29D49</text><text x="832" y="724">#FFD6A6</text></g><rect x="990" y="575" width="175" height="135" rx="10" fill="#e8e8e5" stroke="#5f6670"/><text x="1077" y="607" text-anchor="middle" fill="#111827" font-size="18" font-weight="800">PARKING</text><text x="1077" y="633" text-anchor="middle" fill="#111827" font-size="18" font-weight="800">RESERVED</text><text x="1077" y="659" text-anchor="middle" fill="#111827" font-size="20" font-weight="800">24/7</text><text x="1182" y="646" fill="#886cff" font-size="28">→</text><rect x="1220" y="575" width="145" height="135" rx="10" fill="#0d1518" stroke="#28483a"/><text x="1235" y="610" fill="#58d99a" font-family="monospace" font-size="14">PARKING</text><text x="1235" y="635" fill="#58d99a" font-family="monospace" font-size="14">RESERVED</text><text x="1235" y="660" fill="#58d99a" font-family="monospace" font-size="14">24/7</text><rect x="75" y="835" width="165" height="140" rx="10" fill="#111827" stroke="#2d3b55"/><path d="M101 940l45-82 59 40-58 61z" fill="#d8e1f2"/><circle cx="165" cy="890" r="12" fill="#3d87c7"/><path d="M118 949l-13 19 21-10" fill="#ff633f"/><text x="255" y="920" fill="#886cff" font-size="28">→</text><rect x="300" y="835" width="150" height="140" rx="10" fill="#0f1420" stroke="#2d3b55"/><path d="M322 948l45-91 57 42-62 63z" fill="none" stroke="url(#a)" stroke-width="3"/><circle cx="386" cy="889" r="13" fill="none" stroke="#43d6ff" stroke-width="3"/><rect x="535" y="835" width="170" height="140" rx="10" fill="#262a36"/><path d="M590 890h62l13 52h-90z" fill="#606672"/><path d="M580 942v25M658 942v25" stroke="#7b533a" stroke-width="8"/><text x="720" y="920" fill="#886cff" font-size="28">→</text><rect x="760" y="835" width="145" height="140" rx="10" fill="#ddd"/><g fill="#bfc3c8"><rect x="760" y="835" width="29" height="28"/><rect x="818" y="835" width="29" height="28"/><rect x="876" y="835" width="29" height="28"/><rect x="789" y="863" width="29" height="28"/><rect x="847" y="863" width="29" height="28"/></g><path d="M803 890h60l12 52h-86z" fill="#555b67"/><path d="M793 942v25M868 942v25" stroke="#7b533a" stroke-width="8"/><rect x="990" y="835" width="375" height="140" rx="10" fill="#101621" stroke="#344054"/><rect x="990" y="835" width="375" height="25" rx="10" fill="#171d2a"/><circle cx="1005" cy="847" r="4" fill="#ff665a"/><circle cx="1018" cy="847" r="4" fill="#f0b85b"/><circle cx="1031" cy="847" r="4" fill="#50d890"/><text x="1060" y="851" fill="#8c96a9" font-family="monospace" font-size="10">http://localhost:8080</text><rect x="1004" y="873" width="95" height="86" rx="6" fill="#17132b"/><text x="1018" y="894" fill="#9e8cff" font-size="10">概览</text><text x="1018" y="918" fill="#8d96a8" font-size="10">报表</text><text x="1018" y="942" fill="#8d96a8" font-size="10">设置</text><rect x="1113" y="873" width="105" height="62" rx="6" fill="#151b28"/><rect x="1231" y="873" width="118" height="62" rx="6" fill="#151b28"/><text x="1125" y="895" fill="#7f899d" font-size="9">用户数</text><text x="1125" y="920" fill="#f4f7fb" font-size="18">12,540</text><path d="M1120 956c50-23 82 6 126-22 34-21 56 4 96-36" fill="none" stroke="url(#a)" stroke-width="3"/><rect x="50" y="1018" width="1340" height="52" rx="14" fill="#0c111c" stroke="#44356f"/><circle cx="72" cy="1044" r="16" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="72" y="1044" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-size="15" font-weight="800">10</text><text x="108" y="1050" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="16" font-weight="700">vision_detect</text><text x="420" y="1050" fill="#818ba0" font-size="14">元素清单:按类盘点页面元素,编号 + 原图像素框</text></g><rect x="50" y="1082" width="1340" height="52" rx="14" fill="#0c111c" stroke="#44356f"/><circle cx="72" cy="1108" r="16" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="72" y="1108" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-size="15" font-weight="800">11</text><text x="108" y="1114" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="16" font-weight="700">vision_long_screenshot_ocr</text><text x="420" y="1114" fill="#818ba0" font-size="14">长截图转写:分块重叠识别,拼接输出 Markdown</text></svg>
@@ -1,4 +1,4 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" width="1440" height="1080" viewBox="0 0 1440 1080">
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="1440" height="1140" viewBox="0 0 1440 1140">
2
2
  <defs><linearGradient id="bg" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#070914"/><stop offset="1" stop-color="#09111a"/></linearGradient><linearGradient id="a" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#8a68ff"/><stop offset="1" stop-color="#39d6ff"/></linearGradient><pattern id="d" width="28" height="28" patternUnits="userSpaceOnUse"><circle cx="2" cy="2" r="1" fill="#73809a" opacity=".1"/></pattern></defs>
3
- <rect width="1440" height="1080" rx="28" fill="url(#bg)"/><rect width="1440" height="1080" rx="28" fill="url(#d)"/><rect x="1" y="1" width="1438" height="1078" rx="27" fill="none" stroke="#6554ff" opacity=".32"/>
4
- <g font-family="system-ui,Segoe UI,Arial,sans-serif"><text x="72" y="75" fill="#f5f7fc" font-size="42" font-weight="800">DSH <tspan fill="#8768ff">Vision</tspan> <tspan fill="#4acfff">Router</tspan></text><text x="72" y="108" fill="#818ba0" font-size="15">github.com/ysr666/dsh-vision-router</text><text x="720" y="170" text-anchor="middle" fill="url(#a)" font-size="52" font-weight="800">10 Vision Tools</text><text x="720" y="205" text-anchor="middle" fill="#8d97aa" font-size="17">Built on sharp · potrace · tesseract · system Chrome</text><rect x="50" y="245" width="430" height="240" rx="20" fill="#0c111c" stroke="#44356f"/><circle cx="72" cy="279" r="18" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="72" y="279" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-family="system-ui,Segoe UI,Arial,sans-serif" font-size="17" font-weight="800">1</text><text x="118" y="286" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="18" font-weight="700">vision_describe</text><rect x="505" y="245" width="430" height="240" rx="20" fill="#0c111c" stroke="#265b73"/><circle cx="527" cy="279" r="18" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="527" y="279" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-family="system-ui,Segoe UI,Arial,sans-serif" font-size="17" font-weight="800">2</text><text x="573" y="286" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="18" font-weight="700">vision_ground</text><rect x="960" y="245" width="430" height="240" rx="20" fill="#0c111c" stroke="#265b73"/><circle cx="982" cy="279" r="18" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="982" y="279" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-family="system-ui,Segoe UI,Arial,sans-serif" font-size="17" font-weight="800">3</text><text x="1028" y="286" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="18" font-weight="700">vision_crop</text><rect x="50" y="505" width="430" height="240" rx="20" fill="#0c111c" stroke="#44356f"/><circle cx="72" cy="539" r="18" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="72" y="539" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-family="system-ui,Segoe UI,Arial,sans-serif" font-size="17" font-weight="800">4</text><text x="118" y="546" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="18" font-weight="700">vision_pixel_diff</text><rect x="505" y="505" width="430" height="240" rx="20" fill="#0c111c" stroke="#265b73"/><circle cx="527" cy="539" r="18" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="527" y="539" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-family="system-ui,Segoe UI,Arial,sans-serif" font-size="17" font-weight="800">5</text><text x="573" y="546" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="18" font-weight="700">vision_colors</text><rect x="960" y="505" width="430" height="240" rx="20" fill="#0c111c" stroke="#265b73"/><circle cx="982" cy="539" r="18" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="982" y="539" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-family="system-ui,Segoe UI,Arial,sans-serif" font-size="17" font-weight="800">6</text><text x="1028" y="546" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="18" font-weight="700">vision_ocr</text><rect x="50" y="765" width="430" height="240" rx="20" fill="#0c111c" stroke="#44356f"/><circle cx="72" cy="799" r="18" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="72" y="799" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-family="system-ui,Segoe UI,Arial,sans-serif" font-size="17" font-weight="800">7</text><text x="118" y="806" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="18" font-weight="700">vision_trace</text><rect x="505" y="765" width="430" height="240" rx="20" fill="#0c111c" stroke="#265b73"/><circle cx="527" cy="799" r="18" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="527" y="799" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-family="system-ui,Segoe UI,Arial,sans-serif" font-size="17" font-weight="800">8</text><text x="573" y="806" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="18" font-weight="700">vision_extract_foreground</text><rect x="960" y="765" width="430" height="240" rx="20" fill="#0c111c" stroke="#265b73"/><circle cx="982" cy="799" r="18" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="982" y="799" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-family="system-ui,Segoe UI,Arial,sans-serif" font-size="17" font-weight="800">9</text><text x="1028" y="806" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="18" font-weight="700">vision_html_screenshot</text><rect x="75" y="315" width="205" height="140" rx="10" fill="#102437"/><circle cx="225" cy="347" r="22" fill="#f0d38b"/><path d="M75 431l70-66 45 39 31-30 59 57" fill="#1d5264"/><path d="M75 445l77-62 35 30 42-38 51 70" fill="#18344d"/><rect x="300" y="320" width="150" height="50" rx="10" fill="#151525" stroke="#42366e"/><text x="314" y="342" fill="#a98cff" font-size="12">Q What&apos;s in</text><text x="314" y="359" fill="#c8cede" font-size="12"> this image?</text><rect x="300" y="382" width="150" height="68" rx="10" fill="#111925" stroke="#334054"/><text x="314" y="405" fill="#7fe0bd" font-size="12">A Mountain lake</text><text x="314" y="423" fill="#bfc7d6" font-size="12"> with trees</text><text x="314" y="441" fill="#bfc7d6" font-size="12"> and clouds.</text><rect x="535" y="315" width="370" height="140" rx="10" fill="#101620" stroke="#293244"/><rect x="550" y="330" width="100" height="110" rx="7" fill="#151b27"/><text x="565" y="354" fill="#7f899c" font-size="11">Dashboard</text><rect x="558" y="368" width="80" height="27" rx="6" fill="#251d45"/><text x="570" y="386" fill="#b99cff" font-size="11">Projects</text><text x="565" y="414" fill="#7f899c" font-size="11">Analytics</text><rect x="670" y="337" width="205" height="94" rx="8" fill="#111824"/><text x="686" y="360" fill="#e5e9f1" font-size="15" font-weight="700">Projects</text><rect x="720" y="374" width="115" height="40" rx="6" fill="#161d29" stroke="#48dc8e" stroke-width="2"/><circle cx="720" cy="374" r="4" fill="#48dc8e"/><circle cx="835" cy="374" r="4" fill="#48dc8e"/><circle cx="720" cy="414" r="4" fill="#48dc8e"/><circle cx="835" cy="414" r="4" fill="#48dc8e"/><text x="742" y="399" fill="#c7a6ff" font-size="11">New Project</text><rect x="990" y="315" width="175" height="140" rx="10" fill="#161c2b"/><path d="M1000 435l55-87 45 68 55-45" fill="none" stroke="#d37e46" stroke-width="5"/><rect x="1030" y="335" width="90" height="95" fill="none" stroke="#a987ff" stroke-width="3" stroke-dasharray="8 6"/><circle cx="1030" cy="335" r="5" fill="#b293ff"/><circle cx="1120" cy="335" r="5" fill="#b293ff"/><circle cx="1030" cy="430" r="5" fill="#b293ff"/><circle cx="1120" cy="430" r="5" fill="#b293ff"/><text x="1182" y="392" fill="#886cff" font-size="28">→</text><rect x="1220" y="315" width="145" height="140" rx="10" fill="#201d25"/><path d="M1230 440l45-100 50 83 30-50" fill="none" stroke="#d37e46" stroke-width="6"/><rect x="75" y="575" width="125" height="135" rx="9" fill="#121824" stroke="#344052"/><rect x="215" y="575" width="125" height="135" rx="9" fill="#121824" stroke="#344052"/><text x="92" y="603" fill="#d8deea" font-size="12">Pro Plan</text><text x="92" y="635" fill="#fff" font-size="23">$29</text><rect x="91" y="662" width="92" height="26" rx="6" fill="#226bd1"/><text x="109" y="680" fill="#fff" font-size="10">Get Started</text><text x="232" y="603" fill="#d8deea" font-size="12">Pro Plan</text><text x="232" y="635" fill="#fff" font-size="23">$29</text><rect x="231" y="662" width="92" height="26" rx="6" fill="#3eaa67"/><text x="249" y="680" fill="#fff" font-size="10">Get Started</text><text x="355" y="649" fill="#886cff" font-size="28">→</text><rect x="390" y="575" width="65" height="135" rx="8" fill="#0f1118" stroke="#553445"/><g fill="#f0444f"><rect x="400" y="590" width="14" height="18"/><rect x="430" y="612" width="17" height="20"/><rect x="408" y="645" width="22" height="22"/><rect x="435" y="670" width="12" height="27"/></g><rect x="535" y="575" width="370" height="75" rx="10" fill="#35203c"/><circle cx="840" cy="610" r="30" fill="#f2a34e"/><path d="M535 640l85-48 72 40 74-55 139 63" fill="#1e2a44"/><g><rect x="545" y="668" width="62" height="42" rx="6" fill="#1e2a44"/><rect x="616" y="668" width="62" height="42" rx="6" fill="#3b4d7a"/><rect x="687" y="668" width="62" height="42" rx="6" fill="#6b7fcb"/><rect x="758" y="668" width="62" height="42" rx="6" fill="#f29d49"/><rect x="829" y="668" width="62" height="42" rx="6" fill="#ffd6a6"/></g><g fill="#9da6b8" font-family="monospace" font-size="9"><text x="548" y="724">#1E2A44</text><text x="619" y="724">#3B4D7A</text><text x="690" y="724">#6B7FCB</text><text x="761" y="724">#F29D49</text><text x="832" y="724">#FFD6A6</text></g><rect x="990" y="575" width="175" height="135" rx="10" fill="#e8e8e5" stroke="#5f6670"/><text x="1077" y="607" text-anchor="middle" fill="#111827" font-size="18" font-weight="800">PARKING</text><text x="1077" y="633" text-anchor="middle" fill="#111827" font-size="18" font-weight="800">RESERVED</text><text x="1077" y="659" text-anchor="middle" fill="#111827" font-size="20" font-weight="800">24/7</text><text x="1182" y="646" fill="#886cff" font-size="28">→</text><rect x="1220" y="575" width="145" height="135" rx="10" fill="#0d1518" stroke="#28483a"/><text x="1235" y="610" fill="#58d99a" font-family="monospace" font-size="14">PARKING</text><text x="1235" y="635" fill="#58d99a" font-family="monospace" font-size="14">RESERVED</text><text x="1235" y="660" fill="#58d99a" font-family="monospace" font-size="14">24/7</text><rect x="75" y="835" width="165" height="140" rx="10" fill="#111827" stroke="#2d3b55"/><path d="M101 940l45-82 59 40-58 61z" fill="#d8e1f2"/><circle cx="165" cy="890" r="12" fill="#3d87c7"/><path d="M118 949l-13 19 21-10" fill="#ff633f"/><text x="255" y="920" fill="#886cff" font-size="28">→</text><rect x="300" y="835" width="150" height="140" rx="10" fill="#0f1420" stroke="#2d3b55"/><path d="M322 948l45-91 57 42-62 63z" fill="none" stroke="url(#a)" stroke-width="3"/><circle cx="386" cy="889" r="13" fill="none" stroke="#43d6ff" stroke-width="3"/><rect x="535" y="835" width="170" height="140" rx="10" fill="#262a36"/><path d="M590 890h62l13 52h-90z" fill="#606672"/><path d="M580 942v25M658 942v25" stroke="#7b533a" stroke-width="8"/><text x="720" y="920" fill="#886cff" font-size="28">→</text><rect x="760" y="835" width="145" height="140" rx="10" fill="#ddd"/><g fill="#bfc3c8"><rect x="760" y="835" width="29" height="28"/><rect x="818" y="835" width="29" height="28"/><rect x="876" y="835" width="29" height="28"/><rect x="789" y="863" width="29" height="28"/><rect x="847" y="863" width="29" height="28"/></g><path d="M803 890h60l12 52h-86z" fill="#555b67"/><path d="M793 942v25M868 942v25" stroke="#7b533a" stroke-width="8"/><rect x="990" y="835" width="375" height="140" rx="10" fill="#101621" stroke="#344054"/><rect x="990" y="835" width="375" height="25" rx="10" fill="#171d2a"/><circle cx="1005" cy="847" r="4" fill="#ff665a"/><circle cx="1018" cy="847" r="4" fill="#f0b85b"/><circle cx="1031" cy="847" r="4" fill="#50d890"/><text x="1060" y="851" fill="#8c96a9" font-family="monospace" font-size="10">http://localhost:8080</text><rect x="1004" y="873" width="95" height="86" rx="6" fill="#17132b"/><text x="1018" y="894" fill="#9e8cff" font-size="10">Overview</text><text x="1018" y="918" fill="#8d96a8" font-size="10">Reports</text><text x="1018" y="942" fill="#8d96a8" font-size="10">Settings</text><rect x="1113" y="873" width="105" height="62" rx="6" fill="#151b28"/><rect x="1231" y="873" width="118" height="62" rx="6" fill="#151b28"/><text x="1125" y="895" fill="#7f899d" font-size="9">Users</text><text x="1125" y="920" fill="#f4f7fb" font-size="18">12,540</text><path d="M1120 956c50-23 82 6 126-22 34-21 56 4 96-36" fill="none" stroke="url(#a)" stroke-width="3"/><rect x="50" y="1018" width="1340" height="52" rx="14" fill="#0c111c" stroke="#44356f"/><circle cx="72" cy="1044" r="16" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="72" y="1044" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-size="15" font-weight="800">10</text><text x="108" y="1050" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="16" font-weight="700">vision_detect</text><text x="420" y="1050" fill="#818ba0" font-size="14">numbered inventory of every element of a kind — original-pixel boxes</text></g></svg>
3
+ <rect width="1440" height="1140" rx="28" fill="url(#bg)"/><rect width="1440" height="1140" rx="28" fill="url(#d)"/><rect x="1" y="1" width="1438" height="1138" rx="27" fill="none" stroke="#6554ff" opacity=".32"/>
4
+ <g font-family="system-ui,Segoe UI,Arial,sans-serif"><text x="72" y="75" fill="#f5f7fc" font-size="42" font-weight="800">DSH <tspan fill="#8768ff">Vision</tspan> <tspan fill="#4acfff">Router</tspan></text><text x="72" y="108" fill="#818ba0" font-size="15">github.com/ysr666/dsh-vision-router</text><text x="720" y="170" text-anchor="middle" fill="url(#a)" font-size="52" font-weight="800">11 Vision Tools</text><text x="720" y="205" text-anchor="middle" fill="#8d97aa" font-size="17">Built on sharp · potrace · tesseract · system Chrome</text><rect x="50" y="245" width="430" height="240" rx="20" fill="#0c111c" stroke="#44356f"/><circle cx="72" cy="279" r="18" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="72" y="279" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-family="system-ui,Segoe UI,Arial,sans-serif" font-size="17" font-weight="800">1</text><text x="118" y="286" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="18" font-weight="700">vision_describe</text><rect x="505" y="245" width="430" height="240" rx="20" fill="#0c111c" stroke="#265b73"/><circle cx="527" cy="279" r="18" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="527" y="279" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-family="system-ui,Segoe UI,Arial,sans-serif" font-size="17" font-weight="800">2</text><text x="573" y="286" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="18" font-weight="700">vision_ground</text><rect x="960" y="245" width="430" height="240" rx="20" fill="#0c111c" stroke="#265b73"/><circle cx="982" cy="279" r="18" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="982" y="279" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-family="system-ui,Segoe UI,Arial,sans-serif" font-size="17" font-weight="800">3</text><text x="1028" y="286" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="18" font-weight="700">vision_crop</text><rect x="50" y="505" width="430" height="240" rx="20" fill="#0c111c" stroke="#44356f"/><circle cx="72" cy="539" r="18" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="72" y="539" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-family="system-ui,Segoe UI,Arial,sans-serif" font-size="17" font-weight="800">4</text><text x="118" y="546" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="18" font-weight="700">vision_pixel_diff</text><rect x="505" y="505" width="430" height="240" rx="20" fill="#0c111c" stroke="#265b73"/><circle cx="527" cy="539" r="18" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="527" y="539" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-family="system-ui,Segoe UI,Arial,sans-serif" font-size="17" font-weight="800">5</text><text x="573" y="546" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="18" font-weight="700">vision_colors</text><rect x="960" y="505" width="430" height="240" rx="20" fill="#0c111c" stroke="#265b73"/><circle cx="982" cy="539" r="18" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="982" y="539" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-family="system-ui,Segoe UI,Arial,sans-serif" font-size="17" font-weight="800">6</text><text x="1028" y="546" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="18" font-weight="700">vision_ocr</text><rect x="50" y="765" width="430" height="240" rx="20" fill="#0c111c" stroke="#44356f"/><circle cx="72" cy="799" r="18" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="72" y="799" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-family="system-ui,Segoe UI,Arial,sans-serif" font-size="17" font-weight="800">7</text><text x="118" y="806" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="18" font-weight="700">vision_trace</text><rect x="505" y="765" width="430" height="240" rx="20" fill="#0c111c" stroke="#265b73"/><circle cx="527" cy="799" r="18" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="527" y="799" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-family="system-ui,Segoe UI,Arial,sans-serif" font-size="17" font-weight="800">8</text><text x="573" y="806" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="18" font-weight="700">vision_extract_foreground</text><rect x="960" y="765" width="430" height="240" rx="20" fill="#0c111c" stroke="#265b73"/><circle cx="982" cy="799" r="18" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="982" y="799" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-family="system-ui,Segoe UI,Arial,sans-serif" font-size="17" font-weight="800">9</text><text x="1028" y="806" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="18" font-weight="700">vision_html_screenshot</text><rect x="75" y="315" width="205" height="140" rx="10" fill="#102437"/><circle cx="225" cy="347" r="22" fill="#f0d38b"/><path d="M75 431l70-66 45 39 31-30 59 57" fill="#1d5264"/><path d="M75 445l77-62 35 30 42-38 51 70" fill="#18344d"/><rect x="300" y="320" width="150" height="50" rx="10" fill="#151525" stroke="#42366e"/><text x="314" y="342" fill="#a98cff" font-size="12">Q What&apos;s in</text><text x="314" y="359" fill="#c8cede" font-size="12"> this image?</text><rect x="300" y="382" width="150" height="68" rx="10" fill="#111925" stroke="#334054"/><text x="314" y="405" fill="#7fe0bd" font-size="12">A Mountain lake</text><text x="314" y="423" fill="#bfc7d6" font-size="12"> with trees</text><text x="314" y="441" fill="#bfc7d6" font-size="12"> and clouds.</text><rect x="535" y="315" width="370" height="140" rx="10" fill="#101620" stroke="#293244"/><rect x="550" y="330" width="100" height="110" rx="7" fill="#151b27"/><text x="565" y="354" fill="#7f899c" font-size="11">Dashboard</text><rect x="558" y="368" width="80" height="27" rx="6" fill="#251d45"/><text x="570" y="386" fill="#b99cff" font-size="11">Projects</text><text x="565" y="414" fill="#7f899c" font-size="11">Analytics</text><rect x="670" y="337" width="205" height="94" rx="8" fill="#111824"/><text x="686" y="360" fill="#e5e9f1" font-size="15" font-weight="700">Projects</text><rect x="720" y="374" width="115" height="40" rx="6" fill="#161d29" stroke="#48dc8e" stroke-width="2"/><circle cx="720" cy="374" r="4" fill="#48dc8e"/><circle cx="835" cy="374" r="4" fill="#48dc8e"/><circle cx="720" cy="414" r="4" fill="#48dc8e"/><circle cx="835" cy="414" r="4" fill="#48dc8e"/><text x="742" y="399" fill="#c7a6ff" font-size="11">New Project</text><rect x="990" y="315" width="175" height="140" rx="10" fill="#161c2b"/><path d="M1000 435l55-87 45 68 55-45" fill="none" stroke="#d37e46" stroke-width="5"/><rect x="1030" y="335" width="90" height="95" fill="none" stroke="#a987ff" stroke-width="3" stroke-dasharray="8 6"/><circle cx="1030" cy="335" r="5" fill="#b293ff"/><circle cx="1120" cy="335" r="5" fill="#b293ff"/><circle cx="1030" cy="430" r="5" fill="#b293ff"/><circle cx="1120" cy="430" r="5" fill="#b293ff"/><text x="1182" y="392" fill="#886cff" font-size="28">→</text><rect x="1220" y="315" width="145" height="140" rx="10" fill="#201d25"/><path d="M1230 440l45-100 50 83 30-50" fill="none" stroke="#d37e46" stroke-width="6"/><rect x="75" y="575" width="125" height="135" rx="9" fill="#121824" stroke="#344052"/><rect x="215" y="575" width="125" height="135" rx="9" fill="#121824" stroke="#344052"/><text x="92" y="603" fill="#d8deea" font-size="12">Pro Plan</text><text x="92" y="635" fill="#fff" font-size="23">$29</text><rect x="91" y="662" width="92" height="26" rx="6" fill="#226bd1"/><text x="109" y="680" fill="#fff" font-size="10">Get Started</text><text x="232" y="603" fill="#d8deea" font-size="12">Pro Plan</text><text x="232" y="635" fill="#fff" font-size="23">$29</text><rect x="231" y="662" width="92" height="26" rx="6" fill="#3eaa67"/><text x="249" y="680" fill="#fff" font-size="10">Get Started</text><text x="355" y="649" fill="#886cff" font-size="28">→</text><rect x="390" y="575" width="65" height="135" rx="8" fill="#0f1118" stroke="#553445"/><g fill="#f0444f"><rect x="400" y="590" width="14" height="18"/><rect x="430" y="612" width="17" height="20"/><rect x="408" y="645" width="22" height="22"/><rect x="435" y="670" width="12" height="27"/></g><rect x="535" y="575" width="370" height="75" rx="10" fill="#35203c"/><circle cx="840" cy="610" r="30" fill="#f2a34e"/><path d="M535 640l85-48 72 40 74-55 139 63" fill="#1e2a44"/><g><rect x="545" y="668" width="62" height="42" rx="6" fill="#1e2a44"/><rect x="616" y="668" width="62" height="42" rx="6" fill="#3b4d7a"/><rect x="687" y="668" width="62" height="42" rx="6" fill="#6b7fcb"/><rect x="758" y="668" width="62" height="42" rx="6" fill="#f29d49"/><rect x="829" y="668" width="62" height="42" rx="6" fill="#ffd6a6"/></g><g fill="#9da6b8" font-family="monospace" font-size="9"><text x="548" y="724">#1E2A44</text><text x="619" y="724">#3B4D7A</text><text x="690" y="724">#6B7FCB</text><text x="761" y="724">#F29D49</text><text x="832" y="724">#FFD6A6</text></g><rect x="990" y="575" width="175" height="135" rx="10" fill="#e8e8e5" stroke="#5f6670"/><text x="1077" y="607" text-anchor="middle" fill="#111827" font-size="18" font-weight="800">PARKING</text><text x="1077" y="633" text-anchor="middle" fill="#111827" font-size="18" font-weight="800">RESERVED</text><text x="1077" y="659" text-anchor="middle" fill="#111827" font-size="20" font-weight="800">24/7</text><text x="1182" y="646" fill="#886cff" font-size="28">→</text><rect x="1220" y="575" width="145" height="135" rx="10" fill="#0d1518" stroke="#28483a"/><text x="1235" y="610" fill="#58d99a" font-family="monospace" font-size="14">PARKING</text><text x="1235" y="635" fill="#58d99a" font-family="monospace" font-size="14">RESERVED</text><text x="1235" y="660" fill="#58d99a" font-family="monospace" font-size="14">24/7</text><rect x="75" y="835" width="165" height="140" rx="10" fill="#111827" stroke="#2d3b55"/><path d="M101 940l45-82 59 40-58 61z" fill="#d8e1f2"/><circle cx="165" cy="890" r="12" fill="#3d87c7"/><path d="M118 949l-13 19 21-10" fill="#ff633f"/><text x="255" y="920" fill="#886cff" font-size="28">→</text><rect x="300" y="835" width="150" height="140" rx="10" fill="#0f1420" stroke="#2d3b55"/><path d="M322 948l45-91 57 42-62 63z" fill="none" stroke="url(#a)" stroke-width="3"/><circle cx="386" cy="889" r="13" fill="none" stroke="#43d6ff" stroke-width="3"/><rect x="535" y="835" width="170" height="140" rx="10" fill="#262a36"/><path d="M590 890h62l13 52h-90z" fill="#606672"/><path d="M580 942v25M658 942v25" stroke="#7b533a" stroke-width="8"/><text x="720" y="920" fill="#886cff" font-size="28">→</text><rect x="760" y="835" width="145" height="140" rx="10" fill="#ddd"/><g fill="#bfc3c8"><rect x="760" y="835" width="29" height="28"/><rect x="818" y="835" width="29" height="28"/><rect x="876" y="835" width="29" height="28"/><rect x="789" y="863" width="29" height="28"/><rect x="847" y="863" width="29" height="28"/></g><path d="M803 890h60l12 52h-86z" fill="#555b67"/><path d="M793 942v25M868 942v25" stroke="#7b533a" stroke-width="8"/><rect x="990" y="835" width="375" height="140" rx="10" fill="#101621" stroke="#344054"/><rect x="990" y="835" width="375" height="25" rx="10" fill="#171d2a"/><circle cx="1005" cy="847" r="4" fill="#ff665a"/><circle cx="1018" cy="847" r="4" fill="#f0b85b"/><circle cx="1031" cy="847" r="4" fill="#50d890"/><text x="1060" y="851" fill="#8c96a9" font-family="monospace" font-size="10">http://localhost:8080</text><rect x="1004" y="873" width="95" height="86" rx="6" fill="#17132b"/><text x="1018" y="894" fill="#9e8cff" font-size="10">Overview</text><text x="1018" y="918" fill="#8d96a8" font-size="10">Reports</text><text x="1018" y="942" fill="#8d96a8" font-size="10">Settings</text><rect x="1113" y="873" width="105" height="62" rx="6" fill="#151b28"/><rect x="1231" y="873" width="118" height="62" rx="6" fill="#151b28"/><text x="1125" y="895" fill="#7f899d" font-size="9">Users</text><text x="1125" y="920" fill="#f4f7fb" font-size="18">12,540</text><path d="M1120 956c50-23 82 6 126-22 34-21 56 4 96-36" fill="none" stroke="url(#a)" stroke-width="3"/><rect x="50" y="1018" width="1340" height="52" rx="14" fill="#0c111c" stroke="#44356f"/><circle cx="72" cy="1044" r="16" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="72" y="1044" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-size="15" font-weight="800">10</text><text x="108" y="1050" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="16" font-weight="700">vision_detect</text><text x="420" y="1050" fill="#818ba0" font-size="14">numbered inventory of every element of a kind — original-pixel boxes</text></g><rect x="50" y="1082" width="1340" height="52" rx="14" fill="#0c111c" stroke="#44356f"/><circle cx="72" cy="1108" r="16" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="72" y="1108" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-size="15" font-weight="800">11</text><text x="108" y="1114" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="16" font-weight="700">vision_long_screenshot_ocr</text><text x="420" y="1114" fill="#818ba0" font-size="14">long-screenshot transcription: overlapping chunks, stitched Markdown</text></svg>
package/index.js CHANGED
@@ -40,10 +40,136 @@ import { createHash, randomBytes } from 'node:crypto'
40
40
  // lazily and cache the resolved factory so a sharp failure degrades only the
41
41
  // pixel-level tools — the routing chain and text tools keep working.
42
42
  let sharpPromise
43
+ // Module-level warning sink installed by apply(): the plugin routes runtime
44
+ // diagnostics through ctx.logger instead of console.warn. Kept as a plain
45
+ // function slot so loadSharp() stays usable outside a Cordis context (tests,
46
+ // the doctor CLI).
47
+ let sharpWarningHook
48
+ export function registerSharpWarningHook(hook) {
49
+ sharpWarningHook = typeof hook === 'function' ? hook : undefined
50
+ }
51
+
52
+ function warnSharp(message) {
53
+ if (sharpWarningHook !== undefined) {
54
+ try {
55
+ sharpWarningHook(message)
56
+ return
57
+ } catch {
58
+ /* fall through to console */
59
+ }
60
+ }
61
+ if (typeof console !== 'undefined' && typeof console.warn === 'function') console.warn(message)
62
+ }
63
+
64
+ /** Split "1.2.3" / "1.2" / "1" / "1.2.3-beta.4" into comparable parts
65
+ * (missing minor/patch default to 0, like semver). */
66
+ export function parseVersionParts(version) {
67
+ const match = String(version ?? '').trim().match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?$/)
68
+ if (!match) return undefined
69
+ return {
70
+ major: Number(match[1]),
71
+ minor: Number(match[2] ?? 0),
72
+ patch: Number(match[3] ?? 0),
73
+ pre: match[4],
74
+ }
75
+ }
76
+
77
+ function compareVersionParts(a, b) {
78
+ if (a.major !== b.major) return a.major < b.major ? -1 : 1
79
+ if (a.minor !== b.minor) return a.minor < b.minor ? -1 : 1
80
+ if (a.patch !== b.patch) return a.patch < b.patch ? -1 : 1
81
+ // A prerelease sorts below its release: 0.35.3-beta < 0.35.3.
82
+ if (a.pre === undefined && b.pre === undefined) return 0
83
+ if (a.pre === undefined) return 1
84
+ if (b.pre === undefined) return -1
85
+ return a.pre < b.pre ? -1 : a.pre > b.pre ? 1 : 0
86
+ }
87
+
88
+ /**
89
+ * Minimal semver range check for the comparator shapes the plugin itself
90
+ * declares (`>=0.35.3 <1`, space-separated clauses, `||` alternatives).
91
+ * @returns true when `version` satisfies `range`, false otherwise (also for
92
+ * malformed inputs, so an unparsable range fails safe and loud).
93
+ */
94
+ export function versionSatisfies(version, range) {
95
+ const parts = parseVersionParts(version)
96
+ if (parts === undefined) return false
97
+ const alternatives = String(range ?? '')
98
+ .split('||')
99
+ .map((alt) => alt.trim())
100
+ .filter((alt) => alt !== '')
101
+ if (alternatives.length === 0) return false
102
+ return alternatives.some((alternative) => {
103
+ const clauses = alternative.split(/\s+/)
104
+ if (clauses.length === 0) return false
105
+ return clauses.every((clause) => {
106
+ const match = clause.match(/^(>=|<=|>|<|=)?\s*(.+)$/)
107
+ if (!match) return false
108
+ const op = match[1] ?? '='
109
+ const other = parseVersionParts(match[2])
110
+ if (other === undefined) return false
111
+ const cmp = compareVersionParts(parts, other)
112
+ switch (op) {
113
+ case '>=': return cmp >= 0
114
+ case '<=': return cmp <= 0
115
+ case '>': return cmp > 0
116
+ case '<': return cmp < 0
117
+ default: return cmp === 0
118
+ }
119
+ })
120
+ })
121
+ }
122
+
123
+ // Read the plugin's own peerDependencies.sharp range from the installed
124
+ // package.json (createRequire resolves it relative to this file, so the value
125
+ // is never hardcoded and follows package.json through releases).
126
+ let sharpPeerRangeCache
127
+ function sharpPeerRange() {
128
+ if (sharpPeerRangeCache === undefined) {
129
+ try {
130
+ const requireLocal = createRequire(import.meta.url)
131
+ const pkg = requireLocal('./package.json')
132
+ sharpPeerRangeCache =
133
+ pkg && pkg.peerDependencies && typeof pkg.peerDependencies.sharp === 'string'
134
+ ? pkg.peerDependencies.sharp
135
+ : undefined
136
+ } catch {
137
+ sharpPeerRangeCache = undefined
138
+ }
139
+ }
140
+ return sharpPeerRangeCache
141
+ }
142
+
43
143
  function loadSharp() {
44
144
  if (!sharpPromise) {
45
145
  sharpPromise = import('sharp')
46
- .then((mod) => mod.default ?? mod)
146
+ .then((mod) => {
147
+ const sharp = mod.default ?? mod
148
+ // issue #75: an upgrade from v1.1.x can leave a stale sharp 0.34.0 in
149
+ // the profile's node_modules; pnpm does not physically remove orphaned
150
+ // peer copies on upgrade. On Windows the stale copy's libvips DLL and
151
+ // the host's coexist in one process and every pixel tool then dies
152
+ // with the cryptic "colourspace: parameter space not set". Detect the
153
+ // violation up front and turn it into an actionable warning.
154
+ try {
155
+ const version = sharp && sharp.versions && typeof sharp.versions.sharp === 'string'
156
+ ? sharp.versions.sharp
157
+ : undefined
158
+ const range = sharpPeerRange()
159
+ if (version !== undefined && range !== undefined && !versionSatisfies(version, range)) {
160
+ warnSharp(
161
+ `dsh-vision-router: the resolved sharp ${version} does not satisfy the plugin peer range "${range}". ` +
162
+ 'This is usually a stale sharp left in the profile from a pre-v1.2 upgrade: remove ' +
163
+ '`<profile>/node_modules/sharp` and `<profile>/node_modules/@img` (or run `pnpm install` in the profile) ' +
164
+ 'and restart, so the plugin falls through to the host sharp. Until then, pixel tools may fail with ' +
165
+ '"colourspace: parameter space not set".',
166
+ )
167
+ }
168
+ } catch {
169
+ /* diagnostics must never break the pixel tools */
170
+ }
171
+ return sharp
172
+ })
47
173
  .catch((cause) => {
48
174
  sharpPromise = undefined // allow a retry after the environment is repaired
49
175
  const error = new Error(
@@ -107,6 +233,15 @@ export const Config = z.object({
107
233
  tool: z.boolean().default(true),
108
234
  progressiveTools: z.boolean().default(true),
109
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(''),
110
245
  artifactsDir: z.string().default('.dsh-vision-router/artifacts'),
111
246
  rewriteImages: z.boolean().default(true),
112
247
  downscale: z.boolean().default(true),
@@ -397,29 +532,100 @@ export function renderVisionPresent(value) {
397
532
  ]
398
533
  }
399
534
 
535
+ /** Text marker replacing a tool-produced image block (shared by the pre-step
536
+ * inbox sanitizer and the session-surface shadow sanitizer). */
537
+ export function toolImageMarker(block) {
538
+ const attachment = block && block.attachment ? block.attachment : {}
539
+ const id = attachment.attachmentId || attachment.id || 'unknown'
540
+ const name = attachment.name || 'tool image'
541
+ return {
542
+ type: 'text',
543
+ text:
544
+ `[tool result produced image "${name}", attachment id "${id}". ` +
545
+ `The image was kept out of the text-model request to prevent session corruption. ` +
546
+ `To inspect it, call vision_describe with attachmentIds: ["${id}"] when available, ` +
547
+ 'or use a path-based vision tool. To show a generated image to the user, use vision_present instead of read_image.]',
548
+ }
549
+ }
550
+
400
551
  export function sanitizeToolResultImages(messages) {
401
552
  let anyChanged = false
402
553
  const rewritten = (messages ?? []).map((message) => {
403
554
  if (!message || !Array.isArray(message.content)) return message
404
- const result = rewriteToolResultImages(message.content, (block) => {
405
- const attachment = block.attachment || {}
406
- const id = attachment.attachmentId || attachment.id || 'unknown'
407
- const name = attachment.name || 'tool image'
408
- return {
409
- type: 'text',
410
- text:
411
- `[tool result produced image "${name}", attachment id "${id}". ` +
412
- `The image was kept out of the text-model request to prevent session corruption. ` +
413
- `To inspect it, call vision_describe with attachmentIds: ["${id}"] when available, ` +
414
- 'or use a path-based vision tool. To show a generated image to the user, use vision_present instead of read_image.]',
415
- }
416
- })
555
+ const result = rewriteToolResultImages(message.content, toolImageMarker)
417
556
  if (result.changed) anyChanged = true
418
557
  return result.changed ? { ...message, content: result.content } : message
419
558
  })
420
559
  return { messages: anyChanged ? rewritten : (messages ?? []), changed: anyChanged }
421
560
  }
422
561
 
562
+ /** Recursively freeze a plain structured-clone tree (the session log keeps its
563
+ * messages deep-frozen; replacements must match). */
564
+ export function deepFreezeLocal(value) {
565
+ if (value !== null && typeof value === 'object') {
566
+ for (const key of Object.keys(value)) deepFreezeLocal(value[key])
567
+ Object.freeze(value)
568
+ }
569
+ return value
570
+ }
571
+
572
+ /**
573
+ * Build the sanitized, deep-frozen copy of a tool-result message: identical
574
+ * to the original except that every image block (top-level or nested inside
575
+ * tool-result content) is replaced with a text marker. Returns the original
576
+ * message object unchanged when it contains no image.
577
+ */
578
+ export function sanitizeToolResultMessage(message) {
579
+ if (!message || !Array.isArray(message.content)) return message
580
+ const result = rewriteImagesDeep(message.content, toolImageMarker)
581
+ if (!result.changed) return message
582
+ const clone = structuredClone(message)
583
+ clone.content = result.content
584
+ return deepFreezeLocal(clone)
585
+ }
586
+
587
+ /**
588
+ * Plan the shadow replacements that keep tool-produced image blocks out of
589
+ * the model-visible session surface.
590
+ *
591
+ * A tool result (e.g. vision_present, or the host read_image) is persisted as
592
+ * a durable `tool/result` event whose message nests an image block. The agent
593
+ * pre-step only sees the inbox claim — never the historical surface — so no
594
+ * pre-step rewrite can catch these blocks before `Session.deriveMessages()`
595
+ * feeds them to the adapter, and a text-only adapter then rejects every
596
+ * subsequent request (issue #74: UNSUPPORTED_CONTENT session lock).
597
+ *
598
+ * The harness supports shadowing a surface node with a replacement event that
599
+ * carries `surfaceOp: {op:'replace', start, end}` + `sourceEventSeqs: [seq]`:
600
+ * the human transcript keeps rendering the append-origin original (the user
601
+ * still sees the image), while every later `deriveMessages()` projection sees
602
+ * the sanitized replacement. This is the same mechanism the host compaction
603
+ * pruner uses, so it is durable, replayable, and survives session resume.
604
+ *
605
+ * This function is pure: it returns the replacement events to append. The
606
+ * apply() side decides which events to strip (route-aware: an image-capable
607
+ * route legitimately uses read_image's result image) and performs the append.
608
+ *
609
+ * @param events - the session event log array (`session.events`).
610
+ * @param surfaceNodes - the ordered seqs of the current surface (`session.surface.nodes`).
611
+ * @param shouldStrip - (seq, event) => boolean; true to plan a replacement.
612
+ * @returns [{ seq, event, message }] where message is the sanitized frozen
613
+ * replacement message for the append at `seq`.
614
+ */
615
+ export function planToolResultImageShadows(events, surfaceNodes, shouldStrip) {
616
+ const plans = []
617
+ for (const seq of surfaceNodes ?? []) {
618
+ const event = events && events[seq]
619
+ if (!event || event.type !== 'tool/result') continue
620
+ const message = event.data && event.data.message
621
+ if (!message || !Array.isArray(message.content) || !blocksHaveImage(message.content)) continue
622
+ if (typeof shouldStrip !== 'function' || shouldStrip(seq, event) !== true) continue
623
+ const sanitized = sanitizeToolResultMessage(message)
624
+ if (sanitized !== message) plans.push({ seq, event, message: sanitized })
625
+ }
626
+ return plans
627
+ }
628
+
423
629
  /** Marker text for an image the text-only model cannot see (see vision_describe). */
424
630
  function imageMarker(id) {
425
631
  return `[attached image: ${id}] The current model cannot see images. To examine it, call vision_describe with attachmentIds: ["${id}"] and a specific question.`
@@ -447,6 +653,51 @@ export function rewriteImageBlocks(messages) {
447
653
  return { messages: anyChanged ? rewritten : (messages ?? []), attachments }
448
654
  }
449
655
 
656
+ /**
657
+ * Collect distinct durable attachment refs from a session event log.
658
+ *
659
+ * The event log is the only place that sees every image that entered the
660
+ * conversation, including host-produced ones such as `read_image` re-uploads,
661
+ * which are persisted as `tool/result` events and never pass through the
662
+ * inbox-claim message stream a plugin sees on `agent/pre-step` (issue #72).
663
+ * Extracting refs here — with full metadata, so `attachments.readImage` can
664
+ * verify the bytes — is what lets `vision_describe` / the pixel tools resolve
665
+ * ids the harness announced but the plugin never indexed.
666
+ *
667
+ * Handles the same message-producing event types the host surface derives
668
+ * (`user/message` carries the message directly; `assistant/message` and
669
+ * `tool/result` nest it under `data.message`) and descends into nested
670
+ * `tool-result` content exactly like `rewriteImageBlocks`.
671
+ *
672
+ * @param events - the session event log (`session.events`), or any array shaped like it.
673
+ * @returns distinct attachment refs in first-seen order.
674
+ */
675
+ export function collectEventAttachmentRefs(events) {
676
+ const refs = []
677
+ const seen = new Set()
678
+ for (const event of events ?? []) {
679
+ if (!event || !event.data) continue
680
+ let message
681
+ if (event.type === 'user/message') {
682
+ message = event.data
683
+ } else if (event.type === 'assistant/message' || event.type === 'tool/result') {
684
+ message = event.data.message
685
+ } else {
686
+ continue
687
+ }
688
+ if (!message || !Array.isArray(message.content)) continue
689
+ rewriteImagesDeep(message.content, (block) => {
690
+ const attachment = block && block.attachment
691
+ if (attachment && attachment.attachmentId && !seen.has(String(attachment.attachmentId))) {
692
+ seen.add(String(attachment.attachmentId))
693
+ refs.push(attachment)
694
+ }
695
+ return block
696
+ })
697
+ }
698
+ return refs
699
+ }
700
+
450
701
  /** Extract a JSON object/array from model output (tolerates fences and prose). */
451
702
  export function extractJson(text) {
452
703
  const source = String(text ?? '')
@@ -1755,6 +2006,11 @@ export function modelInfoAcceptsImages(info) {
1755
2006
  }
1756
2007
 
1757
2008
  export function apply(ctx, config = {}) {
2009
+ // Route sharp version diagnostics (issue #75) through the harness logger
2010
+ // instead of console.warn, so the warning lands in the server log.
2011
+ registerSharpWarningHook((message) => {
2012
+ ctx.logger?.warn(message)
2013
+ })
1758
2014
  // Live configuration: composition entry at boot, then the resolved settings
1759
2015
  // section once the settings service mounts (installSettingsSection below).
1760
2016
  let current = () => config
@@ -2710,6 +2966,39 @@ export function apply(ctx, config = {}) {
2710
2966
  }
2711
2967
  }
2712
2968
 
2969
+ // session id -> event-log length already scanned for attachment refs.
2970
+ // Mirrors sessionAttachmentsById: sessions are long-lived objects, and only
2971
+ // the id string survives a process resume, so the index is keyed by id.
2972
+ const scannedSessionEventSeqs = new Map()
2973
+
2974
+ /**
2975
+ * Index image attachments recorded anywhere in the session event log, not
2976
+ * just in the inbox-claim message stream `agent/pre-step` hands us
2977
+ * (issue #72). Host-produced images (the built-in `read_image` tool's
2978
+ * re-uploads, persisted as `tool/result` events) never enter that stream,
2979
+ * so the harness-announced attachment id stayed unresolvable even though
2980
+ * the UI showed the image and the bytes are durably stored. `session.events`
2981
+ * is the complete append-only log (seeded from storage on resume), so
2982
+ * scanning it — incrementally, per session id — finds every ref with full
2983
+ * metadata for a later `attachments.readImage(ref)`.
2984
+ */
2985
+ const scanSessionEventLog = (session) => {
2986
+ if (!session) return
2987
+ let events
2988
+ try {
2989
+ events = session.events
2990
+ } catch {
2991
+ return // not a host Session (or the getter is unavailable): nothing to scan
2992
+ }
2993
+ if (!Array.isArray(events) || events.length === 0) return
2994
+ const key = session.id !== undefined ? String(session.id) : undefined
2995
+ const last = key !== undefined ? (scannedSessionEventSeqs.get(key) ?? 0) : 0
2996
+ if (last >= events.length) return
2997
+ const refs = collectEventAttachmentRefs(events.slice(last))
2998
+ if (key !== undefined) scannedSessionEventSeqs.set(key, events.length)
2999
+ if (refs.length > 0) recordUploadedAttachments(session, refs)
3000
+ }
3001
+
2713
3002
  const lookupAttachment = (session, id) => {
2714
3003
  const byId = session && session.id !== undefined
2715
3004
  ? sessionAttachmentsById.get(String(session.id))
@@ -2719,7 +3008,163 @@ export function apply(ctx, config = {}) {
2719
3008
  if (hit !== undefined) return hit
2720
3009
  }
2721
3010
  const map = session ? sessionAttachments.get(session) : undefined
2722
- return map ? map.get(String(id)) : undefined
3011
+ const hit = map ? map.get(String(id)) : undefined
3012
+ if (hit !== undefined) return hit
3013
+ // Miss: fall back to the session event log. Ids announced by the harness
3014
+ // for images it persisted itself (read_image re-uploads) live there even
3015
+ // though they never crossed the inbox-claim stream, so this resolves them
3016
+ // exactly like user-uploaded ids (issue #72). Refs from the log carry
3017
+ // full metadata, so a later attachments.readImage(ref) verifies and
3018
+ // returns the bytes.
3019
+ if (session !== undefined) {
3020
+ scanSessionEventLog(session)
3021
+ const afterById = session.id !== undefined
3022
+ ? sessionAttachmentsById.get(String(session.id))
3023
+ : undefined
3024
+ if (afterById !== undefined) {
3025
+ const after = afterById.get(String(id))
3026
+ if (after !== undefined) return after
3027
+ }
3028
+ const afterMap = sessionAttachments.get(session)
3029
+ const afterHit = afterMap ? afterMap.get(String(id)) : undefined
3030
+ if (afterHit !== undefined) return afterHit
3031
+ }
3032
+ return undefined
3033
+ }
3034
+
3035
+ // ── issue #74: shadow-sanitize tool-result image blocks on the surface ────
3036
+ //
3037
+ // A tool result that renders an image block (vision_present, or the host
3038
+ // read_image on image-capable routes) is persisted as a durable
3039
+ // `tool/result` event and then flows into EVERY later request through
3040
+ // `Session.deriveMessages()`. A text-only adapter (DeepSeek native, pi-ai
3041
+ // text routes) rejects nested images with UNSUPPORTED_CONTENT and the
3042
+ // session is locked forever. The pre-step inbox sanitizer cannot see these
3043
+ // historical events, so the surface itself is rewritten instead: each
3044
+ // offending event is shadowed by a sanitized replacement event
3045
+ // (`surfaceOp: {op:'replace'}`, the same mechanism the host compaction
3046
+ // pruner uses). The Web UI transcript renders append-origin events, so the
3047
+ // user still sees the image; only the model-visible surface is sanitized.
3048
+ // The decision is route-aware: an image-capable route legitimately consumes
3049
+ // read_image's result image, mirroring the host's own gate
3050
+ // (`assertImageCapableRoute` in dsh-tool-fs).
3051
+
3052
+ // session -> { count: nodes examined, done: Set<seqs already decided> }
3053
+ const sessionSurfaceScans = new WeakMap()
3054
+
3055
+ const sessionRouteHandlesImages = async (session) => {
3056
+ let provider
3057
+ let model
3058
+ try {
3059
+ const header = typeof session.requestHeader === 'function' ? session.requestHeader() : undefined
3060
+ provider = header && header.config ? header.config.provider : undefined
3061
+ model = header && header.config ? header.config.model : undefined
3062
+ } catch {
3063
+ return false
3064
+ }
3065
+ if (typeof provider !== 'string' || provider === '' || typeof model !== 'string' || model === '') {
3066
+ return false
3067
+ }
3068
+ // Plugin-owned routes handle image blocks at their stream boundary: the
3069
+ // wrapper and the provider twins rewrite them into markers/descriptions,
3070
+ // the stealth adapter does the same, and the vision-chain route serves
3071
+ // image-capable models.
3072
+ if (provider === wrapperRoute() || provider === chainRoute() || provider.endsWith('-vision')) {
3073
+ return true
3074
+ }
3075
+ if (stealthActive && provider === 'deepseek-official') return true
3076
+ // Routing mode reverse-routes text-only turns back to the text provider;
3077
+ // when neither the wrapper nor the stealth adapter is there to rewrite
3078
+ // images, the reverse target is the bare text adapter, which rejects
3079
+ // tool-result images. Sanitize so text turns stay usable.
3080
+ if (routingEnabled() && reverseRoutingEnabled() && !wrapperRegistered && !stealthActive) {
3081
+ return false
3082
+ }
3083
+ // Same probe the host uses to gate read_image: only routes whose models
3084
+ // declare image input may keep tool-result images.
3085
+ try {
3086
+ const info = await ctx.llm.resolveModelInfo(provider, model)
3087
+ return Array.isArray(info && info.inputModalities) && info.inputModalities.includes('image')
3088
+ } catch {
3089
+ return false // unknown route: fail safe and sanitize
3090
+ }
3091
+ }
3092
+
3093
+ const sanitizeSessionToolResults = async (session) => {
3094
+ if (!session) return
3095
+ let events
3096
+ let nodes
3097
+ try {
3098
+ events = session.events
3099
+ nodes = session.surface && session.surface.nodes
3100
+ } catch {
3101
+ return // not a host Session: nothing to sanitize
3102
+ }
3103
+ if (!Array.isArray(events) || !Array.isArray(nodes) || nodes.length === 0) return
3104
+ let scan = sessionSurfaceScans.get(session)
3105
+ if (!scan) {
3106
+ scan = { count: 0, done: new Set() }
3107
+ sessionSurfaceScans.set(session, scan)
3108
+ }
3109
+ // Compaction replaces the surface wholesale; a shrunk node list means the
3110
+ // positional cursor is stale, so restart from the head. Kept decisions are
3111
+ // memoized in `done`, so a restart is a cheap no-op for examined events.
3112
+ if (nodes.length < scan.count) {
3113
+ scan.count = 0
3114
+ scan.done = new Set()
3115
+ }
3116
+ if (nodes.length === scan.count) return
3117
+ let routeHandlesImages
3118
+ const newSeqs = nodes.slice(scan.count)
3119
+ for (const seq of newSeqs) {
3120
+ const event = events[seq]
3121
+ if (!event || event.type !== 'tool/result' || scan.done.has(seq)) continue
3122
+ const message = event.data && event.data.message
3123
+ if (!message || !Array.isArray(message.content) || !blocksHaveImage(message.content)) {
3124
+ scan.done.add(seq)
3125
+ continue
3126
+ }
3127
+ if (routeHandlesImages === undefined) {
3128
+ routeHandlesImages = await sessionRouteHandlesImages(session)
3129
+ }
3130
+ if (routeHandlesImages) {
3131
+ // Image-capable route: keep the image (read_image's result is the
3132
+ // model's view of the file). The wrapper/twin/stealth routes rewrite
3133
+ // it at stream time anyway.
3134
+ scan.done.add(seq)
3135
+ continue
3136
+ }
3137
+ const sanitized = sanitizeToolResultMessage(message)
3138
+ if (sanitized === message) {
3139
+ scan.done.add(seq)
3140
+ continue
3141
+ }
3142
+ try {
3143
+ session.append(
3144
+ 'tool/result',
3145
+ { ...event.data, message: sanitized },
3146
+ {
3147
+ surfaceOp: { op: 'replace', start: seq, end: seq },
3148
+ sourceEventSeqs: [seq],
3149
+ },
3150
+ )
3151
+ scan.done.add(seq)
3152
+ ctx.logger?.info(
3153
+ 'vision-router: sanitized a tool-result image block out of the model surface (event seq %s)',
3154
+ seq,
3155
+ )
3156
+ } catch (error) {
3157
+ // A failed shadow leaves the original event on the surface: the
3158
+ // session stays usable (today's behavior) instead of crashing the
3159
+ // pre-step.
3160
+ ctx.logger?.warn(
3161
+ 'vision-router: could not sanitize tool-result image at event seq %s (%s)',
3162
+ seq,
3163
+ error && error.message ? error.message : String(error),
3164
+ )
3165
+ }
3166
+ }
3167
+ scan.count += newSeqs.length
2723
3168
  }
2724
3169
 
2725
3170
  // session -> { turn, startIndex, hasImage, routed, failures, lastError }
@@ -2735,12 +3180,37 @@ export function apply(ctx, config = {}) {
2735
3180
  // sanitized. This preserves attachment lookup for a later vision_describe.
2736
3181
  const rawImageRefs = rewriteImageBlocks(rawMessages)
2737
3182
  recordUploadedAttachments(session, rawImageRefs.attachments)
3183
+ // Also index image attachments that live only in the session event log
3184
+ // (read_image re-uploads never cross the inbox-claim message stream).
3185
+ // Incremental: scans only new events (issue #72).
3186
+ scanSessionEventLog(session)
2738
3187
  // Hard invariant: tool-produced image blocks never reach a model request.
2739
- // This is deliberately route-agnostic, because merely having a wrapper
2740
- // registered does not prove that the current request is actually using it.
3188
+ // Two layers: (1) sanitize tool-result images in the inbox claim, and
3189
+ // (2) shadow-sanitize historical tool/result events on the session
3190
+ // surface (issue #74) — the only lever that can rewrite durable history.
2741
3191
  const sanitizedToolResults = sanitizeToolResultImages(rawMessages)
2742
3192
  const messages = sanitizedToolResults.messages
2743
3193
  const hasImage = messages.some((message) => blocksHaveImage(message && message.content))
3194
+ try {
3195
+ await sanitizeSessionToolResults(session)
3196
+ } catch (error) {
3197
+ ctx.logger?.warn(
3198
+ 'vision-router: session-surface sanitization failed (%s)',
3199
+ error && error.message ? error.message : String(error),
3200
+ )
3201
+ }
3202
+ // Register the turn state BEFORE the image-turn branches below: those
3203
+ // branches return early (auto-mount reminder, history rewrite), and the
3204
+ // agent/request hook must still see the state, otherwise an image turn is
3205
+ // served by the text provider and rejected (issue #74, second root cause).
3206
+ if (routingEnabled()) {
3207
+ const events = session.events ?? []
3208
+ turnState.set(session, {
3209
+ turn: payload.turn,
3210
+ startIndex: events.length,
3211
+ hasImage,
3212
+ })
3213
+ }
2744
3214
  if (hasImage) {
2745
3215
  // Auto-mount the deep vision tools on image turns: the model can use
2746
3216
  // them from its very first step without the user asking for them.
@@ -2805,14 +3275,6 @@ export function apply(ctx, config = {}) {
2805
3275
  return { ...decision, messages: cleaned.messages }
2806
3276
  }
2807
3277
  }
2808
- if (routingEnabled()) {
2809
- const events = session.events ?? []
2810
- turnState.set(session, {
2811
- turn: payload.turn,
2812
- startIndex: events.length,
2813
- hasImage,
2814
- })
2815
- }
2816
3278
  return sanitizedToolResults.changed ? { ...decision, messages } : decision
2817
3279
  })
2818
3280
 
package/lib/client.js CHANGED
@@ -554,20 +554,116 @@ window.__ModuleLoader__.load({
554
554
  }
555
555
 
556
556
  const ONBOARDING_STORAGE_KEY = 'dsh-vision-router:onboarding:model-guide-v2'
557
+ // issue #78: DSH Desktop serves the Web UI from a random port on every
558
+ // launch (--port 0), so origin-scoped localStorage forgets everything
559
+ // between restarts and the first-run dialog re-appeared on every boot.
560
+ // The durable source of truth is now the `vision-router` settings section
561
+ // (the profile settings file, synced through ctx.settingsScope);
562
+ // localStorage survives only as a legacy/migration fallback for
563
+ // pre-v1.2.3 browsers and as a best-effort downgrade channel.
564
+ const ONBOARDING_SETTINGS_KEY = 'onboardingSeen'
557
565
  // The walkthrough has three steps: step1 (the session/text model selector
558
566
  // on the chat page), step2 (open Settings → Plugins → Vision Router), and
559
567
  // step3 (the highlighted vision chain, rendered by the settings card as a
560
568
  // callout). Only step1/step2 need persistence; step3 re-derives from the
561
569
  // card being on screen.
562
570
  const VISION_GUIDE_STORAGE_KEY = 'dsh-vision-router:guide:vision-backend-v2'
571
+ const VISION_GUIDE_SETTINGS_KEY = 'visionGuideStep'
563
572
  const VISION_GUIDE_EVENT = 'dsh-vision-router:vision-settings-guide'
564
573
  let visionGuidePrompt
565
574
  let visionGuideStepMemory
575
+ // Installed by apply(): a narrow, defensive view of the settings scope.
576
+ // { get, set, unset } never throw, and subscribe returns a disposer (or
577
+ // undefined when the scope is unavailable — the legacy paths take over).
578
+ let settingsPersistence
579
+ function installSettingsPersistence(scope) {
580
+ const readSection = () => {
581
+ try {
582
+ const snapshot = scope && scope.getSnapshot && scope.getSnapshot()
583
+ return snapshot && snapshot.value ? snapshot.value : undefined
584
+ } catch {
585
+ return undefined
586
+ }
587
+ }
588
+ settingsPersistence = {
589
+ get(field) {
590
+ const section = readSection()
591
+ return section ? section[field] : undefined
592
+ },
593
+ set(field, value) {
594
+ try {
595
+ const snapshot = scope && scope.getSnapshot && scope.getSnapshot()
596
+ if (!snapshot || !snapshot.writable || typeof scope.set !== 'function') return
597
+ void scope.set(field, value)
598
+ } catch {
599
+ // Read-only provider or unavailable scope: the localStorage
600
+ // fallback keeps this load functional.
601
+ }
602
+ },
603
+ unset(field) {
604
+ try {
605
+ const snapshot = scope && scope.getSnapshot && scope.getSnapshot()
606
+ if (!snapshot || !snapshot.writable || typeof scope.unset !== 'function') return
607
+ void scope.unset(field)
608
+ } catch {
609
+ // Same fallback reasoning as set().
610
+ }
611
+ },
612
+ subscribe(listener) {
613
+ try {
614
+ return scope && typeof scope.subscribe === 'function' ? scope.subscribe(listener) : undefined
615
+ } catch {
616
+ return undefined
617
+ }
618
+ },
619
+ }
620
+ }
621
+
622
+ function readOnboardingSeen() {
623
+ try {
624
+ if (settingsPersistence && settingsPersistence.get(ONBOARDING_SETTINGS_KEY) === true) return true
625
+ } catch {
626
+ // Fall through to the legacy origin-scoped marker.
627
+ }
628
+ try {
629
+ if (window.localStorage && window.localStorage.getItem(ONBOARDING_STORAGE_KEY) === 'seen') {
630
+ // Legacy value from before the settings-backed flag: migrate it so
631
+ // it survives desktop restarts, then honor it for this load.
632
+ if (settingsPersistence) settingsPersistence.set(ONBOARDING_SETTINGS_KEY, true)
633
+ return true
634
+ }
635
+ } catch {
636
+ // Best effort only.
637
+ }
638
+ return false
639
+ }
640
+ function rememberOnboardingSeen() {
641
+ try {
642
+ if (settingsPersistence) settingsPersistence.set(ONBOARDING_SETTINGS_KEY, true)
643
+ } catch {
644
+ // The dialog can still be dismissed for this page load.
645
+ }
646
+ try {
647
+ if (window.localStorage) window.localStorage.setItem(ONBOARDING_STORAGE_KEY, 'seen')
648
+ } catch {
649
+ // Best effort: the dialog can still be dismissed for this page load.
650
+ }
651
+ }
566
652
 
567
653
  function readVisionGuideStep() {
568
654
  try {
569
- const value = window.localStorage && window.localStorage.getItem(VISION_GUIDE_STORAGE_KEY)
655
+ const value = settingsPersistence && settingsPersistence.get(VISION_GUIDE_SETTINGS_KEY)
570
656
  if (value === 'step1' || value === 'step2') return value
657
+ } catch {
658
+ // Fall through to the legacy origin-scoped marker.
659
+ }
660
+ try {
661
+ const value = window.localStorage && window.localStorage.getItem(VISION_GUIDE_STORAGE_KEY)
662
+ if (value === 'step1' || value === 'step2') {
663
+ // Legacy value: migrate into settings for desktop durability.
664
+ if (settingsPersistence) settingsPersistence.set(VISION_GUIDE_SETTINGS_KEY, value)
665
+ return value
666
+ }
571
667
  } catch {
572
668
  // Fall through to page-memory state when storage access is blocked.
573
669
  }
@@ -575,6 +671,14 @@ window.__ModuleLoader__.load({
575
671
  }
576
672
  function writeVisionGuideStep(step) {
577
673
  visionGuideStepMemory = step === 'step1' || step === 'step2' ? step : undefined
674
+ try {
675
+ if (settingsPersistence) {
676
+ if (visionGuideStepMemory) settingsPersistence.set(VISION_GUIDE_SETTINGS_KEY, visionGuideStepMemory)
677
+ else settingsPersistence.unset(VISION_GUIDE_SETTINGS_KEY)
678
+ }
679
+ } catch {
680
+ // Page-memory state still keeps the guide functional for this load.
681
+ }
578
682
  try {
579
683
  if (!window.localStorage) return
580
684
  if (visionGuideStepMemory) window.localStorage.setItem(VISION_GUIDE_STORAGE_KEY, visionGuideStepMemory)
@@ -677,9 +781,13 @@ window.__ModuleLoader__.load({
677
781
  syncVisionGuidePrompt(t)
678
782
  window.addEventListener(VISION_GUIDE_EVENT, sync)
679
783
  window.addEventListener('popstate', sync)
784
+ // The durable step now lives in the settings section; re-sync when its
785
+ // snapshot changes (e.g. the first read resolves after page load).
786
+ const unsubscribe = settingsPersistence && settingsPersistence.subscribe(sync)
680
787
  return () => {
681
788
  window.removeEventListener(VISION_GUIDE_EVENT, sync)
682
789
  window.removeEventListener('popstate', sync)
790
+ if (typeof unsubscribe === 'function') unsubscribe()
683
791
  removeVisionGuidePrompt()
684
792
  }
685
793
  }
@@ -687,13 +795,6 @@ window.__ModuleLoader__.load({
687
795
  let onboardingOverlay
688
796
  let onboardingKeyDown
689
797
 
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
798
  function dismissOnboarding(remember = true) {
698
799
  if (remember) rememberOnboardingSeen()
699
800
  if (typeof document !== 'undefined' && onboardingKeyDown) {
@@ -779,16 +880,33 @@ window.__ModuleLoader__.load({
779
880
  }
780
881
  function installOnboarding(t) {
781
882
  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.
883
+ let timer
884
+ const clearTimer = () => {
885
+ if (timer !== undefined) window.clearTimeout(timer)
886
+ timer = undefined
786
887
  }
787
-
788
- const timer = window.setTimeout(() => showOnboarding(t), 650)
789
-
888
+ // Reactive against the settings scope: the settings snapshot arrives
889
+ // asynchronously on a cold page load, so a one-shot localStorage check
890
+ // could show the dialog before the durable "seen" flag lands. Re-sync
891
+ // on every snapshot change and dismiss the overlay if the flag appears.
892
+ const sync = () => {
893
+ if (readOnboardingSeen()) {
894
+ clearTimer()
895
+ if (onboardingOverlay) dismissOnboarding(false)
896
+ return
897
+ }
898
+ if (timer === undefined && !onboardingOverlay) {
899
+ timer = window.setTimeout(() => {
900
+ timer = undefined
901
+ if (!readOnboardingSeen()) showOnboarding(t)
902
+ }, 650)
903
+ }
904
+ }
905
+ sync()
906
+ const unsubscribe = settingsPersistence && settingsPersistence.subscribe(sync)
790
907
  return () => {
791
- if (timer !== undefined) window.clearTimeout(timer)
908
+ clearTimer()
909
+ if (typeof unsubscribe === 'function') unsubscribe()
792
910
  // Unmounting is not a user choice: drop the dialog without marking
793
911
  // the guide seen, so the first-run overview can still auto-open.
794
912
  if (onboardingOverlay) dismissOnboarding(false)
@@ -1894,6 +2012,10 @@ window.__ModuleLoader__.load({
1894
2012
 
1895
2013
  function apply(ctx) {
1896
2014
  const scope = ctx.settingsScope.bind({ namespace: 'vision-router' })
2015
+ // issue #78: route the onboarding/guide durability through the settings
2016
+ // section (profile settings file) so it survives DSH Desktop's random
2017
+ // per-launch port. Installed before the effects below run.
2018
+ installSettingsPersistence(scope)
1897
2019
  const presentedImageUrls = new Map()
1898
2020
  const createdImageUrls = new Set()
1899
2021
  const loadPresentedImage = (sessionId, attachment) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-vision-router",
3
- "version": "1.2.1",
3
+ "version": "1.2.3",
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": {