@mengruo/dsh-vision-toolkit 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (216) hide show
  1. package/LICENSE +21 -0
  2. package/README.i18n.yaml +6 -0
  3. package/README.md +356 -0
  4. package/README.zh.md +354 -0
  5. package/assets/community-group-qr.png +0 -0
  6. package/assets/dsh-conversation-artifact.png +0 -0
  7. package/assets/dsh-conversation-image-qa-top.png +0 -0
  8. package/assets/dsh-conversation-image-qa.png +0 -0
  9. package/assets/dsh-conversation-pixel-diff.png +0 -0
  10. package/assets/dsh-conversation-screenshot-debugging-top.png +0 -0
  11. package/assets/dsh-conversation-screenshot-debugging.png +0 -0
  12. package/assets/dsh-conversation-tool-call.png +0 -0
  13. package/assets/dsh-conversation-vision-trace.png +0 -0
  14. package/assets/dsh-view-example.png +0 -0
  15. package/assets/hero-v2.png +0 -0
  16. package/assets/logo_aihubmix.png +0 -0
  17. package/assets/python-bootstrap.json +48 -0
  18. package/assets/skill/SKILL.md +329 -0
  19. package/assets/skill/UPSTREAM.json +71 -0
  20. package/assets/skill/references/gui.md +88 -0
  21. package/assets/skill/references/long-screenshot-ocr.md +77 -0
  22. package/assets/skill/references/restore-graphic.md +84 -0
  23. package/assets/skill/references/restore-structure.md +45 -0
  24. package/assets/skill/references/restore-ui.md +202 -0
  25. package/assets/social-preview.png +0 -0
  26. package/assets/upstream/README.md +18 -0
  27. package/assets/upstream/focus-hint-comparison-1.webp +0 -0
  28. package/assets/upstream/focus-hint-comparison-2.webp +0 -0
  29. package/assets/upstream/infographic-reference.webp +0 -0
  30. package/assets/upstream/infographic-result.webp +0 -0
  31. package/assets/upstream/ui-fast-restore-reference.webp +0 -0
  32. package/assets/upstream/ui-fast-restore-result.webp +0 -0
  33. package/assets/upstream/ui-result.webp +0 -0
  34. package/assets/upstream/ui-sketch.webp +0 -0
  35. package/assets/vision-model-test.png +0 -0
  36. package/assets/vision-settings.png +0 -0
  37. package/assets/wechat-reward.png +0 -0
  38. package/cordis.patch.yml +6 -0
  39. package/docs/aihubmix-gemini-vision.i18n.yaml +6 -0
  40. package/docs/aihubmix-gemini-vision.md +138 -0
  41. package/docs/aihubmix-gemini-vision.zh.md +138 -0
  42. package/docs/assets/aihubmix-api-keys.png +0 -0
  43. package/docs/assets/aihubmix-create-key.png +0 -0
  44. package/docs/assets/aihubmix-free-vision-model.png +0 -0
  45. package/docs/assets/aihubmix-home.png +0 -0
  46. package/docs/assets/aihubmix-sign-up.png +0 -0
  47. package/docs/assets/groq-console-home.png +0 -0
  48. package/docs/assets/groq-console-keys-login.png +0 -0
  49. package/docs/assets/groq-docs-quickstart.png +0 -0
  50. package/docs/assets/groq-docs-qwen3.6.png +0 -0
  51. package/docs/assets/groq-docs-vision.png +0 -0
  52. package/docs/assets/vision-settings.png +0 -0
  53. package/docs/dsh-desktop-install.md +67 -0
  54. package/docs/dsh-desktop-install.zh.md +67 -0
  55. package/docs/groq-qwen3.6-vision.i18n.yaml +6 -0
  56. package/docs/groq-qwen3.6-vision.md +236 -0
  57. package/docs/groq-qwen3.6-vision.zh.md +236 -0
  58. package/docs/python-runtime.i18n.yaml +6 -0
  59. package/docs/python-runtime.md +89 -0
  60. package/docs/python-runtime.zh.md +89 -0
  61. package/docs/requirements-traceability/README.i18n.yaml +6 -0
  62. package/docs/requirements-traceability/README.md +75 -0
  63. package/docs/requirements-traceability/README.zh.md +75 -0
  64. package/examples/ui-restoration/README.i18n.yaml +6 -0
  65. package/examples/ui-restoration/README.md +70 -0
  66. package/examples/ui-restoration/README.zh.md +70 -0
  67. package/examples/ui-restoration/assets/final-heatmap.png +0 -0
  68. package/examples/ui-restoration/assets/final-report.json +83 -0
  69. package/examples/ui-restoration/assets/implementation.png +0 -0
  70. package/examples/ui-restoration/assets/initial-heatmap.png +0 -0
  71. package/examples/ui-restoration/assets/initial-report.json +83 -0
  72. package/examples/ui-restoration/assets/initial.png +0 -0
  73. package/examples/ui-restoration/assets/metrics.json +12 -0
  74. package/examples/ui-restoration/assets/reference.png +0 -0
  75. package/examples/ui-restoration/implementation.html +94 -0
  76. package/examples/ui-restoration/initial.html +57 -0
  77. package/lib/artifact-access.js +369 -0
  78. package/lib/artifact-access.js.map +1 -0
  79. package/lib/artifacts.js +56 -0
  80. package/lib/artifacts.js.map +1 -0
  81. package/lib/client.js +2099 -0
  82. package/lib/client.js.map +1 -0
  83. package/lib/config.js +172 -0
  84. package/lib/config.js.map +1 -0
  85. package/lib/defaults.js +6 -0
  86. package/lib/defaults.js.map +1 -0
  87. package/lib/errors.js +56 -0
  88. package/lib/errors.js.map +1 -0
  89. package/lib/evidence-cache.js +350 -0
  90. package/lib/evidence-cache.js.map +1 -0
  91. package/lib/exposure.js +247 -0
  92. package/lib/exposure.js.map +1 -0
  93. package/lib/image-input-variants.js +882 -0
  94. package/lib/image-input-variants.js.map +1 -0
  95. package/lib/index.js +105 -0
  96. package/lib/index.js.map +1 -0
  97. package/lib/paste-images.js +217 -0
  98. package/lib/paste-images.js.map +1 -0
  99. package/lib/paths.js +354 -0
  100. package/lib/paths.js.map +1 -0
  101. package/lib/plugin-update.js +1003 -0
  102. package/lib/plugin-update.js.map +1 -0
  103. package/lib/runtime-install.js +964 -0
  104. package/lib/runtime-install.js.map +1 -0
  105. package/lib/runtime-manager.js +133 -0
  106. package/lib/runtime-manager.js.map +1 -0
  107. package/lib/runtime.js +1678 -0
  108. package/lib/runtime.js.map +1 -0
  109. package/lib/skill.js +25 -0
  110. package/lib/skill.js.map +1 -0
  111. package/lib/tools.js +549 -0
  112. package/lib/tools.js.map +1 -0
  113. package/lib/types/artifact-access.d.ts +61 -0
  114. package/lib/types/artifact-access.d.ts.map +1 -0
  115. package/lib/types/artifacts.d.ts +42 -0
  116. package/lib/types/artifacts.d.ts.map +1 -0
  117. package/lib/types/client/display-config.d.ts +24 -0
  118. package/lib/types/client/display-config.d.ts.map +1 -0
  119. package/lib/types/client/index.d.ts +350 -0
  120. package/lib/types/client/index.d.ts.map +1 -0
  121. package/lib/types/client/model-variants-hider.d.ts +40 -0
  122. package/lib/types/client/model-variants-hider.d.ts.map +1 -0
  123. package/lib/types/client/paste-images.d.ts +126 -0
  124. package/lib/types/client/paste-images.d.ts.map +1 -0
  125. package/lib/types/config.d.ts +123 -0
  126. package/lib/types/config.d.ts.map +1 -0
  127. package/lib/types/defaults.d.ts +6 -0
  128. package/lib/types/defaults.d.ts.map +1 -0
  129. package/lib/types/errors.d.ts +35 -0
  130. package/lib/types/errors.d.ts.map +1 -0
  131. package/lib/types/evidence-cache.d.ts +91 -0
  132. package/lib/types/evidence-cache.d.ts.map +1 -0
  133. package/lib/types/exposure.d.ts +50 -0
  134. package/lib/types/exposure.d.ts.map +1 -0
  135. package/lib/types/image-input-variants.d.ts +144 -0
  136. package/lib/types/image-input-variants.d.ts.map +1 -0
  137. package/lib/types/index.d.ts +19 -0
  138. package/lib/types/index.d.ts.map +1 -0
  139. package/lib/types/paste-images.d.ts +73 -0
  140. package/lib/types/paste-images.d.ts.map +1 -0
  141. package/lib/types/paths.d.ts +117 -0
  142. package/lib/types/paths.d.ts.map +1 -0
  143. package/lib/types/plugin-update.d.ts +119 -0
  144. package/lib/types/plugin-update.d.ts.map +1 -0
  145. package/lib/types/runtime-install.d.ts +92 -0
  146. package/lib/types/runtime-install.d.ts.map +1 -0
  147. package/lib/types/runtime-manager.d.ts +60 -0
  148. package/lib/types/runtime-manager.d.ts.map +1 -0
  149. package/lib/types/runtime.d.ts +412 -0
  150. package/lib/types/runtime.d.ts.map +1 -0
  151. package/lib/types/skill.d.ts +14 -0
  152. package/lib/types/skill.d.ts.map +1 -0
  153. package/lib/types/tools.d.ts +35 -0
  154. package/lib/types/tools.d.ts.map +1 -0
  155. package/lib/types/upstream.d.ts +233 -0
  156. package/lib/types/upstream.d.ts.map +1 -0
  157. package/lib/types/version.d.ts +15 -0
  158. package/lib/types/version.d.ts.map +1 -0
  159. package/lib/types/web-request.d.ts +11 -0
  160. package/lib/types/web-request.d.ts.map +1 -0
  161. package/lib/types/web.d.ts +118 -0
  162. package/lib/types/web.d.ts.map +1 -0
  163. package/lib/upstream.js +860 -0
  164. package/lib/upstream.js.map +1 -0
  165. package/lib/version.js +18 -0
  166. package/lib/version.js.map +1 -0
  167. package/lib/web-request.js +29 -0
  168. package/lib/web-request.js.map +1 -0
  169. package/lib/web.js +462 -0
  170. package/lib/web.js.map +1 -0
  171. package/package.json +149 -0
  172. package/patches/vision-tools-dsh.patch +970 -0
  173. package/runtime/requirements.lock +3 -0
  174. package/src/artifact-access.ts +386 -0
  175. package/src/artifacts.ts +85 -0
  176. package/src/client/display-config.ts +62 -0
  177. package/src/client/index.tsx +1580 -0
  178. package/src/client/model-variants-hider.ts +159 -0
  179. package/src/client/paste-images.tsx +747 -0
  180. package/src/config.ts +290 -0
  181. package/src/defaults.ts +5 -0
  182. package/src/errors.ts +62 -0
  183. package/src/evidence-cache.ts +418 -0
  184. package/src/exposure.ts +265 -0
  185. package/src/image-input-variants.ts +996 -0
  186. package/src/index.ts +141 -0
  187. package/src/paste-images.ts +286 -0
  188. package/src/paths.ts +383 -0
  189. package/src/plugin-update.ts +1143 -0
  190. package/src/runtime-install.ts +1161 -0
  191. package/src/runtime-manager.ts +174 -0
  192. package/src/runtime.ts +2189 -0
  193. package/src/skill.ts +35 -0
  194. package/src/tools.ts +691 -0
  195. package/src/upstream.ts +1078 -0
  196. package/src/version.ts +37 -0
  197. package/src/web-request.ts +27 -0
  198. package/src/web.ts +594 -0
  199. package/vendor/agent-vision-toolkit/CHANGELOG.md +30 -0
  200. package/vendor/agent-vision-toolkit/LICENSE +21 -0
  201. package/vendor/agent-vision-toolkit/README.md +405 -0
  202. package/vendor/agent-vision-toolkit/UPSTREAM_MANIFEST.json +94 -0
  203. package/vendor/agent-vision-toolkit/bin/crop +90 -0
  204. package/vendor/agent-vision-toolkit/bin/detect +13 -0
  205. package/vendor/agent-vision-toolkit/bin/glance +93 -0
  206. package/vendor/agent-vision-toolkit/bin/ground +13 -0
  207. package/vendor/agent-vision-toolkit/bin/trace +129 -0
  208. package/vendor/agent-vision-toolkit/detect.py +60 -0
  209. package/vendor/agent-vision-toolkit/ground.py +267 -0
  210. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/dominant_colors.py +224 -0
  211. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/extract_fg.py +278 -0
  212. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/html_shot.py +420 -0
  213. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/long_screenshot_ocr.py +1245 -0
  214. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/pixel_diff.py +88 -0
  215. package/vendor/agent-vision-toolkit/tests/test_vision_client.py +450 -0
  216. package/vendor/agent-vision-toolkit/vision_client.py +310 -0
@@ -0,0 +1,1580 @@
1
+ /**
2
+ * DSH Vision Toolkit browser plugin: dedicated Tool cards plus the Settings,
3
+ * health, connection-test, and safe Artifact preview experience.
4
+ */
5
+
6
+ import {
7
+ useEffect,
8
+ useState,
9
+ useSyncExternalStore,
10
+ type ReactNode,
11
+ } from 'react'
12
+ import { Button, Input } from '@deepseek-ai/dsh-client-ui-primitives'
13
+ import type { ClientContext, ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
14
+ import type {} from '@deepseek-ai/dsh-api-remotes/client'
15
+ import type {} from '@deepseek-ai/dsh-credentials/types'
16
+ import type {} from '@deepseek-ai/dsh-settings/types'
17
+ import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
18
+ import type {} from '@deepseek-ai/dsh-client-ui-input-trigger/client'
19
+ import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
20
+ import type {} from '@deepseek-ai/dsh-client-locale/client'
21
+ import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
22
+ import { installPasteImages } from './paste-images.tsx'
23
+ import { installModelVariantsHider } from './model-variants-hider.ts'
24
+ import { resetDisplayConfigCache } from './display-config.ts'
25
+
26
+ const NS = 'vision-toolkit'
27
+ const SETTINGS_ROUTE = '/_dsh/vision-toolkit/settings'
28
+ const PRESENTATION_META_KEY = '$dshVisionToolkit'
29
+ const DEFAULT_USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36'
30
+ // Keep these browser defaults aligned with src/defaults.ts without importing server-side config.
31
+ const BUILT_IN_FREE_VISION_BASE_URL = 'https://vision.anionex.me/v1'
32
+ const BUILT_IN_FREE_VISION_CREDENTIAL = 'ANIONEX_FREE_VISION'
33
+ const BUILT_IN_FREE_VISION_MODEL = 'gemini-3.7-flash'
34
+ const AIHUBMIX_TUTORIAL_URL_EN = 'https://github.com/Anionex/dsh-vision-toolkit/blob/main/docs/aihubmix-gemini-vision.md'
35
+ const AIHUBMIX_TUTORIAL_URL_ZH = 'https://github.com/Anionex/dsh-vision-toolkit/blob/main/docs/aihubmix-gemini-vision.zh.md'
36
+
37
+ const en = {
38
+ nav: 'Vision',
39
+ settingsTitle: 'Vision Toolkit',
40
+ settingsIntro: 'Configure the pinned visual engineering runtime, its external vision endpoint, and local safety limits.',
41
+ externalNotice: 'Remote tools send the selected image bytes to the configured external vision API. Local crop, trace, pixel diff, palette, foreground extraction, and HTML rendering do not upload images.',
42
+ provider: 'Vision service',
43
+ providerHint: 'Choose the API protocol, then provide the service address, model, and API key used by online vision features.',
44
+ aihubmixTutorial: 'Need an AIHubMix key for free Gemini 3.7 Flash vision? Follow the signup guide →',
45
+ baseUrl: 'Base URL',
46
+ apiKey: 'API key',
47
+ apiKeyPlaceholderMissing: 'Paste the API key',
48
+ apiKeyPlaceholderConfigured: 'Saved; leave blank to keep it',
49
+ apiKeyHint: 'The key is stored in DSH Credentials and is never shown again after saving.',
50
+ apiKeyLocked: 'The current key comes from a read-only source and cannot be replaced here.',
51
+ apiKeyBlank: 'The API key cannot contain only spaces.',
52
+ apiKeyInvalid: 'Paste only the key, without a variable name, quotes, spaces, or line breaks.',
53
+ credential: 'Credential name',
54
+ credentialHint: 'The built-in free provider needs no user key. For a custom provider, this is the DSH credential reference used to store its key.',
55
+ model: 'Model',
56
+ protocol: 'API protocol',
57
+ anthropicThinking: 'Anthropic thinking',
58
+ anthropicThinkingHint: 'omit has the broadest compatibility. Use disabled or adaptive only when the selected model documents that mode; restore omit first after HTTP 400.',
59
+ userAgent: 'User-Agent',
60
+ language: 'Output language',
61
+ limits: 'Limits',
62
+ timeout: 'Request timeout (ms)',
63
+ maxBytes: 'Maximum image bytes',
64
+ maxPixels: 'Maximum image pixels',
65
+ concurrency: 'Concurrent calls per session',
66
+ runtime: 'Runtime',
67
+ runtimeMode: 'Runtime mode',
68
+ toolkitPath: 'Pinned checkout path',
69
+ python: 'Python override',
70
+ allowedDirs: 'Additional allowed directories',
71
+ allowedDirsHint: 'One path per line. The session workspace is always allowed.',
72
+ save: 'Save and apply',
73
+ saving: 'Validating runtime…',
74
+ reload: 'Reload',
75
+ saved: 'Settings validated and applied.',
76
+ readOnly: 'Service settings are read-only. A writable API key can still be saved.',
77
+ configured: 'Configured',
78
+ missing: 'Missing',
79
+ source: 'Source',
80
+ sourceHint: '{source}: {value}',
81
+ sourceEnv: 'Environment variable',
82
+ sourceFile: 'Credential file',
83
+ health: 'Health',
84
+ runHealth: 'Run health check',
85
+ testConnection: 'Test API connection',
86
+ testModel: 'Test vision model',
87
+ testing: 'Checking…',
88
+ testingModel: 'Testing model…',
89
+ connectionHint: 'The API connection test only queries GET /models. The vision model test sends the bundled diagnostic image and verifies one real multimodal request.',
90
+ saveBeforeTesting: 'Save service changes before testing the connection.',
91
+ advanced: 'Advanced settings',
92
+ advancedHint: 'Credential name, provider compatibility, output language, resource limits, runtime source, Python, and additional readable directories.',
93
+ imageInput: 'Image input',
94
+ hiddenVariants: 'Transparent variant routing',
95
+ hiddenVariantsLabel: 'Keep the original model names and enable images automatically',
96
+ hiddenVariantsHint: 'Text-only models keep one model-selector entry with the original name while the session runs on the image-capable variant. Pasted images, image history, and the built-in read_image tool keep working; disable to restore the explicit (Vision Toolkit) entries.',
97
+ pluginVersion: 'Plugin',
98
+ upstreamVersion: 'Upstream',
99
+ activeGeneration: 'Runtime generation',
100
+ activeGenerationValue: 'Generation {generation}',
101
+ updates: 'Plugin updates',
102
+ updatesHint: 'Check npm for a newer release, install it into this DSH profile, and restart DSH Web automatically.',
103
+ manualUpdate: 'Manual update',
104
+ manualUpdateHint: 'Run this command in your terminal to install the latest release into this DSH profile.',
105
+ copy: 'Copy',
106
+ copied: 'Copied',
107
+ checkUpdate: 'Check for updates',
108
+ checkingUpdate: 'Checking for updates…',
109
+ updateAvailable: 'Update available',
110
+ updateAvailableDetail: 'Version {version} is available. It will restart DSH Web automatically when safe; otherwise you will be asked to restart it manually.',
111
+ upToDate: 'Up to date',
112
+ upToDateDetail: 'Version {version} is the latest release.',
113
+ updateNow: 'Install update',
114
+ updatingPlugin: 'Installing update…',
115
+ updateConfirm: 'Install Vision Toolkit {version} now? DSH Web will restart automatically when supported; otherwise a manual restart will be required.',
116
+ restarting: 'Version {version} was installed. Waiting for DSH Web to restart…',
117
+ manualRestartRequired: 'Version {version} was installed. Restart DSH Web through your usual command or process manager to activate it.',
118
+ updateProfile: 'Profile',
119
+ updateInstalled: 'Installed',
120
+ updateLatest: 'Latest',
121
+ updateUnsupported: 'In-app updates are unavailable for this installation.',
122
+ updateReasonProfileNotFound: 'The running plugin could not be matched to a DSH profile installation.',
123
+ updateReasonNotDependency: 'The plugin is not a direct dependency of this DSH profile.',
124
+ updateReasonLocalSource: 'This profile uses a local, workspace, URL, or git installation; update that source manually so local work is not overwritten.',
125
+ updateReasonReadOnly: 'The profile package manifest is read-only.',
126
+ updateReasonPnpm: 'pnpm is unavailable in the DSH execution environment.',
127
+ updateReasonPlatform: 'Automatic restart is unavailable on this operating system.',
128
+ updateReasonRestartUnmanaged: 'Detached self-restart is disabled. Use a supported process manager, or explicitly opt in with DSH_VISION_TOOLKIT_ALLOW_DETACHED_RESTART=1 for an unsupervised Web process.',
129
+ updateReasonRestartAddress: 'Automatic restart is unavailable when DSH Web uses an unknown or dynamically allocated port. Start it with a fixed --port value.',
130
+ updateSaveFirst: 'Save or discard the current Settings and API key changes before updating the plugin.',
131
+ restartTimedOut: 'DSH Web did not return with the target plugin version. Check the restart log and restart the Web profile through its original process manager.',
132
+ restartRolledBack: 'The new plugin did not become ready, so the previous version was restored. Check the restart log before trying again.',
133
+ pluginKind: 'DSH native plugin',
134
+ runtimeUnavailable: 'Runtime unavailable',
135
+ runtimeCandidateRejected: 'Last runtime candidate was rejected; the active generation remains available.',
136
+ runtimeReady: 'Ready',
137
+ runtimeManaged: 'Managed',
138
+ runtimeExternal: 'External checkout',
139
+ retry: 'Retry',
140
+ open: 'Open file',
141
+ download: 'Download',
142
+ previewUnavailable: 'HTTP preview is unavailable in this host; use Open file.',
143
+ running: 'Running…',
144
+ failed: 'Failed',
145
+ matches: 'matches',
146
+ elements: 'elements',
147
+ dimensions: 'Dimensions',
148
+ coordinates: 'Coordinates',
149
+ artifact: 'Artifact',
150
+ artifacts: 'Artifacts',
151
+ difference: 'Overall difference',
152
+ worstRegions: 'Worst regions',
153
+ colors: 'Dominant colors',
154
+ noResult: 'Structured result unavailable; inspect the raw Tool result.',
155
+ healthy: 'Healthy',
156
+ degraded: 'Needs attention',
157
+ notTested: 'Not tested',
158
+ groundTitle: 'Ground',
159
+ detectTitle: 'Detect',
160
+ traceTitle: 'Trace SVG',
161
+ pixelDiffTitle: 'Pixel Diff',
162
+ cropTitle: 'Crop',
163
+ longOcrTitle: 'Long OCR',
164
+ extractForegroundTitle: 'Extract Foreground',
165
+ htmlScreenshotTitle: 'HTML Screenshot',
166
+ artifactTitle: 'Vision Artifact',
167
+ dominantColorsTitle: 'Dominant Colors',
168
+ artifactGroundPreview: 'Grounding bounding-box preview',
169
+ artifactDetectPreview: 'Detected-element bounding-box preview',
170
+ artifactCrop: 'Cropped image region',
171
+ artifactTrace: 'Traced vector geometry',
172
+ artifactDiffHeatmap: 'Pixel-difference heatmap',
173
+ artifactDiffReport: 'Structured pixel-difference report',
174
+ artifactLongManifest: 'Long-screenshot split and merge manifest',
175
+ artifactLongTranscript: 'Merged long-screenshot OCR transcript',
176
+ artifactLongAudit: 'Long-screenshot OCR boundary audit',
177
+ artifactLongChunk: 'Long-screenshot OCR chunk {index}',
178
+ artifactOcrSidecar: 'OCR sidecar for chunk {index}',
179
+ artifactForeground: 'Extracted transparent foreground',
180
+ artifactHtmlScreenshot: 'Headless browser screenshot of local HTML',
181
+ label: 'Label',
182
+ paths: 'paths',
183
+ healthPython: 'Python',
184
+ healthDependencies: 'Dependencies',
185
+ healthChrome: 'Browser',
186
+ healthCredential: 'Credential',
187
+ healthArtifactDirectory: 'Artifact directory',
188
+ healthTempDirectory: 'Temporary directory',
189
+ healthService: 'Vision service',
190
+ healthModel: 'Vision model',
191
+ statusOk: 'OK',
192
+ statusWarning: 'Warning',
193
+ statusError: 'Error',
194
+ statusNotTested: 'Not tested',
195
+ positiveInteger: '{field} must be a positive integer.',
196
+ healthPythonDetail: '{version} via {path}',
197
+ healthChromeMissing: 'Chrome, Chromium, or Edge was not found; HTML Screenshot is unavailable.',
198
+ healthChromeProbeFailed: 'Could not check whether a supported browser is available.',
199
+ healthCredentialMissing: 'Credential {credential} is not configured.',
200
+ healthCredentialReady: 'Credential {credential} is available.',
201
+ healthCredentialFailed: 'Could not read credential {credential}.',
202
+ healthDirectoryWritable: '{directory} is writable: {path}',
203
+ healthDirectoryNotWritable: '{directory} is not writable: {path}',
204
+ healthArtifactDirectoryFailed: 'Could not prepare the artifact directory.',
205
+ healthConnectionNotTested: 'API connection not tested. Use Test API connection to query /models.',
206
+ healthConnectionCredentialMissing: 'Connection test skipped because the credential is unavailable.',
207
+ healthServiceResponded: 'Service responded at {endpoint} (HTTP {status}).',
208
+ healthServiceRejectedCredential: 'Service rejected the configured credential (HTTP {status}).',
209
+ healthServiceForbidden: 'Service is reachable, but GET /models is restricted (HTTP {status}). This is often an account or model-list permission limit, not an invalid key; you can ignore this warning when the vision-model test reports success.',
210
+ healthServiceNoModels: 'Service is reachable but does not support GET /models (HTTP {status}).',
211
+ healthServiceRateLimited: 'Service is reachable, but the connection test was rate-limited (HTTP 429).',
212
+ healthServiceHttpFailed: 'Connection test failed with HTTP {status}.',
213
+ healthServiceUnreachable: 'Could not reach {endpoint}.',
214
+ healthModelNotTested: 'Vision model not tested. Run Test vision model to make one real multimodal request.',
215
+ healthModelCredentialMissing: 'Vision model test skipped because the credential is unavailable.',
216
+ healthModelReady: 'Model {model} completed a real multimodal request.',
217
+ healthModelFailed: 'Real multimodal request failed: {detail}',
218
+ modelTestVerifiedTag: 'Verified',
219
+ modelTestNotRunTag: 'Not tested',
220
+ modelTestFailedTag: 'Test failed',
221
+ } as const
222
+
223
+ type LocaleKey = keyof typeof en
224
+
225
+ const zh: Record<LocaleKey, string> = {
226
+ nav: '视觉工具',
227
+ settingsTitle: '视觉工具箱',
228
+ settingsIntro: '在这里设置视觉模型服务、工具运行环境,以及图片和文件的本地访问范围。',
229
+ externalNotice: '使用图像理解、目标定位、界面检测或文字识别等在线功能时,所选图片会发送到下方配置的视觉服务。图片裁剪、轮廓描摹、像素对比、主色提取、前景提取和网页截图均在本机完成,不会上传图片。',
230
+ provider: '在线视觉服务',
231
+ providerHint: '选择接口协议后,填写在线视觉功能使用的 API 地址、模型名称和 API 密钥。',
232
+ aihubmixTutorial: '想申请 AIHubMix Key,并用免费 Gemini 3.7 Flash 识图?看这篇图文教程 →',
233
+ baseUrl: 'API 地址',
234
+ apiKey: 'API 密钥',
235
+ apiKeyPlaceholderMissing: '粘贴 API 密钥',
236
+ apiKeyPlaceholderConfigured: '已保存;留空表示不修改',
237
+ apiKeyHint: '密钥会保存到 DSH 凭据存储,保存后不会在页面中回显。',
238
+ apiKeyLocked: '当前密钥来自只读配置,无法在此替换。',
239
+ apiKeyBlank: 'API 密钥不能只包含空格。',
240
+ apiKeyInvalid: '请只粘贴密钥本身,不要包含变量名、引号、空格或换行。',
241
+ credential: '凭据名称',
242
+ credentialHint: '内置免费视觉服务无需用户密钥;切换到自定义服务时,此处是保存其密钥的 DSH 凭据名称。',
243
+ model: '模型名称',
244
+ protocol: 'API 协议',
245
+ anthropicThinking: 'Anthropic thinking',
246
+ anthropicThinkingHint: 'omit 兼容性最好。仅当所选模型明确支持时使用 disabled 或 adaptive;遇到 HTTP 400 时先恢复 omit。',
247
+ userAgent: 'User-Agent',
248
+ language: '结果语言',
249
+ limits: '资源与并发限制',
250
+ timeout: '单次请求超时(毫秒)',
251
+ maxBytes: '单张图片大小上限(字节)',
252
+ maxPixels: '单张图片最大像素数',
253
+ concurrency: '单个会话最多并发任务数',
254
+ runtime: '工具运行环境',
255
+ runtimeMode: '环境来源',
256
+ toolkitPath: 'agent-vision-toolkit 目录',
257
+ python: 'Python 解释器(可选)',
258
+ allowedDirs: '允许读取的其他目录',
259
+ allowedDirsHint: '每行填写一个目录。当前会话的工作目录始终可以读取,无需重复填写。',
260
+ save: '保存设置',
261
+ saving: '正在检查并应用…',
262
+ reload: '重新加载',
263
+ saved: '设置已保存并生效。',
264
+ readOnly: '服务设置来自只读配置;如果 API 密钥可写,仍可在此保存密钥。',
265
+ configured: '已就绪',
266
+ missing: '未配置',
267
+ source: '配置来源',
268
+ sourceHint: '{source}:{value}',
269
+ sourceEnv: '环境变量',
270
+ sourceFile: '凭据文件',
271
+ health: '运行检查',
272
+ runHealth: '检查本地环境',
273
+ testConnection: '测试 API 连接',
274
+ testModel: '测试视觉模型',
275
+ testing: '检查中…',
276
+ testingModel: '正在测试模型…',
277
+ connectionHint: '“测试 API 连接”只请求 GET /models;“测试视觉模型”会发送插件自带的诊断图片,验证一次真实多模态调用。',
278
+ saveBeforeTesting: '修改服务配置后,请先保存,再执行 API 或视觉模型测试。',
279
+ advanced: '高级设置',
280
+ advancedHint: '凭据名称、服务兼容参数、结果语言、资源限制、运行环境来源、Python 和额外可读目录。一般无需修改。',
281
+ imageInput: '图片输入',
282
+ hiddenVariants: '透明变体路由',
283
+ hiddenVariantsLabel: '保留原模型名并自动启用图片能力',
284
+ hiddenVariantsHint: '文本模型在模型列表中只显示原名称,会话实际运行在支持图片的变体路由上:粘贴图片、历史图片和内置 read_image 工具均可正常使用。关闭后恢复显示显式的(Vision Toolkit)条目。',
285
+ pluginVersion: '插件版本',
286
+ upstreamVersion: '工具包版本',
287
+ activeGeneration: '本次运行已应用',
288
+ activeGenerationValue: '{generation} 次',
289
+ updates: '插件更新',
290
+ updatesHint: '检查 npm 新版本,自动更新当前 DSH Profile 中的插件,然后重启 DSH Web。',
291
+ manualUpdate: '手动更新',
292
+ manualUpdateHint: '在终端运行以下命令,将当前 DSH Profile 更新到最新版本。',
293
+ copy: '复制',
294
+ copied: '已复制',
295
+ checkUpdate: '检查更新',
296
+ checkingUpdate: '正在检查更新…',
297
+ updateAvailable: '发现新版本',
298
+ updateAvailableDetail: '可更新到 {version}。能安全自重启时会自动重启,否则安装完成后会提示你手动重启。',
299
+ upToDate: '已是最新版',
300
+ upToDateDetail: '当前 {version} 已是最新正式版本。',
301
+ updateNow: '安装更新',
302
+ updatingPlugin: '正在安装更新…',
303
+ updateConfirm: '现在安装 Vision Toolkit {version} 吗?支持安全自重启时会自动重启,否则需要你手动重启 DSH Web。',
304
+ restarting: '已安装 {version},正在等待 DSH Web 重启…',
305
+ manualRestartRequired: '已安装 {version}。请按你平时的方式手动重启 DSH Web,重启后新版本生效。',
306
+ updateProfile: 'Profile',
307
+ updateInstalled: '当前版本',
308
+ updateLatest: '最新版本',
309
+ updateUnsupported: '当前安装方式不支持页面内更新。',
310
+ updateReasonProfileNotFound: '无法把正在运行的插件匹配到某个 DSH Profile 安装。',
311
+ updateReasonNotDependency: '该插件不是当前 DSH Profile 的直接依赖。',
312
+ updateReasonLocalSource: '当前使用本地、workspace、URL 或 git 安装;为避免覆盖本地修改,请手动更新对应来源。',
313
+ updateReasonReadOnly: '当前 Profile 的 package.json 不可写。',
314
+ updateReasonPnpm: 'DSH 运行环境中找不到 pnpm。',
315
+ updateReasonPlatform: '当前操作系统不支持安全的自动重启。',
316
+ updateReasonRestartUnmanaged: '默认禁用脱离原进程管理器的自重启。仅对无人监管的 Web 进程明确设置 DSH_VISION_TOOLKIT_ALLOW_DETACHED_RESTART=1 后开放。',
317
+ updateReasonRestartAddress: 'DSH Web 使用未知端口或动态端口时无法安全自动重启。请用固定的 --port 值启动。',
318
+ updateSaveFirst: '更新插件前,请先保存或放弃当前 Settings 和 API 密钥修改。',
319
+ restartTimedOut: 'DSH Web 未能以目标插件版本恢复。请检查重启日志,并通过原进程管理器重启 Web Profile。',
320
+ restartRolledBack: '新插件未能就绪,系统已恢复上一版本。再次尝试前请检查重启日志。',
321
+ pluginKind: 'DSH 原生插件',
322
+ runtimeUnavailable: '运行环境尚未就绪',
323
+ runtimeCandidateRejected: '新设置未能生效,仍在使用上一次可用的设置。',
324
+ runtimeReady: '已就绪',
325
+ runtimeManaged: '自动安装',
326
+ runtimeExternal: '本地源码',
327
+ retry: '重试',
328
+ open: '在工作区中打开',
329
+ download: '下载',
330
+ previewUnavailable: '此页面无法直接预览该文件,请在工作区中打开。',
331
+ running: '运行中…',
332
+ failed: '运行失败',
333
+ matches: '处匹配',
334
+ elements: '个元素',
335
+ dimensions: '图片尺寸',
336
+ coordinates: '坐标',
337
+ artifact: '生成文件',
338
+ artifacts: '个生成文件',
339
+ difference: '像素差异',
340
+ worstRegions: '差异最大的区域',
341
+ colors: '种颜色',
342
+ noResult: '未能读取结果,请查看工具的原始输出。',
343
+ healthy: '一切正常',
344
+ degraded: '有项目需要处理',
345
+ notTested: '尚未检查',
346
+ groundTitle: '目标定位',
347
+ detectTitle: '界面元素识别',
348
+ traceTitle: '描摹为 SVG',
349
+ pixelDiffTitle: '像素对比',
350
+ cropTitle: '裁剪图片',
351
+ longOcrTitle: '长图文字识别',
352
+ extractForegroundTitle: '提取前景',
353
+ htmlScreenshotTitle: '网页截图',
354
+ artifactTitle: '视觉处理结果',
355
+ dominantColorsTitle: '主色提取',
356
+ artifactGroundPreview: '目标定位框预览',
357
+ artifactDetectPreview: '界面元素标注预览',
358
+ artifactCrop: '裁剪后的图片',
359
+ artifactTrace: '描摹得到的矢量图',
360
+ artifactDiffHeatmap: '像素差异热力图',
361
+ artifactDiffReport: '像素差异详细报告',
362
+ artifactLongManifest: '长图切分与合并记录',
363
+ artifactLongTranscript: '长图文字识别结果',
364
+ artifactLongAudit: '长图分块边界检查记录',
365
+ artifactLongChunk: '长图文字识别分块 {index}',
366
+ artifactOcrSidecar: '分块 {index} 的文字识别记录',
367
+ artifactForeground: '提取后的透明背景前景图',
368
+ artifactHtmlScreenshot: '本地网页截图',
369
+ label: '名称',
370
+ paths: '条路径',
371
+ healthPython: 'Python',
372
+ healthDependencies: 'Python 依赖',
373
+ healthChrome: '浏览器',
374
+ healthCredential: 'API 密钥',
375
+ healthArtifactDirectory: '输出目录',
376
+ healthTempDirectory: '临时目录',
377
+ healthService: '视觉服务',
378
+ healthModel: '视觉模型',
379
+ statusOk: '正常',
380
+ statusWarning: '注意',
381
+ statusError: '异常',
382
+ statusNotTested: '未检查',
383
+ positiveInteger: '{field}必须填写正整数。',
384
+ healthPythonDetail: '版本 {version};解释器:{path}',
385
+ healthChromeMissing: '未找到 Chrome、Chromium 或 Edge,网页截图功能暂不可用。',
386
+ healthChromeProbeFailed: '无法检查浏览器是否可用。',
387
+ healthCredentialMissing: '尚未配置凭据 {credential}。',
388
+ healthCredentialReady: '已找到凭据 {credential}。',
389
+ healthCredentialFailed: '无法读取凭据 {credential}。',
390
+ healthDirectoryWritable: '{directory}可写:{path}',
391
+ healthDirectoryNotWritable: '{directory}不可写:{path}',
392
+ healthArtifactDirectoryFailed: '无法准备输出目录。',
393
+ healthConnectionNotTested: '尚未测试 API 连接。点击“测试 API 连接”可请求 /models。',
394
+ healthConnectionCredentialMissing: 'API 密钥不可用,未执行连接测试。',
395
+ healthServiceResponded: '服务已响应:{endpoint}(HTTP {status})。',
396
+ healthServiceRejectedCredential: '服务拒绝了当前 API 密钥(HTTP {status})。',
397
+ healthServiceForbidden: '服务可以访问,但对 GET /models 的访问被限制(HTTP {status})。这通常是账号或模型列表权限限制,不代表密钥无效;若“视觉模型已实测正常”,此警告可忽略。',
398
+ healthServiceNoModels: '服务可以访问,但不支持 GET /models(HTTP {status})。',
399
+ healthServiceRateLimited: '服务可以访问,但本次连接测试触发了限流(HTTP 429)。',
400
+ healthServiceHttpFailed: '连接测试失败(HTTP {status})。',
401
+ healthServiceUnreachable: '无法连接到 {endpoint}。',
402
+ healthModelNotTested: '尚未测试视觉模型。点击“测试视觉模型”可执行一次真实多模态请求。',
403
+ healthModelCredentialMissing: '视觉模型测试已跳过,因为当前 API 密钥不可用。',
404
+ healthModelReady: '模型 {model} 已完成一次真实多模态请求。',
405
+ healthModelFailed: '真实多模态请求失败:{detail}',
406
+ modelTestVerifiedTag: '已实测',
407
+ modelTestNotRunTag: '未测试',
408
+ modelTestFailedTag: '测试失败',
409
+ }
410
+
411
+ type Translate = (key: LocaleKey, params?: Record<string, unknown>) => string
412
+
413
+ interface ToolCallOwnerProps {
414
+ callId: string
415
+ toolName: string
416
+ block: ToolCallBlock
417
+ cwd?: string | undefined
418
+ openFile: (path: string) => void
419
+ inspect?: (() => void) | undefined
420
+ }
421
+
422
+ declare module '@deepseek-ai/dsh-client-ui-slots' {
423
+ interface SlotMap {
424
+ /** Keyed atomic Tool call view, dispatched by wire Tool name. */
425
+ 'tool.call.toolview': { kind: 'keyed'; scope: 'session'; owner: ToolCallOwnerProps }
426
+ }
427
+
428
+ interface LocaleNamespaceMap {
429
+ /** DSH Vision Toolkit Tool cards and Settings copy. */
430
+ 'vision-toolkit': LocaleKey
431
+ }
432
+ }
433
+
434
+ type ToolCallViewProps = PropsRuntime<'tool.call.toolview'>
435
+
436
+ interface ArtifactDescriptor {
437
+ path: string
438
+ filename: string
439
+ mimeType: string
440
+ kind: 'image' | 'svg' | 'markdown' | 'json'
441
+ description: string
442
+ sourceTool: string
443
+ previewIntent: 'image' | 'svg' | 'text' | 'download'
444
+ bytes: number
445
+ }
446
+
447
+ interface ArtifactGrant {
448
+ path: string
449
+ previewUrl: string
450
+ downloadUrl: string
451
+ }
452
+
453
+ interface HealthCheck {
454
+ status: 'ok' | 'warning' | 'error' | 'not_tested'
455
+ detail: string
456
+ }
457
+
458
+ interface HealthResult {
459
+ pluginVersion: string
460
+ checks: Record<string, HealthCheck>
461
+ healthy: boolean
462
+ connectionTested: boolean
463
+ modelTested: boolean
464
+ }
465
+
466
+ interface SettingsValue {
467
+ provider?: {
468
+ baseUrl?: string
469
+ credential?: string
470
+ model?: string
471
+ protocol?: 'openai' | 'anthropic'
472
+ anthropicThinking?: 'omit' | 'disabled' | 'adaptive'
473
+ userAgent?: string
474
+ }
475
+ language?: 'zh' | 'en'
476
+ timeoutMs?: number
477
+ maxImageBytes?: number
478
+ maxImagePixels?: number
479
+ concurrency?: number
480
+ runtime?: { mode?: 'managed' | 'external'; agentVisionToolkitPath?: string; python?: string }
481
+ allowedDirs?: string[]
482
+ imageInputVariants?: {
483
+ enabled?: boolean
484
+ providers?: string[]
485
+ autoSwitch?: boolean
486
+ hidden?: boolean
487
+ }
488
+ }
489
+
490
+ type PluginUpdateUnavailableReason =
491
+ | 'profile-not-found'
492
+ | 'not-direct-dependency'
493
+ | 'unsupported-install-source'
494
+ | 'profile-read-only'
495
+ | 'pnpm-unavailable'
496
+ | 'unsupported-platform'
497
+ | 'restart-unmanaged'
498
+ | 'restart-address-unavailable'
499
+
500
+ interface PluginUpdateCapability {
501
+ supported: boolean
502
+ checkSupported?: boolean
503
+ profile?: string
504
+ dependencySpec?: string
505
+ reason?: PluginUpdateUnavailableReason
506
+ }
507
+
508
+ interface PluginUpdateCheck extends PluginUpdateCapability {
509
+ currentVersion: string
510
+ latestVersion?: string
511
+ updateAvailable: boolean
512
+ checkedAt: string
513
+ }
514
+
515
+ type PluginUpdateResult = {
516
+ fromVersion: string
517
+ toVersion: string
518
+ profile: string
519
+ restarting: true
520
+ retryAfterMs: number
521
+ manualRestartRequired?: false
522
+ } | {
523
+ fromVersion: string
524
+ toVersion: string
525
+ profile: string
526
+ restarting: false
527
+ manualRestartRequired: true
528
+ retryAfterMs?: undefined
529
+ }
530
+
531
+ interface SettingsSnapshot {
532
+ schemaVersion: 1
533
+ writable: boolean
534
+ settings: { value: SettingsValue; revision: number; applies: 'live' }
535
+ credential: { ref: string; configured: boolean; source?: string; writable: boolean }
536
+ runtime: {
537
+ ready: boolean
538
+ generation: number
539
+ activeConfig?: SettingsValue
540
+ upstream?: {
541
+ source: 'managed' | 'external'
542
+ path: string
543
+ runtimeHome: string
544
+ python: string
545
+ pythonVersion: string
546
+ }
547
+ lastError?: string
548
+ }
549
+ release: {
550
+ pluginVersion: string
551
+ upstreamRepository: string
552
+ upstreamVersion: string
553
+ upstreamCommit: string
554
+ update: PluginUpdateCapability
555
+ }
556
+ artifactRouteAvailable: boolean
557
+ }
558
+
559
+ interface ApiSuccess<T> { ok: true; value: T }
560
+ interface ApiFailure { ok: false; error: { code: string; message: string } }
561
+
562
+ function isRecord(value: unknown): value is Record<string, unknown> {
563
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
564
+ }
565
+
566
+ function textOfContent(block: ToolCallBlock): string {
567
+ if (!('kind' in block)) return ''
568
+ return block.content
569
+ .filter((entry): entry is Extract<typeof entry, { type: 'text' }> => entry.type === 'text')
570
+ .map(entry => entry.text)
571
+ .join('\n')
572
+ }
573
+
574
+ /** Decode canonical presentation metadata with a JSON-text fallback. */
575
+ export function decodeVisionResult(block: ToolCallBlock): Record<string, unknown> | undefined {
576
+ if (!('kind' in block) || block.isError) return undefined
577
+ if (isRecord(block.meta)) return block.meta
578
+ const text = textOfContent(block).trim()
579
+ if (text.length === 0) return undefined
580
+ try {
581
+ const parsed = JSON.parse(text) as unknown
582
+ return isRecord(parsed) ? parsed : undefined
583
+ } catch {
584
+ return undefined
585
+ }
586
+ }
587
+
588
+ function accessMap(value: Record<string, unknown> | undefined): Map<string, ArtifactGrant> {
589
+ const map = new Map<string, ArtifactGrant>()
590
+ if (value === undefined) return map
591
+ const envelope = value[PRESENTATION_META_KEY]
592
+ if (!isRecord(envelope) || envelope.schemaVersion !== 1 || !Array.isArray(envelope.artifacts)) return map
593
+ for (const entry of envelope.artifacts) {
594
+ if (!isRecord(entry) || typeof entry.path !== 'string' || typeof entry.previewUrl !== 'string' || typeof entry.downloadUrl !== 'string') continue
595
+ map.set(entry.path, entry as unknown as ArtifactGrant)
596
+ }
597
+ return map
598
+ }
599
+
600
+ function artifactFrom(value: unknown): ArtifactDescriptor | undefined {
601
+ if (!isRecord(value)) return undefined
602
+ if (
603
+ typeof value.path !== 'string'
604
+ || typeof value.filename !== 'string'
605
+ || typeof value.mimeType !== 'string'
606
+ || (value.kind !== 'image' && value.kind !== 'svg' && value.kind !== 'markdown' && value.kind !== 'json')
607
+ || typeof value.description !== 'string'
608
+ || typeof value.sourceTool !== 'string'
609
+ || (value.previewIntent !== 'image' && value.previewIntent !== 'svg' && value.previewIntent !== 'text' && value.previewIntent !== 'download')
610
+ || typeof value.bytes !== 'number'
611
+ ) return undefined
612
+ return value as unknown as ArtifactDescriptor
613
+ }
614
+
615
+ function collectArtifacts(value: unknown, found = new Map<string, ArtifactDescriptor>(), depth = 0): ArtifactDescriptor[] {
616
+ if (depth > 16) return [...found.values()]
617
+ const artifact = artifactFrom(value)
618
+ if (artifact !== undefined) {
619
+ found.set(artifact.path, artifact)
620
+ return [...found.values()]
621
+ }
622
+ if (Array.isArray(value)) {
623
+ for (const entry of value) collectArtifacts(entry, found, depth + 1)
624
+ } else if (isRecord(value)) {
625
+ for (const entry of Object.values(value)) collectArtifacts(entry, found, depth + 1)
626
+ }
627
+ return [...found.values()]
628
+ }
629
+
630
+ function numberOf(value: unknown): number | undefined {
631
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined
632
+ }
633
+
634
+ function stringOf(value: unknown): string | undefined {
635
+ return typeof value === 'string' ? value : undefined
636
+ }
637
+
638
+ function formatBytes(bytes: number): string {
639
+ if (bytes < 1024) return `${bytes} B`
640
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
641
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
642
+ }
643
+
644
+ function boxText(value: unknown): string {
645
+ if (!isRecord(value)) return '—'
646
+ const parts = ['x1', 'y1', 'x2', 'y2'].map(key => numberOf(value[key]))
647
+ return parts.every(part => part !== undefined) ? parts.join(', ') : '—'
648
+ }
649
+
650
+ function statusText(block: ToolCallBlock, t: Translate): string | undefined {
651
+ if (!('kind' in block)) return t('running')
652
+ if (block.isError) return textOfContent(block).split('\n')[0] || t('failed')
653
+ return undefined
654
+ }
655
+
656
+ function VisionIcon({ kind = 'scan' }: { kind?: 'scan' | 'target' | 'layers' | 'shape' | 'diff' | 'palette' }) {
657
+ const path = kind === 'target'
658
+ ? 'M8 2v2m0 8v2M2 8h2m8 0h2M5 5h6v6H5z'
659
+ : kind === 'layers'
660
+ ? 'm3 6 5-3 5 3-5 3-5-3Zm0 3 5 3 5-3M3 12l5 3 5-3'
661
+ : kind === 'shape'
662
+ ? 'M3 12 6 4l7-1-1 7-9 2Zm3-8 6 6'
663
+ : kind === 'diff'
664
+ ? 'M3 3h4v4H3V3Zm6 6h4v4H9V9Zm0-6h4M3 11h4'
665
+ : kind === 'palette'
666
+ ? 'M8 2a6 6 0 1 0 0 12h1.2a1.3 1.3 0 0 0 0-2.6H8a1.5 1.5 0 0 1 0-3h3.5A2.5 2.5 0 0 0 14 5.9C13.2 3.6 10.9 2 8 2Z'
667
+ : 'M3 5V3h2M11 3h2v2M13 11v2h-2M5 13H3v-2M5 8h6'
668
+ return (
669
+ <svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" fill="none" stroke="currentColor" strokeWidth="1.35" strokeLinecap="round" strokeLinejoin="round">
670
+ <path d={path} />
671
+ </svg>
672
+ )
673
+ }
674
+
675
+ function ToolShell({
676
+ block, title, summary, icon, children, t,
677
+ }: {
678
+ block: ToolCallBlock
679
+ title: string
680
+ summary?: string | undefined
681
+ icon: ReactNode
682
+ children?: ReactNode | undefined
683
+ t: Translate
684
+ }) {
685
+ const [open, setOpen] = useState(true)
686
+ const status = statusText(block, t)
687
+ const expandable = children !== undefined && children !== null
688
+ return (
689
+ <section className="dvt-tool" data-state={!('kind' in block) ? 'running' : block.isError ? 'error' : 'success'}>
690
+ <button type="button" className="dvt-tool-head" onClick={() => { if (expandable) setOpen(value => !value) }} aria-expanded={expandable ? open : undefined}>
691
+ <span className="dvt-tool-icon">{icon}</span>
692
+ <span className="dvt-tool-title">{title}</span>
693
+ {summary !== undefined && summary.length > 0 ? <span className="dvt-tool-sep" aria-hidden="true">·</span> : null}
694
+ {summary !== undefined ? <span className="dvt-tool-summary">{summary}</span> : null}
695
+ {status !== undefined ? <span className="dvt-tool-status">{status}</span> : null}
696
+ {expandable ? <span className="dvt-chevron" data-open={open || undefined}>⌄</span> : null}
697
+ </button>
698
+ {expandable && open ? <div className="dvt-tool-body">{children}</div> : null}
699
+ </section>
700
+ )
701
+ }
702
+
703
+ function ArtifactActions({ artifact, grant, openFile, t }: {
704
+ artifact: ArtifactDescriptor
705
+ grant?: ArtifactGrant | undefined
706
+ openFile: (path: string) => void
707
+ t: Translate
708
+ }) {
709
+ return (
710
+ <div className="dvt-actions">
711
+ <Button size="sm" variant="outline" onClick={() => { openFile(artifact.path) }}>{t('open')}</Button>
712
+ {grant === undefined ? null : <a className="dvt-download" href={grant.downloadUrl} download={artifact.filename}>{t('download')}</a>}
713
+ </div>
714
+ )
715
+ }
716
+
717
+ function ArtifactPreview({ artifact, grant, openFile, t }: {
718
+ artifact: ArtifactDescriptor
719
+ grant?: ArtifactGrant | undefined
720
+ openFile: (path: string) => void
721
+ t: Translate
722
+ }) {
723
+ const canPreview = grant !== undefined && (artifact.kind === 'image' || artifact.kind === 'svg')
724
+ const description = artifactDescription(artifact.description, t)
725
+ return (
726
+ <article className="dvt-artifact">
727
+ {canPreview
728
+ ? artifact.kind === 'svg'
729
+ ? <iframe className="dvt-preview dvt-svg" sandbox="" src={grant.previewUrl} title={description} />
730
+ : <img className="dvt-preview" src={grant.previewUrl} alt={description} loading="lazy" />
731
+ : null}
732
+ <div className="dvt-artifact-meta">
733
+ <div>
734
+ <strong>{artifact.filename}</strong>
735
+ <span>{description}</span>
736
+ <small>{artifact.mimeType} · {formatBytes(artifact.bytes)}</small>
737
+ </div>
738
+ <ArtifactActions artifact={artifact} grant={grant} openFile={openFile} t={t} />
739
+ </div>
740
+ {!canPreview && grant === undefined ? <p className="dvt-muted">{t('previewUnavailable')}</p> : null}
741
+ </article>
742
+ )
743
+ }
744
+
745
+ const ARTIFACT_DESCRIPTION_KEYS: Record<string, LocaleKey> = {
746
+ 'Grounding bounding-box preview': 'artifactGroundPreview',
747
+ 'Detected-element bounding-box preview': 'artifactDetectPreview',
748
+ 'Cropped image region': 'artifactCrop',
749
+ 'Traced vector geometry': 'artifactTrace',
750
+ 'Pixel-difference heatmap': 'artifactDiffHeatmap',
751
+ 'Structured pixel-difference report': 'artifactDiffReport',
752
+ 'Long-screenshot split and merge manifest': 'artifactLongManifest',
753
+ 'Merged long-screenshot OCR transcript': 'artifactLongTranscript',
754
+ 'Long-screenshot OCR boundary audit': 'artifactLongAudit',
755
+ 'Extracted transparent foreground': 'artifactForeground',
756
+ 'Headless browser screenshot of local HTML': 'artifactHtmlScreenshot',
757
+ }
758
+
759
+ function artifactDescription(description: string, t: Translate): string {
760
+ const key = ARTIFACT_DESCRIPTION_KEYS[description]
761
+ if (key !== undefined) {
762
+ const translated = t(key)
763
+ return translated === key ? description : translated
764
+ }
765
+ let match = /^Long-screenshot OCR chunk (\d+)$/u.exec(description)
766
+ if (match !== null) {
767
+ const translated = t('artifactLongChunk', { index: match[1] })
768
+ return translated === 'artifactLongChunk' ? description : translated
769
+ }
770
+ match = /^OCR sidecar for chunk (\d+)$/u.exec(description)
771
+ if (match !== null) {
772
+ const translated = t('artifactOcrSidecar', { index: match[1] })
773
+ return translated === 'artifactOcrSidecar' ? description : translated
774
+ }
775
+ return description
776
+ }
777
+
778
+ type ViewProps = ToolCallViewProps & { t?: Translate }
779
+
780
+ function GroundView({ block, openFile, t = key => en[key] }: ViewProps) {
781
+ const value = decodeVisionResult(block)
782
+ const matches = Array.isArray(value?.matches) ? value.matches.filter(isRecord) : []
783
+ const target = stringOf(value?.target) ?? t('groundTitle')
784
+ const width = numberOf(value?.imageWidth)
785
+ const height = numberOf(value?.imageHeight)
786
+ const preview = artifactFrom(value?.preview)
787
+ const grants = accessMap(value)
788
+ return (
789
+ <ToolShell block={block} title={t('groundTitle')} summary={matches.length > 0 ? `${target} · ${matches.length} ${t('matches')}` : target} icon={<VisionIcon kind="target" />} t={t}>
790
+ {value === undefined ? <p className="dvt-muted">{t('noResult')}</p> : (
791
+ <div className="dvt-stack">
792
+ <div className="dvt-metrics">
793
+ <div><span>{t('dimensions')}</span><strong>{width ?? '—'} × {height ?? '—'}</strong></div>
794
+ <div><span>{t('coordinates')}</span><strong>{matches[0] === undefined ? '—' : boxText(matches[0].box)}</strong></div>
795
+ </div>
796
+ {matches.length > 1 ? (
797
+ <ol className="dvt-list">{matches.map((match, index) => <li key={index}><span>{stringOf(match.label) ?? `#${index + 1}`}</span><code>{boxText(match.box)}</code></li>)}</ol>
798
+ ) : null}
799
+ {preview === undefined ? null : <ArtifactPreview artifact={preview} grant={grants.get(preview.path)} openFile={openFile} t={t} />}
800
+ </div>
801
+ )}
802
+ </ToolShell>
803
+ )
804
+ }
805
+
806
+ function DetectView({ block, openFile, t = key => en[key] }: ViewProps) {
807
+ const value = decodeVisionResult(block)
808
+ const elements = Array.isArray(value?.elements) ? value.elements.filter(isRecord) : []
809
+ const width = numberOf(value?.imageWidth)
810
+ const height = numberOf(value?.imageHeight)
811
+ const preview = artifactFrom(value?.preview)
812
+ const grants = accessMap(value)
813
+ return (
814
+ <ToolShell block={block} title={t('detectTitle')} summary={`${elements.length} ${t('elements')}`} icon={<VisionIcon kind="layers" />} t={t}>
815
+ {value === undefined ? <p className="dvt-muted">{t('noResult')}</p> : (
816
+ <div className="dvt-stack">
817
+ <div className="dvt-metrics">
818
+ <div><span>{t('dimensions')}</span><strong>{width ?? '—'} × {height ?? '—'}</strong></div>
819
+ <div><span>{t('elements')}</span><strong>{elements.length}</strong></div>
820
+ </div>
821
+ <div className="dvt-table-wrap"><table className="dvt-table"><thead><tr><th>#</th><th>{t('label')}</th><th>{t('coordinates')}</th></tr></thead><tbody>
822
+ {elements.map((element, index) => <tr key={index}><td>{numberOf(element.index) ?? index + 1}</td><td>{stringOf(element.label) ?? '—'}</td><td><code>{boxText(element.box)}</code></td></tr>)}
823
+ </tbody></table></div>
824
+ {preview === undefined ? null : <ArtifactPreview artifact={preview} grant={grants.get(preview.path)} openFile={openFile} t={t} />}
825
+ </div>
826
+ )}
827
+ </ToolShell>
828
+ )
829
+ }
830
+
831
+ function TraceView({ block, openFile, t = key => en[key] }: ViewProps) {
832
+ const value = decodeVisionResult(block)
833
+ const artifact = artifactFrom(value?.artifact)
834
+ const geometry = isRecord(value?.geometry) ? value.geometry : undefined
835
+ const summary = geometry === undefined ? undefined : `${numberOf(geometry.pathCount) ?? 0} ${t('paths')} · ${formatBytes(numberOf(geometry.bytes) ?? 0)}`
836
+ const grants = accessMap(value)
837
+ return (
838
+ <ToolShell block={block} title={t('traceTitle')} summary={summary} icon={<VisionIcon kind="shape" />} t={t}>
839
+ {artifact === undefined ? <p className="dvt-muted">{t('noResult')}</p> : <ArtifactPreview artifact={artifact} grant={grants.get(artifact.path)} openFile={openFile} t={t} />}
840
+ </ToolShell>
841
+ )
842
+ }
843
+
844
+ function PixelDiffView({ block, openFile, t = key => en[key] }: ViewProps) {
845
+ const value = decodeVisionResult(block)
846
+ const pct = numberOf(value?.overallDifferencePct)
847
+ const regions = Array.isArray(value?.worstRegions) ? value.worstRegions.filter(isRecord) : []
848
+ const heatmap = artifactFrom(value?.heatmap)
849
+ const report = artifactFrom(value?.report)
850
+ const grants = accessMap(value)
851
+ return (
852
+ <ToolShell block={block} title={t('pixelDiffTitle')} summary={pct === undefined ? undefined : `${pct.toFixed(3)}%`} icon={<VisionIcon kind="diff" />} t={t}>
853
+ {value === undefined ? <p className="dvt-muted">{t('noResult')}</p> : (
854
+ <div className="dvt-stack">
855
+ <div className="dvt-diff-score"><span>{t('difference')}</span><strong>{pct?.toFixed(4) ?? '—'}%</strong><div><i style={{ width: `${Math.min(100, Math.max(0, pct ?? 0))}%` }} /></div></div>
856
+ {regions.length === 0 ? null : <div><h4>{t('worstRegions')}</h4><ol className="dvt-list">{regions.map((region, index) => <li key={index}><span>{(numberOf(region.differencePct) ?? 0).toFixed(3)}%</span><code>{boxText(region.box)}</code></li>)}</ol></div>}
857
+ {heatmap === undefined ? null : <ArtifactPreview artifact={heatmap} grant={grants.get(heatmap.path)} openFile={openFile} t={t} />}
858
+ {report === undefined ? null : <ArtifactPreview artifact={report} grant={grants.get(report.path)} openFile={openFile} t={t} />}
859
+ </div>
860
+ )}
861
+ </ToolShell>
862
+ )
863
+ }
864
+
865
+ function ArtifactView({ block, openFile, toolName, t = key => en[key] }: ViewProps) {
866
+ const value = decodeVisionResult(block)
867
+ const artifacts = collectArtifacts(value)
868
+ const grants = accessMap(value)
869
+ const title = toolName === 'vision_crop' ? t('cropTitle')
870
+ : toolName === 'vision_long_screenshot_ocr' ? t('longOcrTitle')
871
+ : toolName === 'vision_extract_foreground' ? t('extractForegroundTitle')
872
+ : toolName === 'vision_html_screenshot' ? t('htmlScreenshotTitle')
873
+ : t('artifactTitle')
874
+ return (
875
+ <ToolShell block={block} title={title} summary={artifacts.length > 0 ? `${artifacts.length} ${t('artifacts')}` : undefined} icon={<VisionIcon />} t={t}>
876
+ {artifacts.length === 0 ? <p className="dvt-muted">{t('noResult')}</p> : <div className="dvt-stack">{artifacts.map(artifact => <ArtifactPreview key={artifact.path} artifact={artifact} grant={grants.get(artifact.path)} openFile={openFile} t={t} />)}</div>}
877
+ </ToolShell>
878
+ )
879
+ }
880
+
881
+ function PaletteView({ block, t = key => en[key] }: ViewProps) {
882
+ const value = decodeVisionResult(block)
883
+ const analysis = isRecord(value?.analysis) ? value.analysis : undefined
884
+ const colors = Array.isArray(analysis?.colors) ? analysis.colors.filter(isRecord) : []
885
+ return (
886
+ <ToolShell block={block} title={t('dominantColorsTitle')} summary={`${colors.length} ${t('colors')}`} icon={<VisionIcon kind="palette" />} t={t}>
887
+ {colors.length === 0 ? <p className="dvt-muted">{t('noResult')}</p> : <div className="dvt-palette">{colors.map((color, index) => {
888
+ const hex = stringOf(color.color) ?? '#000000'
889
+ const share = numberOf(color.sharePct)
890
+ return <div key={`${hex}-${index}`}><i style={{ background: hex }} /><span><strong>{hex}</strong><small>{share === undefined ? '' : `${share.toFixed(2)}%`}</small></span></div>
891
+ })}</div>}
892
+ </ToolShell>
893
+ )
894
+ }
895
+
896
+ async function apiRequest<T>(init?: RequestInit): Promise<T> {
897
+ const response = await fetch(SETTINGS_ROUTE, { credentials: 'same-origin', ...init })
898
+ const body = await response.json() as ApiSuccess<T> | ApiFailure
899
+ if (!response.ok || !body.ok) {
900
+ const failure = body as ApiFailure
901
+ throw new Error(failure.error?.message ?? `Vision Toolkit request failed with HTTP ${response.status}`)
902
+ }
903
+ return body.value
904
+ }
905
+
906
+ interface SettingsState {
907
+ status: 'idle' | 'loading' | 'ready' | 'error'
908
+ snapshot?: SettingsSnapshot | undefined
909
+ health?: HealthResult | undefined
910
+ update?: PluginUpdateCheck | undefined
911
+ restart?: PluginUpdateResult | undefined
912
+ action?: 'save' | 'health' | 'connection' | 'model' | 'check-update' | 'apply-update' | undefined
913
+ message?: string | undefined
914
+ error?: string | undefined
915
+ }
916
+
917
+ /** Small external store shared by the Settings route and pushed invalidations. */
918
+ export class VisionSettingsController {
919
+ private state: SettingsState = { status: 'idle' }
920
+ private listeners = new Set<() => void>()
921
+ private generation = 0
922
+
923
+ subscribe = (listener: () => void): (() => void) => {
924
+ this.listeners.add(listener)
925
+ return () => { this.listeners.delete(listener) }
926
+ }
927
+
928
+ snapshot = (): SettingsState => this.state
929
+
930
+ private set(next: SettingsState): void {
931
+ this.state = next
932
+ for (const listener of this.listeners) listener()
933
+ }
934
+
935
+ async load(): Promise<void> {
936
+ const generation = ++this.generation
937
+ this.set({ ...this.state, status: 'loading', error: undefined, message: undefined })
938
+ try {
939
+ const snapshot = await apiRequest<SettingsSnapshot>()
940
+ if (generation !== this.generation) return
941
+ this.set({
942
+ status: 'ready',
943
+ snapshot,
944
+ health: this.state.health,
945
+ update: this.state.update,
946
+ restart: this.state.restart,
947
+ })
948
+ } catch (error) {
949
+ if (generation !== this.generation) return
950
+ this.set({ ...this.state, status: 'error', error: error instanceof Error ? error.message : String(error) })
951
+ }
952
+ }
953
+
954
+ refreshIfLoaded(): void {
955
+ if (this.state.status === 'idle' || this.state.action === 'save') return
956
+ void this.load()
957
+ }
958
+
959
+ async save(
960
+ value: SettingsValue,
961
+ expectedRevision: number,
962
+ credentialValue: string | undefined,
963
+ writeSettings: boolean,
964
+ ): Promise<boolean> {
965
+ this.set({ ...this.state, action: 'save', error: undefined, message: undefined })
966
+ let snapshot = this.state.snapshot
967
+ try {
968
+ if (writeSettings) {
969
+ snapshot = await apiRequest<SettingsSnapshot>({
970
+ method: 'POST',
971
+ headers: { 'Content-Type': 'application/json' },
972
+ body: JSON.stringify({ action: 'save', expectedRevision, value }),
973
+ })
974
+ }
975
+ if (snapshot === undefined) throw new Error('Vision Toolkit Settings are unavailable')
976
+ if (credentialValue !== undefined) {
977
+ try {
978
+ snapshot = await apiRequest<SettingsSnapshot>({
979
+ method: 'POST',
980
+ headers: { 'Content-Type': 'application/json' },
981
+ body: JSON.stringify({
982
+ action: 'credential',
983
+ expectedRevision: snapshot.settings.revision,
984
+ ref: snapshot.credential.ref,
985
+ value: credentialValue,
986
+ }),
987
+ })
988
+ } catch (error) {
989
+ this.set({
990
+ status: 'ready',
991
+ snapshot,
992
+ health: this.state.health,
993
+ update: this.state.update,
994
+ restart: this.state.restart,
995
+ error: error instanceof Error ? error.message : String(error),
996
+ })
997
+ return false
998
+ }
999
+ }
1000
+ this.set({
1001
+ status: 'ready',
1002
+ snapshot,
1003
+ health: this.state.health,
1004
+ update: this.state.update,
1005
+ restart: this.state.restart,
1006
+ message: 'saved',
1007
+ })
1008
+ return true
1009
+ } catch (error) {
1010
+ this.set({ ...this.state, action: undefined, error: error instanceof Error ? error.message : String(error) })
1011
+ return false
1012
+ } finally {
1013
+ // The backend commits the generation before the response is readable, so
1014
+ // the browser cache must not keep serving the previous hidden flag.
1015
+ resetDisplayConfigCache()
1016
+ }
1017
+ }
1018
+
1019
+ async runHealth(mode: 'health' | 'connection' | 'model'): Promise<void> {
1020
+ const testConnection = mode !== 'health'
1021
+ const testModel = mode === 'model'
1022
+ this.set({ ...this.state, action: mode, error: undefined, message: undefined })
1023
+ try {
1024
+ const health = await apiRequest<HealthResult>({
1025
+ method: 'POST',
1026
+ headers: { 'Content-Type': 'application/json' },
1027
+ body: JSON.stringify({ action: 'health', testConnection, testModel }),
1028
+ })
1029
+ this.set({ ...this.state, action: undefined, health })
1030
+ } catch (error) {
1031
+ this.set({ ...this.state, action: undefined, error: error instanceof Error ? error.message : String(error) })
1032
+ }
1033
+ }
1034
+
1035
+ async checkUpdate(): Promise<void> {
1036
+ this.set({ ...this.state, action: 'check-update', error: undefined, message: undefined })
1037
+ try {
1038
+ const update = await apiRequest<PluginUpdateCheck>({
1039
+ method: 'POST',
1040
+ headers: { 'Content-Type': 'application/json' },
1041
+ body: JSON.stringify({ action: 'check-update' }),
1042
+ })
1043
+ this.set({ ...this.state, action: undefined, update })
1044
+ } catch (error) {
1045
+ this.set({ ...this.state, action: undefined, error: error instanceof Error ? error.message : String(error) })
1046
+ }
1047
+ }
1048
+
1049
+ async applyUpdate(expectedVersion: string): Promise<void> {
1050
+ this.set({ ...this.state, action: 'apply-update', error: undefined, message: undefined })
1051
+ try {
1052
+ const result = await apiRequest<PluginUpdateResult>({
1053
+ method: 'POST',
1054
+ headers: { 'Content-Type': 'application/json' },
1055
+ body: JSON.stringify({ action: 'apply-update', expectedVersion }),
1056
+ })
1057
+ this.set({
1058
+ ...this.state,
1059
+ action: undefined,
1060
+ restart: result,
1061
+ message: result.restarting ? 'restarting' : 'manual-restart-required',
1062
+ })
1063
+ } catch (error) {
1064
+ this.set({ ...this.state, action: undefined, error: error instanceof Error ? error.message : String(error) })
1065
+ }
1066
+ }
1067
+
1068
+ reportRestartTimeout(message: string): void {
1069
+ this.set({ ...this.state, restart: undefined, message: undefined, error: message })
1070
+ }
1071
+ }
1072
+
1073
+ interface Draft {
1074
+ baseUrl: string
1075
+ credential: string
1076
+ model: string
1077
+ protocol: 'openai' | 'anthropic'
1078
+ anthropicThinking: 'omit' | 'disabled' | 'adaptive'
1079
+ userAgent: string
1080
+ language: 'zh' | 'en'
1081
+ timeoutMs: string
1082
+ maxImageBytes: string
1083
+ maxImagePixels: string
1084
+ concurrency: string
1085
+ runtimeMode: 'managed' | 'external'
1086
+ toolkitPath: string
1087
+ python: string
1088
+ allowedDirs: string
1089
+ hiddenVariants: boolean
1090
+ variantEnabled: boolean
1091
+ variantProviders: string
1092
+ variantAutoSwitch: boolean
1093
+ }
1094
+
1095
+ function draftOf(value: SettingsValue): Draft {
1096
+ return {
1097
+ baseUrl: value.provider?.baseUrl ?? BUILT_IN_FREE_VISION_BASE_URL,
1098
+ credential: value.provider?.credential ?? BUILT_IN_FREE_VISION_CREDENTIAL,
1099
+ model: value.provider?.model ?? BUILT_IN_FREE_VISION_MODEL,
1100
+ protocol: value.provider?.protocol ?? 'openai',
1101
+ anthropicThinking: value.provider?.anthropicThinking ?? 'omit',
1102
+ userAgent: value.provider?.userAgent ?? DEFAULT_USER_AGENT,
1103
+ language: value.language ?? 'zh',
1104
+ timeoutMs: String(value.timeoutMs ?? 30000),
1105
+ maxImageBytes: String(value.maxImageBytes ?? 4194304),
1106
+ maxImagePixels: String(value.maxImagePixels ?? 20000000),
1107
+ concurrency: String(value.concurrency ?? 4),
1108
+ runtimeMode: value.runtime?.mode ?? 'managed',
1109
+ toolkitPath: value.runtime?.agentVisionToolkitPath ?? '',
1110
+ python: value.runtime?.python ?? '',
1111
+ allowedDirs: (value.allowedDirs ?? []).join('\n'),
1112
+ hiddenVariants: value.imageInputVariants?.hidden ?? true,
1113
+ variantEnabled: value.imageInputVariants?.enabled ?? true,
1114
+ variantProviders: (value.imageInputVariants?.providers ?? []).join('\n'),
1115
+ variantAutoSwitch: value.imageInputVariants?.autoSwitch ?? true,
1116
+ }
1117
+ }
1118
+
1119
+ function positiveInteger(raw: string, label: string, t: Translate): number {
1120
+ const value = Number(raw)
1121
+ if (!Number.isSafeInteger(value) || value <= 0) throw new Error(t('positiveInteger', { field: label }))
1122
+ return value
1123
+ }
1124
+
1125
+ function apiKeyFailure(value: string, t: Translate): string | undefined {
1126
+ if (value.length === 0) return undefined
1127
+ const trimmed = value.trim()
1128
+ if (trimmed.length === 0) return t('apiKeyBlank')
1129
+ const quoted = trimmed.length > 1 && ['"', '\'', '`'].includes(trimmed[0] ?? '') && trimmed.endsWith(trimmed[0] ?? '')
1130
+ const environmentLine = /^[A-Z][A-Z0-9_]*=[^=]/u.test(trimmed)
1131
+ if (quoted || environmentLine || !/^[\x21-\x7E]+$/u.test(trimmed)) return t('apiKeyInvalid')
1132
+ return undefined
1133
+ }
1134
+
1135
+ function valueOf(draft: Draft, t: Translate): SettingsValue {
1136
+ return {
1137
+ provider: {
1138
+ baseUrl: draft.baseUrl.trim(),
1139
+ credential: draft.credential.trim(),
1140
+ model: draft.model.trim(),
1141
+ protocol: draft.protocol,
1142
+ anthropicThinking: draft.anthropicThinking,
1143
+ userAgent: draft.userAgent.trim(),
1144
+ },
1145
+ language: draft.language,
1146
+ timeoutMs: positiveInteger(draft.timeoutMs, t('timeout'), t),
1147
+ maxImageBytes: positiveInteger(draft.maxImageBytes, t('maxBytes'), t),
1148
+ maxImagePixels: positiveInteger(draft.maxImagePixels, t('maxPixels'), t),
1149
+ concurrency: positiveInteger(draft.concurrency, t('concurrency'), t),
1150
+ runtime: {
1151
+ mode: draft.runtimeMode,
1152
+ ...(draft.runtimeMode === 'external' ? { agentVisionToolkitPath: draft.toolkitPath.trim() } : {}),
1153
+ ...(draft.python.trim().length === 0 ? {} : { python: draft.python.trim() }),
1154
+ },
1155
+ allowedDirs: draft.allowedDirs.split(/\r?\n/).map(entry => entry.trim()).filter(Boolean),
1156
+ imageInputVariants: {
1157
+ ...(draft.variantEnabled ? {} : { enabled: false }),
1158
+ ...(draft.variantProviders.trim().length === 0 ? {} : {
1159
+ providers: draft.variantProviders.split(/\r?\n/).map(entry => entry.trim()).filter(Boolean),
1160
+ }),
1161
+ ...(draft.variantAutoSwitch ? {} : { autoSwitch: false }),
1162
+ hidden: draft.hiddenVariants,
1163
+ },
1164
+ }
1165
+ }
1166
+
1167
+ function settingsDraftChanged(draft: Draft, saved: SettingsValue, t: Translate): boolean {
1168
+ try {
1169
+ return JSON.stringify(valueOf(draft, t)) !== JSON.stringify(valueOf(draftOf(saved), t))
1170
+ } catch {
1171
+ return true
1172
+ }
1173
+ }
1174
+
1175
+ function isBuiltInFreeVisionDraft(draft: Draft): boolean {
1176
+ return draft.baseUrl.trim().replace(/\/+$/, '') === BUILT_IN_FREE_VISION_BASE_URL
1177
+ && draft.credential.trim() === BUILT_IN_FREE_VISION_CREDENTIAL
1178
+ && draft.model.trim() === BUILT_IN_FREE_VISION_MODEL
1179
+ && draft.protocol === 'openai'
1180
+ }
1181
+
1182
+ interface SettingsInjected {
1183
+ controller: VisionSettingsController
1184
+ t: Translate
1185
+ }
1186
+
1187
+ type SettingsProps = PropsRuntime<'settings.section'> & Partial<SettingsInjected>
1188
+
1189
+ function Field({ label, children, hint }: { label: string; children: ReactNode; hint?: string | undefined }) {
1190
+ return <label className="dvt-field"><span>{label}</span>{children}{hint === undefined ? null : <small>{hint}</small>}</label>
1191
+ }
1192
+
1193
+ function SettingsSection({ controller, t }: SettingsProps) {
1194
+ if (controller === undefined || t === undefined) return null
1195
+ return <LoadedSettings controller={controller} t={t} />
1196
+ }
1197
+
1198
+ const HEALTH_NAME_KEYS: Record<string, LocaleKey> = {
1199
+ python: 'healthPython',
1200
+ dependencies: 'healthDependencies',
1201
+ chrome: 'healthChrome',
1202
+ credential: 'healthCredential',
1203
+ artifactDirectory: 'healthArtifactDirectory',
1204
+ tempDirectory: 'healthTempDirectory',
1205
+ service: 'healthService',
1206
+ model: 'healthModel',
1207
+ }
1208
+
1209
+ const HEALTH_STATUS_KEYS: Record<HealthCheck['status'], LocaleKey> = {
1210
+ ok: 'statusOk',
1211
+ warning: 'statusWarning',
1212
+ error: 'statusError',
1213
+ not_tested: 'statusNotTested',
1214
+ }
1215
+
1216
+ function healthDetail(name: string, detail: string, t: Translate): string {
1217
+ if (name === 'python') {
1218
+ const match = /^(.+) via (.+)$/u.exec(detail)
1219
+ if (match !== null) return t('healthPythonDetail', { version: match[1], path: match[2] })
1220
+ }
1221
+ if (detail === 'Chrome/Chromium/Edge was not found; vision_html_screenshot is unavailable') return t('healthChromeMissing')
1222
+ if (detail === 'Chrome availability probe failed') return t('healthChromeProbeFailed')
1223
+ let match = /^credential (.+) is not configured$/u.exec(detail)
1224
+ if (match !== null) return t('healthCredentialMissing', { credential: match[1] })
1225
+ match = /^credential (.+) is resolvable$/u.exec(detail)
1226
+ if (match !== null) return t('healthCredentialReady', { credential: match[1] })
1227
+ match = /^credential (.+) could not be resolved$/u.exec(detail)
1228
+ if (match !== null) return t('healthCredentialFailed', { credential: match[1] })
1229
+ match = /^(Artifact directory|Runtime temp directory) is writable: (.+)$/u.exec(detail)
1230
+ if (match !== null) return t('healthDirectoryWritable', {
1231
+ directory: match[1] === 'Artifact directory' ? t('healthArtifactDirectory') : t('healthTempDirectory'),
1232
+ path: match[2],
1233
+ })
1234
+ match = /^(Artifact directory|Runtime temp directory) is not writable: (.+)$/u.exec(detail)
1235
+ if (match !== null) return t('healthDirectoryNotWritable', {
1236
+ directory: match[1] === 'Artifact directory' ? t('healthArtifactDirectory') : t('healthTempDirectory'),
1237
+ path: match[2],
1238
+ })
1239
+ if (detail === 'Artifact directory could not be prepared') return t('healthArtifactDirectoryFailed')
1240
+ if (detail === 'Connection was not tested; pass testConnection=true to query the configured /models endpoint') return t('healthConnectionNotTested')
1241
+ if (detail === 'Connection test skipped because the configured credential is unavailable') return t('healthConnectionCredentialMissing')
1242
+ match = /^Service responded at (.+) \(HTTP (\d+)\)$/u.exec(detail)
1243
+ if (match !== null) return t('healthServiceResponded', { endpoint: match[1], status: match[2] })
1244
+ match = /^Service rejected the configured credential \(HTTP (\d+)\)$/u.exec(detail)
1245
+ if (match !== null) return t('healthServiceRejectedCredential', { status: match[1] })
1246
+ match = /^Service is reachable but restricted GET \/models \(HTTP (\d+)\); the credential may still be valid for real vision requests$/u.exec(detail)
1247
+ if (match !== null) return t('healthServiceForbidden', { status: match[1] })
1248
+ match = /^Service is reachable but does not expose GET \/models \(HTTP (\d+)\)$/u.exec(detail)
1249
+ if (match !== null) return t('healthServiceNoModels', { status: match[1] })
1250
+ if (detail === 'Service is reachable but rate-limited the connection test (HTTP 429)') return t('healthServiceRateLimited')
1251
+ match = /^Service connection test failed with HTTP (\d+)$/u.exec(detail)
1252
+ if (match !== null) return t('healthServiceHttpFailed', { status: match[1] })
1253
+ match = /^Service could not be reached at (.+)$/u.exec(detail)
1254
+ if (match !== null) return t('healthServiceUnreachable', { endpoint: match[1] })
1255
+ if (detail === 'Vision model was not tested; run an explicit model test to send the bundled diagnostic image') return t('healthModelNotTested')
1256
+ if (detail === 'Vision model test skipped because the configured credential is unavailable') return t('healthModelCredentialMissing')
1257
+ match = /^Vision model (.+) completed a multimodal request$/u.exec(detail)
1258
+ if (match !== null) return t('healthModelReady', { model: match[1] })
1259
+ match = /^Vision model test failed: (.+)$/u.exec(detail)
1260
+ if (match !== null) return t('healthModelFailed', { detail: match[1] })
1261
+ return detail
1262
+ }
1263
+
1264
+ function modelTestTag(health: HealthResult, check: HealthCheck): { status: 'ok' | 'warning' | 'error'; label: LocaleKey } {
1265
+ if (!health.modelTested) return { status: 'warning', label: 'modelTestNotRunTag' }
1266
+ if (check.status === 'ok') return { status: 'ok', label: 'modelTestVerifiedTag' }
1267
+ return { status: 'error', label: 'modelTestFailedTag' }
1268
+ }
1269
+
1270
+ function credentialSource(source: string, t: Translate): string {
1271
+ if (source === 'env') return t('sourceEnv')
1272
+ if (source === 'file') return t('sourceFile')
1273
+ return source
1274
+ }
1275
+
1276
+ const UPDATE_REASON_KEYS: Record<PluginUpdateUnavailableReason, LocaleKey> = {
1277
+ 'profile-not-found': 'updateReasonProfileNotFound',
1278
+ 'not-direct-dependency': 'updateReasonNotDependency',
1279
+ 'unsupported-install-source': 'updateReasonLocalSource',
1280
+ 'profile-read-only': 'updateReasonReadOnly',
1281
+ 'pnpm-unavailable': 'updateReasonPnpm',
1282
+ 'unsupported-platform': 'updateReasonPlatform',
1283
+ 'restart-unmanaged': 'updateReasonRestartUnmanaged',
1284
+ 'restart-address-unavailable': 'updateReasonRestartAddress',
1285
+ }
1286
+
1287
+ function wait(delayMs: number): Promise<void> {
1288
+ return new Promise(resolve => { setTimeout(resolve, delayMs) })
1289
+ }
1290
+
1291
+ function LoadedSettings({ controller, t }: SettingsInjected) {
1292
+ const state = useSyncExternalStore(controller.subscribe, controller.snapshot, controller.snapshot)
1293
+ const snapshot = state.snapshot
1294
+ const [draft, setDraft] = useState<Draft | undefined>(undefined)
1295
+ const [apiKey, setApiKey] = useState('')
1296
+ const [draftError, setDraftError] = useState<string | undefined>(undefined)
1297
+ const [copiedCommand, setCopiedCommand] = useState(false)
1298
+
1299
+ useEffect(() => { if (state.status === 'idle') void controller.load() }, [controller, state.status])
1300
+ useEffect(() => {
1301
+ if (snapshot !== undefined) setDraft(draftOf(snapshot.settings.value))
1302
+ }, [snapshot])
1303
+ useEffect(() => {
1304
+ const restart = state.restart
1305
+ if (restart === undefined || !restart.restarting) return
1306
+ let cancelled = false
1307
+ void (async () => {
1308
+ await wait(restart.retryAfterMs)
1309
+ const deadline = Date.now() + 390_000
1310
+ let outageSeen = false
1311
+ while (!cancelled && Date.now() < deadline) {
1312
+ try {
1313
+ const current = await apiRequest<SettingsSnapshot>()
1314
+ if (current.release.pluginVersion === restart.toVersion) {
1315
+ window.location.reload()
1316
+ return
1317
+ }
1318
+ if (outageSeen && current.release.pluginVersion === restart.fromVersion) {
1319
+ controller.reportRestartTimeout(t('restartRolledBack'))
1320
+ return
1321
+ }
1322
+ } catch {
1323
+ // The expected outage while the replacement process starts.
1324
+ outageSeen = true
1325
+ }
1326
+ await wait(1_000)
1327
+ }
1328
+ if (!cancelled) controller.reportRestartTimeout(t('restartTimedOut'))
1329
+ })()
1330
+ return () => { cancelled = true }
1331
+ }, [controller, state.restart, t])
1332
+
1333
+ if (state.status === 'idle' || (state.status === 'loading' && snapshot === undefined)) {
1334
+ return <div className="dvt-settings"><div className="dvt-loading">{t('testing')}</div></div>
1335
+ }
1336
+ if (snapshot === undefined || draft === undefined) {
1337
+ return <div className="dvt-settings"><div className="dvt-alert error">{state.error ?? t('runtimeUnavailable')}</div><Button variant="outline" onClick={() => { void controller.load() }}>{t('retry')}</Button></div>
1338
+ }
1339
+
1340
+ const update = <K extends keyof Draft>(key: K, value: Draft[K]): void => setDraft(current => current === undefined ? current : { ...current, [key]: value })
1341
+ const save = (): void => {
1342
+ try {
1343
+ const keyFailure = apiKeyFailure(apiKey, t)
1344
+ if (keyFailure !== undefined) {
1345
+ setDraftError(keyFailure)
1346
+ return
1347
+ }
1348
+ const credentialValue = apiKey.length === 0 ? undefined : apiKey.trim()
1349
+ setDraftError(undefined)
1350
+ void controller.save(
1351
+ valueOf(draft, t),
1352
+ snapshot.settings.revision,
1353
+ credentialValue,
1354
+ snapshot.writable,
1355
+ ).then(saved => { if (saved) setApiKey('') })
1356
+ } catch (error) {
1357
+ setDraftError(error instanceof Error ? error.message : String(error))
1358
+ }
1359
+ }
1360
+ const busy = state.action !== undefined
1361
+ const credentialMatchesSnapshot = draft.credential.trim() === snapshot.credential.ref
1362
+ const builtInCredentialChangedProvider = snapshot.credential.source === 'built-in-free'
1363
+ && !isBuiltInFreeVisionDraft(draft)
1364
+ const keyLocked = credentialMatchesSnapshot
1365
+ && !snapshot.credential.writable
1366
+ && !builtInCredentialChangedProvider
1367
+ const canSave = snapshot.writable || (apiKey.length > 0 && !keyLocked)
1368
+ const runtimeErrorTitle = snapshot.runtime.ready ? t('runtimeCandidateRejected') : t('runtimeUnavailable')
1369
+ const pluginUpdate = state.update
1370
+ const updateCapability = pluginUpdate ?? snapshot.release.update
1371
+ const latestVersion = pluginUpdate?.latestVersion
1372
+ const updateReason = updateCapability.reason === undefined ? undefined : t(UPDATE_REASON_KEYS[updateCapability.reason])
1373
+ const updateCheckSupported = updateCapability.checkSupported ?? updateCapability.supported
1374
+ const updateHasUnsavedChanges = apiKey.length > 0 || settingsDraftChanged(draft, snapshot.settings.value, t)
1375
+ const manualUpdateProfile = updateCapability.profile ?? 'web'
1376
+ const manualUpdateCommand = `dsh plugin --profile ${manualUpdateProfile} add @mengruo/dsh-vision-toolkit@latest --registry=https://registry.npmjs.org/`
1377
+ const aihubmixTutorialUrl = draft?.language === 'en' ? AIHUBMIX_TUTORIAL_URL_EN : AIHUBMIX_TUTORIAL_URL_ZH
1378
+ const copyManualUpdate = (): void => {
1379
+ void navigator.clipboard?.writeText(manualUpdateCommand)
1380
+ .then(() => {
1381
+ setCopiedCommand(true)
1382
+ window.setTimeout(() => setCopiedCommand(false), 2_000)
1383
+ })
1384
+ .catch(() => {})
1385
+ }
1386
+ const applyUpdate = (): void => {
1387
+ if (latestVersion === undefined) return
1388
+ if (!window.confirm(t('updateConfirm', { version: latestVersion }))) return
1389
+ void controller.applyUpdate(latestVersion)
1390
+ }
1391
+
1392
+ return (
1393
+ <div className="dvt-settings">
1394
+ <div className="dvt-alert notice">{t('externalNotice')}</div>
1395
+ {!snapshot.writable ? <div className="dvt-alert warning">{t('readOnly')}</div> : null}
1396
+ {draftError === undefined ? null : <div className="dvt-alert error">{draftError}</div>}
1397
+ {state.error === undefined ? null : <div className="dvt-alert error">{state.error}</div>}
1398
+ {state.message === 'saved' ? <div className="dvt-alert success">{t('saved')}</div> : null}
1399
+ {state.message === 'restarting' && state.restart !== undefined ? <div className="dvt-alert success">{t('restarting', { version: state.restart.toVersion })}</div> : null}
1400
+ {state.message === 'manual-restart-required' && state.restart !== undefined ? <div className="dvt-alert success">{t('manualRestartRequired', { version: state.restart.toVersion })}</div> : null}
1401
+ {snapshot.runtime.lastError === undefined ? null : <div className="dvt-alert error"><strong>{runtimeErrorTitle}</strong><span>{snapshot.runtime.lastError}</span></div>}
1402
+
1403
+ <section className="dvt-panel dvt-essential"><div className="dvt-panel-title"><div><h3>{t('provider')}</h3><p>{t('providerHint')}</p></div><span className={`dvt-badge ${snapshot.credential.configured ? 'ok' : 'error'}`}>{snapshot.credential.configured ? t('configured') : t('missing')}</span></div>
1404
+ <p className="dvt-tutorial-link"><a href={aihubmixTutorialUrl} target="_blank" rel="noreferrer">{t('aihubmixTutorial')}</a></p>
1405
+ <div className="dvt-form-grid">
1406
+ <Field label={t('protocol')}><select disabled={!snapshot.writable || busy} value={draft.protocol} onChange={(event) => { update('protocol', event.target.value as 'openai' | 'anthropic') }}><option value="openai">OpenAI Chat Completions</option><option value="anthropic">Anthropic Messages</option></select></Field>
1407
+ <Field label={t('baseUrl')}><Input disabled={!snapshot.writable || busy} value={draft.baseUrl} onChange={(event) => { update('baseUrl', event.target.value) }} /></Field>
1408
+ <Field label={t('model')}><Input disabled={!snapshot.writable || busy} value={draft.model} onChange={(event) => { update('model', event.target.value) }} /></Field>
1409
+ <Field label={t('apiKey')} hint={keyLocked ? t('apiKeyLocked') : snapshot.credential.source === undefined ? t('apiKeyHint') : `${t('apiKeyHint')} ${t('sourceHint', { source: t('source'), value: credentialSource(snapshot.credential.source, t) })}`}><Input aria-label={t('apiKey')} type="password" autoComplete="new-password" disabled={busy || keyLocked} placeholder={snapshot.credential.configured ? t('apiKeyPlaceholderConfigured') : t('apiKeyPlaceholderMissing')} value={apiKey} onChange={(event) => { setApiKey(event.target.value); setDraftError(undefined) }} /></Field>
1410
+ </div>
1411
+ </section>
1412
+
1413
+ <div className="dvt-save-row"><Button variant="primary" disabled={!canSave || busy} onClick={save}>{state.action === 'save' ? t('saving') : t('save')}</Button><Button variant="outline" disabled={busy} onClick={() => { void controller.load() }}>{t('reload')}</Button></div>
1414
+
1415
+ <section className="dvt-panel"><div className="dvt-panel-title"><div><h3>{t('health')}</h3><p>{t('connectionHint')}</p></div><div className="dvt-actions"><Button size="sm" variant="outline" disabled={busy || !snapshot.runtime.ready} onClick={() => { void controller.runHealth('health') }}>{state.action === 'health' ? t('testing') : t('runHealth')}</Button><Button size="sm" variant="outline" disabled={busy || !snapshot.runtime.ready} onClick={() => { void controller.runHealth('connection') }}>{state.action === 'connection' ? t('testing') : t('testConnection')}</Button><Button size="sm" variant="primary" disabled={busy || !snapshot.runtime.ready} onClick={() => { void controller.runHealth('model') }}>{state.action === 'model' ? t('testingModel') : t('testModel')}</Button></div></div>
1416
+ <p className="dvt-muted">{t('saveBeforeTesting')}</p>
1417
+ {state.health === undefined ? <p className="dvt-muted">{t('notTested')}</p> : <div className="dvt-health-grid">{Object.entries(state.health.checks).map(([name, check]) => {
1418
+ const testTag = name === 'model' ? modelTestTag(state.health as HealthResult, check) : undefined
1419
+ return <div key={name} data-status={check.status}><span>{t(HEALTH_NAME_KEYS[name] ?? 'health')}</span>{testTag === undefined ? null : <em className="dvt-health-test-tag" data-status={testTag.status}>{t(testTag.label)}</em>}<strong>{t(HEALTH_STATUS_KEYS[check.status])}</strong><p>{healthDetail(name, check.detail, t)}</p></div>
1420
+ })}</div>}
1421
+ </section>
1422
+
1423
+ <section className="dvt-panel dvt-update-panel">
1424
+ <div className="dvt-panel-title">
1425
+ <div><h3>{t('updates')}</h3><p>{t('updatesHint')}</p></div>
1426
+ <span className={`dvt-badge ${pluginUpdate?.updateAvailable ? 'warning' : pluginUpdate !== undefined && pluginUpdate.supported ? 'ok' : ''}`}>
1427
+ {pluginUpdate?.updateAvailable ? t('updateAvailable') : pluginUpdate !== undefined && pluginUpdate.supported ? t('upToDate') : t('pluginVersion')}
1428
+ </span>
1429
+ </div>
1430
+ <div className="dvt-update-grid">
1431
+ <div><span>{t('updateInstalled')}</span><strong>{snapshot.release.pluginVersion}</strong></div>
1432
+ <div><span>{t('updateLatest')}</span><strong>{latestVersion ?? '—'}</strong></div>
1433
+ <div><span>{t('updateProfile')}</span><strong>{updateCapability.profile ?? '—'}</strong></div>
1434
+ </div>
1435
+ {!updateCapability.supported ? <div className="dvt-alert warning"><strong>{t('updateUnsupported')}</strong><span>{updateReason}</span></div> : null}
1436
+ {updateCapability.supported && updateHasUnsavedChanges ? <div className="dvt-alert warning">{t('updateSaveFirst')}</div> : null}
1437
+ {pluginUpdate?.supported && pluginUpdate.updateAvailable && latestVersion !== undefined ? <p className="dvt-muted">{t('updateAvailableDetail', { version: latestVersion })}</p> : null}
1438
+ {pluginUpdate?.supported && !pluginUpdate.updateAvailable && latestVersion !== undefined ? <p className="dvt-muted">{t('upToDateDetail', { version: latestVersion })}</p> : null}
1439
+ <p className="dvt-muted">{t('manualUpdateHint')}</p>
1440
+ <div className="dvt-manual-update"><code>{manualUpdateCommand}</code><Button size="sm" variant="outline" onClick={copyManualUpdate}>{copiedCommand ? t('copied') : t('copy')}</Button></div>
1441
+ <div className="dvt-actions">
1442
+ <Button variant="outline" disabled={busy || !updateCheckSupported || state.restart !== undefined} onClick={() => { void controller.checkUpdate() }}>{state.action === 'check-update' ? t('checkingUpdate') : t('checkUpdate')}</Button>
1443
+ {pluginUpdate?.supported && pluginUpdate.updateAvailable && latestVersion !== undefined ? <Button variant="primary" disabled={busy || state.restart !== undefined || updateHasUnsavedChanges} onClick={applyUpdate}>{state.action === 'apply-update' ? t('updatingPlugin') : t('updateNow')}</Button> : null}
1444
+ </div>
1445
+ </section>
1446
+
1447
+ <details className="dvt-advanced">
1448
+ <summary><span><strong>{t('advanced')}</strong><small>{t('advancedHint')}</small></span><span className="dvt-details-chevron" aria-hidden="true">⌄</span></summary>
1449
+ <div className="dvt-advanced-body">
1450
+ <section className="dvt-panel"><div className="dvt-panel-title"><h3>{t('provider')}</h3></div><div className="dvt-form-grid">
1451
+ <Field label={t('credential')} hint={t('credentialHint')}><Input aria-label={t('credential')} disabled={!snapshot.writable || busy} value={draft.credential} onChange={(event) => { update('credential', event.target.value) }} /></Field>
1452
+ {draft.protocol === 'anthropic' ? <Field label={t('anthropicThinking')} hint={t('anthropicThinkingHint')}><select aria-label={t('anthropicThinking')} value={draft.anthropicThinking} onChange={(event) => { update('anthropicThinking', event.target.value as 'omit' | 'disabled' | 'adaptive') }}><option value="omit">omit (widest compatibility)</option><option value="disabled">disabled (model support required)</option><option value="adaptive">adaptive (model support required)</option></select></Field> : null}
1453
+ <Field label={t('userAgent')}><Input value={draft.userAgent} onChange={(event) => { update('userAgent', event.target.value) }} /></Field>
1454
+ </div></section>
1455
+
1456
+ <section className="dvt-panel"><div className="dvt-panel-title"><h3>{t('limits')}</h3></div><div className="dvt-form-grid">
1457
+ <Field label={t('language')}><select value={draft.language} onChange={(event) => { update('language', event.target.value as 'zh' | 'en') }}><option value="zh">中文</option><option value="en">English</option></select></Field>
1458
+ <Field label={t('timeout')}><Input inputMode="numeric" value={draft.timeoutMs} onChange={(event) => { update('timeoutMs', event.target.value) }} /></Field>
1459
+ <Field label={t('maxBytes')}><Input inputMode="numeric" value={draft.maxImageBytes} onChange={(event) => { update('maxImageBytes', event.target.value) }} /></Field>
1460
+ <Field label={t('maxPixels')}><Input inputMode="numeric" value={draft.maxImagePixels} onChange={(event) => { update('maxImagePixels', event.target.value) }} /></Field>
1461
+ <Field label={t('concurrency')}><Input inputMode="numeric" value={draft.concurrency} onChange={(event) => { update('concurrency', event.target.value) }} /></Field>
1462
+ </div></section>
1463
+
1464
+ <section className="dvt-panel"><div className="dvt-panel-title"><h3>{t('imageInput')}</h3></div><label className="dvt-check"><input type="checkbox" checked={draft.hiddenVariants} disabled={!snapshot.writable || busy} onChange={(event) => { update('hiddenVariants', event.target.checked) }} /><span>{t('hiddenVariantsLabel')}</span><small>{t('hiddenVariantsHint')}</small></label></section>
1465
+
1466
+ <section className="dvt-panel"><div className="dvt-panel-title"><h3>{t('runtime')}</h3><span className={`dvt-badge ${snapshot.runtime.ready ? 'ok' : 'error'}`}>{snapshot.runtime.ready ? snapshot.runtime.upstream?.source === 'managed' ? t('runtimeManaged') : snapshot.runtime.upstream?.source === 'external' ? t('runtimeExternal') : t('runtimeReady') : t('runtimeUnavailable')}</span></div><div className="dvt-form-grid">
1467
+ <Field label={t('runtimeMode')}><select value={draft.runtimeMode} onChange={(event) => { update('runtimeMode', event.target.value as 'managed' | 'external') }}><option value="managed">{t('runtimeManaged')}</option><option value="external">{t('runtimeExternal')}</option></select></Field>
1468
+ {draft.runtimeMode === 'external' ? <Field label={t('toolkitPath')}><Input value={draft.toolkitPath} onChange={(event) => { update('toolkitPath', event.target.value) }} /></Field> : null}
1469
+ <Field label={t('python')}><Input placeholder="python3" value={draft.python} onChange={(event) => { update('python', event.target.value) }} /></Field>
1470
+ <Field label={t('allowedDirs')} hint={t('allowedDirsHint')}><textarea rows={3} value={draft.allowedDirs} onChange={(event) => { update('allowedDirs', event.target.value) }} /></Field>
1471
+ </div>
1472
+ {snapshot.runtime.upstream === undefined ? null : <div className="dvt-runtime-facts"><code>{snapshot.runtime.upstream.path}</code><code>{snapshot.runtime.upstream.python} · {snapshot.runtime.upstream.pythonVersion}</code><code>{snapshot.runtime.upstream.runtimeHome}</code></div>}
1473
+ </section>
1474
+
1475
+ </div>
1476
+ </details>
1477
+
1478
+ <footer className="dvt-settings-footer">
1479
+ <div><span className="dvt-kicker">{t('pluginKind')}</span><h2>{t('settingsTitle')}</h2><p>{t('settingsIntro')}</p></div>
1480
+ <div className="dvt-release"><span>{t('pluginVersion')} <strong>{snapshot.release.pluginVersion}</strong></span><span>{t('upstreamVersion')} <strong>{snapshot.release.upstreamVersion}</strong></span><span>{t('activeGeneration')} <strong>{t('activeGenerationValue', { generation: snapshot.runtime.generation })}</strong></span></div>
1481
+ </footer>
1482
+ </div>
1483
+ )
1484
+ }
1485
+
1486
+ const CSS = `
1487
+ .dvt-tool{margin:4px 0;border:1px solid var(--dsw-alias-border-l1);border-radius:12px;background:var(--dsw-alias-bg-layer-1);overflow:hidden;box-shadow:var(--dsw-shadow-lv1)}
1488
+ .dvt-tool-head{width:100%;min-height:38px;display:flex;align-items:center;gap:7px;padding:8px 10px;border:0;background:transparent;color:inherit;text-align:left;cursor:pointer;font:inherit}.dvt-tool-head:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:-2px}.dvt-tool-icon{width:20px;height:20px;display:grid;place-items:center;border-radius:6px;color:var(--dsw-alias-state-business-primary);background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 12%,transparent);flex:none}.dvt-tool-title{font-size:12px;font-weight:650;white-space:nowrap}.dvt-tool-sep{opacity:.35}.dvt-tool-summary{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;color:var(--dsw-alias-label-secondary)}.dvt-tool-status{margin-left:auto;font-size:11px;color:var(--dsw-alias-label-secondary);max-width:45%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dvt-tool[data-state=error] .dvt-tool-status{color:var(--dsw-alias-state-error-primary)}.dvt-chevron{margin-left:auto;transition:transform .16s ease;opacity:.55}.dvt-chevron[data-open=true]{transform:rotate(180deg)}.dvt-tool-body{padding:0 10px 10px}.dvt-stack{display:grid;gap:10px}.dvt-muted{margin:0;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1.5}
1489
+ .dvt-metrics{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}.dvt-metrics>div,.dvt-diff-score{padding:10px;border-radius:9px;background:var(--dsw-alias-bg-layer-2);display:grid;gap:4px}.dvt-metrics span,.dvt-diff-score span{font-size:10px;text-transform:uppercase;letter-spacing:.06em;color:var(--dsw-alias-label-secondary)}.dvt-metrics strong,.dvt-diff-score strong{font-size:13px}.dvt-list{list-style:none;margin:0;padding:0;display:grid;gap:4px;max-height:160px;overflow:auto}.dvt-list li{display:flex;justify-content:space-between;gap:12px;padding:6px 8px;border-radius:7px;background:var(--dsw-alias-bg-layer-2);font-size:11px}.dvt-list code{color:var(--dsw-alias-state-business-primary)}.dvt-table-wrap{max-height:220px;overflow:auto;border:1px solid var(--dsw-alias-border-l1);border-radius:9px}.dvt-table{width:100%;border-collapse:collapse;font-size:11px}.dvt-table th,.dvt-table td{padding:7px 8px;text-align:left;border-bottom:1px solid var(--dsw-alias-border-l1)}.dvt-table th{position:sticky;top:0;background:var(--dsw-alias-bg-layer-2);font-size:10px;text-transform:uppercase;letter-spacing:.05em}.dvt-table tr:last-child td{border-bottom:0}
1490
+ .dvt-artifact{border:1px solid var(--dsw-alias-border-l1);border-radius:10px;overflow:hidden;background:var(--dsw-alias-bg-layer-1)}.dvt-preview{display:block;width:100%;max-height:360px;object-fit:contain;background:repeating-conic-gradient(var(--dsw-alias-bg-module-platform) 0 25%,var(--dsw-alias-bg-layer-1) 0 50%) 50%/18px 18px;border:0}.dvt-svg{height:280px}.dvt-artifact-meta{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:9px 10px}.dvt-artifact-meta>div:first-child{min-width:0;display:grid;gap:2px}.dvt-artifact-meta strong{font-size:12px;overflow:hidden;text-overflow:ellipsis}.dvt-artifact-meta span,.dvt-artifact-meta small{font-size:10px;color:var(--dsw-alias-label-secondary)}.dvt-actions{display:flex;align-items:center;gap:7px;flex-wrap:wrap}.dvt-download{display:inline-flex;align-items:center;height:28px;padding:0 12px;border-radius:999px;background:var(--dsw-alias-button-primary-fill);color:var(--dsw-alias-label-primary-foreground);text-decoration:none;font-size:12px;font-weight:600}.dvt-download:hover{background:var(--dsw-alias-button-primary-hover)}.dvt-download:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:2px}.dvt-artifact>.dvt-muted{padding:0 10px 10px}.dvt-diff-score>div{height:5px;border-radius:99px;background:var(--dsw-alias-border-l2);overflow:hidden}.dvt-diff-score i{display:block;height:100%;min-width:2px;background:linear-gradient(90deg,var(--dsw-alias-state-warn-primary),var(--dsw-alias-state-error-primary));border-radius:99px}.dvt-tool h4{font-size:11px;margin:0 0 6px}.dvt-palette{display:grid;grid-template-columns:repeat(auto-fit,minmax(112px,1fr));gap:7px}.dvt-palette>div{display:flex;align-items:center;gap:8px;padding:7px;border:1px solid var(--dsw-alias-border-l1);border-radius:9px}.dvt-palette i{width:28px;height:28px;border-radius:7px;box-shadow:inset 0 0 0 1px var(--dsw-alias-border-l2)}.dvt-palette span{display:grid}.dvt-palette strong{font-size:11px}.dvt-palette small{font-size:10px;color:var(--dsw-alias-label-secondary)}
1491
+ .dvt-tutorial-link{margin:0;font-size:12px;line-height:1.5}.dvt-tutorial-link a{color:var(--dsw-alias-state-business-primary);text-decoration:none;font-weight:600}.dvt-tutorial-link a:hover{text-decoration:underline}.dvt-manual-update{display:flex;align-items:center;gap:8px;padding:9px 10px;border-radius:9px;background:var(--dsw-alias-bg-layer-2)}.dvt-manual-update code{flex:1;min-width:0;overflow:auto;white-space:nowrap;font-size:11px;color:var(--dsw-alias-label-primary)}.dvt-settings{display:grid;grid-template-columns:minmax(0,1fr);width:100%;max-width:900px;min-width:0;box-sizing:border-box;gap:14px;padding:8px 2px 32px;color:var(--dsw-alias-label-primary)}.dvt-settings-footer{display:flex;justify-content:space-between;gap:20px;align-items:flex-start;padding:8px 2px}.dvt-settings-footer h2{font-size:25px;letter-spacing:-.025em;margin:3px 0 6px}.dvt-settings-footer p{max-width:620px;margin:0;color:var(--dsw-alias-label-secondary);font-size:13px;line-height:1.55}.dvt-kicker{font-size:10px;text-transform:uppercase;letter-spacing:.1em;color:var(--dsw-alias-state-business-primary);font-weight:700}.dvt-release{display:grid;gap:4px;min-width:170px;padding:9px 11px;border-radius:10px;background:var(--dsw-alias-bg-layer-2);font-size:10px;color:var(--dsw-alias-label-secondary)}.dvt-release span{display:flex;justify-content:space-between;gap:12px}.dvt-release strong{color:var(--dsw-alias-label-primary)}.dvt-alert{padding:10px 12px;border-radius:10px;font-size:12px;line-height:1.5;display:grid;gap:3px}.dvt-alert.notice{background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 10%,transparent);color:var(--dsw-alias-state-business-primary)}.dvt-alert.warning{background:color-mix(in srgb,var(--dsw-alias-state-warn-primary) 12%,transparent);color:var(--dsw-alias-state-warn-label)}.dvt-alert.error{background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 10%,transparent);color:var(--dsw-alias-state-error-primary)}.dvt-alert.success{background:color-mix(in srgb,var(--dsw-alias-state-success-primary) 10%,transparent);color:var(--dsw-alias-state-success-primary)}.dvt-panel{display:grid;grid-template-columns:minmax(0,1fr);gap:12px;padding:15px;border:1px solid var(--dsw-alias-border-l1);border-radius:14px;background:var(--dsw-alias-bg-layer-1);box-shadow:var(--dsw-shadow-lv1)}.dvt-panel-title{display:flex;align-items:flex-start;justify-content:space-between;flex-wrap:wrap;gap:12px}.dvt-panel-title>div:first-child{flex:1 1 320px;min-width:0}.dvt-panel-title>.dvt-actions{margin-left:auto;justify-content:flex-end}.dvt-panel-title h3{font-size:14px;margin:0}.dvt-panel-title p{font-size:11px;line-height:1.45;color:var(--dsw-alias-label-secondary);margin:4px 0 0;max-width:620px}.dvt-badge{font-size:10px;padding:3px 7px;border-radius:999px;font-weight:650}.dvt-badge.ok{background:color-mix(in srgb,var(--dsw-alias-state-success-primary) 12%,transparent);color:var(--dsw-alias-state-success-primary)}.dvt-badge.warning{background:color-mix(in srgb,var(--dsw-alias-state-warn-primary) 12%,transparent);color:var(--dsw-alias-state-warn-label)}.dvt-badge.error{background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 10%,transparent);color:var(--dsw-alias-state-error-primary)}.dvt-form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.dvt-field{display:grid;min-width:0;gap:6px;align-content:start}.dvt-field>span{font-size:11px;font-weight:600}.dvt-field>small{font-size:10px;color:var(--dsw-alias-label-secondary);line-height:1.4}.dvt-field select,.dvt-field textarea{width:100%;box-sizing:border-box;border:1px solid var(--dsw-alias-border-l1);border-radius:9px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font:inherit;font-size:12px;padding:8px 10px}.dvt-field select{height:36px}.dvt-field textarea{resize:vertical;min-height:76px}.dvt-check{display:grid;gap:6px;cursor:pointer}.dvt-check input{width:auto}.dvt-check>span{font-size:12px;font-weight:600}.dvt-check>small{font-size:10px;color:var(--dsw-alias-label-secondary);line-height:1.4}.dvt-runtime-facts{display:grid;gap:4px;padding:9px 10px;border-radius:9px;background:var(--dsw-alias-bg-layer-2);overflow:auto}.dvt-runtime-facts code{font-size:10px;white-space:nowrap;color:var(--dsw-alias-label-secondary)}.dvt-save-row{display:flex;gap:8px;padding:2px 0}.dvt-update-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}.dvt-update-grid>div{display:grid;gap:3px;padding:9px 10px;border-radius:9px;background:var(--dsw-alias-bg-layer-2)}.dvt-update-grid span{font-size:9px;text-transform:uppercase;letter-spacing:.04em;color:var(--dsw-alias-label-caption)}.dvt-update-grid strong{font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1492
+ .dvt-settings-footer{margin-top:8px;padding:20px 2px 4px;border-top:1px solid var(--dsw-alias-border-l1);opacity:.82}.dvt-settings-footer h2{font-size:18px;letter-spacing:-.015em;margin:3px 0 5px}.dvt-settings-footer p{font-size:11px;line-height:1.5}.dvt-release{min-width:220px}.dvt-release span{white-space:nowrap}.dvt-essential{border-color:color-mix(in srgb,var(--dsw-alias-state-business-primary) 30%,var(--dsw-alias-border-l1));box-shadow:var(--dsw-shadow-lv1),0 0 0 3px color-mix(in srgb,var(--dsw-alias-state-business-primary) 5%,transparent)}.dvt-advanced{border:1px solid var(--dsw-alias-border-l1);border-radius:14px;background:var(--dsw-alias-bg-layer-1);overflow:hidden}.dvt-advanced>summary{display:flex;align-items:center;justify-content:space-between;gap:14px;padding:14px 15px;cursor:pointer;list-style:none}.dvt-advanced>summary::-webkit-details-marker{display:none}.dvt-advanced>summary>span:first-child{display:grid;gap:3px}.dvt-advanced>summary strong{font-size:13px}.dvt-advanced>summary small{font-size:10px;line-height:1.45;color:var(--dsw-alias-label-secondary);font-weight:400}.dvt-details-chevron{font-size:15px;opacity:.55;transition:transform .16s ease}.dvt-advanced[open] .dvt-details-chevron{transform:rotate(180deg)}.dvt-advanced-body{display:grid;grid-template-columns:minmax(0,1fr);gap:12px;padding:0 12px 12px}.dvt-advanced-body>.dvt-panel{box-shadow:none}
1493
+ .dvt-health-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:8px}.dvt-health-grid>div{padding:9px 10px;border-radius:9px;background:var(--dsw-alias-bg-layer-2);border-left:3px solid var(--dsw-alias-border-l4)}.dvt-health-grid>div[data-status=ok]{border-left-color:var(--dsw-alias-state-success-primary)}.dvt-health-grid>div[data-status=warning],.dvt-health-grid>div[data-status=not_tested]{border-left-color:var(--dsw-alias-state-warn-primary)}.dvt-health-grid>div[data-status=error]{border-left-color:var(--dsw-alias-state-error-primary)}.dvt-health-grid span{font-size:10px;text-transform:capitalize}.dvt-health-grid strong{float:right;font-size:9px;text-transform:uppercase;color:var(--dsw-alias-label-secondary)}.dvt-health-test-tag{display:inline-flex;margin-left:6px;padding:1px 6px;border-radius:999px;background:var(--dsw-alias-bg-layer-1);font-size:9px;font-style:normal;font-weight:600;color:var(--dsw-alias-label-secondary)}.dvt-health-test-tag[data-status=ok]{background:color-mix(in srgb,var(--dsw-alias-state-success-primary) 12%,transparent);color:var(--dsw-alias-state-success-primary)}.dvt-health-test-tag[data-status=warning]{background:color-mix(in srgb,var(--dsw-alias-state-warn-primary) 12%,transparent);color:var(--dsw-alias-state-warn-label)}.dvt-health-test-tag[data-status=error]{background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 12%,transparent);color:var(--dsw-alias-state-error-primary)}.dvt-health-grid p{clear:both;margin:5px 0 0;font-size:10px;line-height:1.4;color:var(--dsw-alias-label-secondary)}.dvt-loading{padding:24px;border-radius:12px;background:var(--dsw-alias-bg-layer-2);font-size:12px;color:var(--dsw-alias-label-secondary)}
1494
+ .dvt-paste-dock{box-sizing:border-box;width:calc(100% - 32px);max-width:var(--dsh-composer-card-max-width,960px);margin:0 auto;display:flex;flex-wrap:wrap;gap:6px;padding:0 2px 6px}.dvt-paste-chip{max-width:100%;height:32px;box-sizing:border-box;display:flex;align-items:center;gap:7px;padding:0 6px 0 10px;border:1px solid var(--dsw-alias-border-l1);border-radius:9px;background:var(--dsw-specific-tip);font-size:12px}.dvt-paste-chip[data-status=copying]{border-color:var(--dsw-alias-state-business-primary)}.dvt-paste-chip[data-status=error]{border-color:var(--dsw-alias-state-error-primary)}.dvt-paste-name{max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dvt-paste-detail{color:var(--dsw-alias-label-caption);max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dvt-paste-chip[data-status=error] .dvt-paste-detail{color:var(--dsw-alias-state-error-primary)}.dvt-paste-chip button{width:20px;height:20px;display:grid;place-items:center;border:0;border-radius:50%;padding:0;background:transparent;color:var(--dsw-alias-label-caption);font:inherit;font-size:16px;cursor:pointer}.dvt-paste-chip button:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}.dvt-paste-chip button:disabled{opacity:.4;cursor:default}
1495
+ @media(max-width:720px){.dvt-settings-footer{display:grid}.dvt-release{width:auto}.dvt-form-grid,.dvt-update-grid{grid-template-columns:1fr}.dvt-metrics{grid-template-columns:1fr}.dvt-artifact-meta{align-items:flex-start;flex-direction:column}.dvt-panel-title{flex-direction:column}}
1496
+ `
1497
+
1498
+ function installStyles(): () => void {
1499
+ const id = '@mengruo/dsh-vision-toolkit/client'
1500
+ const existing = document.querySelector<HTMLStyleElement>(`style[data-plugin-css="${id}"]`)
1501
+ if (existing !== null) return () => {}
1502
+ const style = document.createElement('style')
1503
+ style.dataset.plugin = '@mengruo/dsh-vision-toolkit'
1504
+ style.dataset.pluginCss = id
1505
+ style.textContent = CSS
1506
+ document.head.appendChild(style)
1507
+ return () => { style.remove() }
1508
+ }
1509
+
1510
+ /** Required client services. The pasted-image codec attaches to either trigger-service generation after load. */
1511
+ export const inject = ['slots', 'locale', 'remote', 'conversation', 'sessions']
1512
+
1513
+ /** Register dedicated Tool views and the Vision Settings section. */
1514
+ export function apply(ctx: ClientContext): void {
1515
+ ctx.effect(installStyles, 'dsh-vision-toolkit: styles')
1516
+ ctx.effect(() => ctx.locale.register(NS, { en, zh }), 'dsh-vision-toolkit: locale')
1517
+ installPasteImages(ctx)
1518
+ ctx.effect(installModelVariantsHider, 'dsh-vision-toolkit: model-selector transparent routing')
1519
+ const t = ctx.locale.bind(NS)
1520
+ const injected = () => ({ t })
1521
+ const entries: Array<[string, (props: ViewProps) => ReactNode]> = [
1522
+ ['vision_ground', GroundView],
1523
+ ['vision_detect', DetectView],
1524
+ ['vision_trace', TraceView],
1525
+ ['vision_pixel_diff', PixelDiffView],
1526
+ ['vision_crop', ArtifactView],
1527
+ ['vision_long_screenshot_ocr', ArtifactView],
1528
+ ['vision_extract_foreground', ArtifactView],
1529
+ ['vision_html_screenshot', ArtifactView],
1530
+ ['vision_dominant_colors', PaletteView],
1531
+ ]
1532
+ ctx.slots.inject('tool.call.toolview', function* () {
1533
+ for (const [key, component] of entries) {
1534
+ yield ctx.slots.register({ name: 'tool.call.toolview', key, inject: injected }, component)
1535
+ }
1536
+ })
1537
+
1538
+ const controller = new VisionSettingsController()
1539
+ ctx.effect(() => {
1540
+ const refreshSettings = (namespace: string): void => {
1541
+ if (namespace === 'vision-toolkit') {
1542
+ resetDisplayConfigCache()
1543
+ controller.refreshIfLoaded()
1544
+ }
1545
+ }
1546
+ const refreshCredential = (ref: string): void => {
1547
+ const current = controller.snapshot().snapshot
1548
+ if (current?.credential.ref === ref) controller.refreshIfLoaded()
1549
+ }
1550
+ const legacyRemote = ctx.remote as typeof ctx.remote & {
1551
+ $on?: (event: string, listener: (value: string) => void) => () => void
1552
+ }
1553
+ const currentEvents = ctx as unknown as {
1554
+ on(event: 'settings/changed', listener: (namespace: string) => void): () => void
1555
+ on(event: 'credentials/changed', listener: (ref: string) => void): () => void
1556
+ }
1557
+ const disposers = typeof legacyRemote.$on === 'function'
1558
+ ? [
1559
+ legacyRemote.$on('settings/document-updated', refreshSettings),
1560
+ legacyRemote.$on('credentials/updated', refreshCredential),
1561
+ ]
1562
+ : [
1563
+ currentEvents.on('settings/changed', (namespace) => {
1564
+ refreshSettings(namespace)
1565
+ }),
1566
+ currentEvents.on('credentials/changed', (ref) => {
1567
+ refreshCredential(ref)
1568
+ }),
1569
+ ]
1570
+ disposers.push(ctx.on('connection/reset', () => { controller.refreshIfLoaded() }))
1571
+ return () => { for (const dispose of disposers) dispose() }
1572
+ }, 'dsh-vision-toolkit: Settings invalidations')
1573
+ ctx.slots.inject('settings.section', () => ctx.slots.register({
1574
+ name: 'settings.section',
1575
+ id: 'vision-toolkit',
1576
+ order: 30,
1577
+ label: () => t('nav'),
1578
+ inject: () => ({ controller, t }),
1579
+ }, SettingsSection))
1580
+ }