@anionex/dsh-vision-toolkit 0.1.5

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 (152) hide show
  1. package/LICENSE +21 -0
  2. package/README.i18n.yaml +6 -0
  3. package/README.md +383 -0
  4. package/README.zh.md +383 -0
  5. package/assets/dsh-conversation-artifact.png +0 -0
  6. package/assets/dsh-conversation-image-qa-top.png +0 -0
  7. package/assets/dsh-conversation-image-qa.png +0 -0
  8. package/assets/dsh-conversation-pixel-diff.png +0 -0
  9. package/assets/dsh-conversation-screenshot-debugging-top.png +0 -0
  10. package/assets/dsh-conversation-screenshot-debugging.png +0 -0
  11. package/assets/dsh-conversation-tool-call.png +0 -0
  12. package/assets/dsh-conversation-vision-trace.png +0 -0
  13. package/assets/hero.png +0 -0
  14. package/assets/social-preview.png +0 -0
  15. package/assets/upstream/README.md +16 -0
  16. package/assets/upstream/image-qa.webp +0 -0
  17. package/assets/upstream/infographic-reference.webp +0 -0
  18. package/assets/upstream/infographic-result.webp +0 -0
  19. package/assets/upstream/screenshot-debugging.webp +0 -0
  20. package/assets/upstream/ui-result.webp +0 -0
  21. package/assets/upstream/ui-sketch.webp +0 -0
  22. package/assets/vision-settings.png +0 -0
  23. package/cordis.patch.yml +6 -0
  24. package/docs/assets/vision-settings.png +0 -0
  25. package/docs/requirements-traceability/README.i18n.yaml +6 -0
  26. package/docs/requirements-traceability/README.md +75 -0
  27. package/docs/requirements-traceability/README.zh.md +75 -0
  28. package/examples/ui-restoration/README.i18n.yaml +6 -0
  29. package/examples/ui-restoration/README.md +70 -0
  30. package/examples/ui-restoration/README.zh.md +70 -0
  31. package/examples/ui-restoration/assets/final-heatmap.png +0 -0
  32. package/examples/ui-restoration/assets/final-report.json +83 -0
  33. package/examples/ui-restoration/assets/implementation.png +0 -0
  34. package/examples/ui-restoration/assets/initial-heatmap.png +0 -0
  35. package/examples/ui-restoration/assets/initial-report.json +83 -0
  36. package/examples/ui-restoration/assets/initial.png +0 -0
  37. package/examples/ui-restoration/assets/metrics.json +12 -0
  38. package/examples/ui-restoration/assets/reference.png +0 -0
  39. package/examples/ui-restoration/implementation.html +94 -0
  40. package/examples/ui-restoration/initial.html +57 -0
  41. package/lib/artifact-access.js +369 -0
  42. package/lib/artifact-access.js.map +1 -0
  43. package/lib/artifacts.js +56 -0
  44. package/lib/artifacts.js.map +1 -0
  45. package/lib/client.js +952 -0
  46. package/lib/client.js.map +1 -0
  47. package/lib/config.js +117 -0
  48. package/lib/config.js.map +1 -0
  49. package/lib/errors.js +56 -0
  50. package/lib/errors.js.map +1 -0
  51. package/lib/exposure.js +213 -0
  52. package/lib/exposure.js.map +1 -0
  53. package/lib/index.js +97 -0
  54. package/lib/index.js.map +1 -0
  55. package/lib/paste-images.js +199 -0
  56. package/lib/paste-images.js.map +1 -0
  57. package/lib/paths.js +325 -0
  58. package/lib/paths.js.map +1 -0
  59. package/lib/runtime-install.js +601 -0
  60. package/lib/runtime-install.js.map +1 -0
  61. package/lib/runtime-manager.js +126 -0
  62. package/lib/runtime-manager.js.map +1 -0
  63. package/lib/runtime.js +1344 -0
  64. package/lib/runtime.js.map +1 -0
  65. package/lib/skill.js +139 -0
  66. package/lib/skill.js.map +1 -0
  67. package/lib/tools.js +528 -0
  68. package/lib/tools.js.map +1 -0
  69. package/lib/types/artifact-access.d.ts +61 -0
  70. package/lib/types/artifact-access.d.ts.map +1 -0
  71. package/lib/types/artifacts.d.ts +42 -0
  72. package/lib/types/artifacts.d.ts.map +1 -0
  73. package/lib/types/client/index.d.ts +179 -0
  74. package/lib/types/client/index.d.ts.map +1 -0
  75. package/lib/types/client/paste-images.d.ts +57 -0
  76. package/lib/types/client/paste-images.d.ts.map +1 -0
  77. package/lib/types/config.d.ts +73 -0
  78. package/lib/types/config.d.ts.map +1 -0
  79. package/lib/types/errors.d.ts +35 -0
  80. package/lib/types/errors.d.ts.map +1 -0
  81. package/lib/types/exposure.d.ts +40 -0
  82. package/lib/types/exposure.d.ts.map +1 -0
  83. package/lib/types/index.d.ts +18 -0
  84. package/lib/types/index.d.ts.map +1 -0
  85. package/lib/types/paste-images.d.ts +21 -0
  86. package/lib/types/paste-images.d.ts.map +1 -0
  87. package/lib/types/paths.d.ts +107 -0
  88. package/lib/types/paths.d.ts.map +1 -0
  89. package/lib/types/runtime-install.d.ts +49 -0
  90. package/lib/types/runtime-install.d.ts.map +1 -0
  91. package/lib/types/runtime-manager.d.ts +60 -0
  92. package/lib/types/runtime-manager.d.ts.map +1 -0
  93. package/lib/types/runtime.d.ts +389 -0
  94. package/lib/types/runtime.d.ts.map +1 -0
  95. package/lib/types/skill.d.ts +15 -0
  96. package/lib/types/skill.d.ts.map +1 -0
  97. package/lib/types/tools.d.ts +22 -0
  98. package/lib/types/tools.d.ts.map +1 -0
  99. package/lib/types/upstream.d.ts +207 -0
  100. package/lib/types/upstream.d.ts.map +1 -0
  101. package/lib/types/version.d.ts +15 -0
  102. package/lib/types/version.d.ts.map +1 -0
  103. package/lib/types/web-request.d.ts +4 -0
  104. package/lib/types/web-request.d.ts.map +1 -0
  105. package/lib/types/web.d.ts +74 -0
  106. package/lib/types/web.d.ts.map +1 -0
  107. package/lib/upstream.js +675 -0
  108. package/lib/upstream.js.map +1 -0
  109. package/lib/version.js +18 -0
  110. package/lib/version.js.map +1 -0
  111. package/lib/web-request.js +20 -0
  112. package/lib/web-request.js.map +1 -0
  113. package/lib/web.js +244 -0
  114. package/lib/web.js.map +1 -0
  115. package/package.json +139 -0
  116. package/runtime/requirements.lock +3 -0
  117. package/src/artifact-access.ts +386 -0
  118. package/src/artifacts.ts +85 -0
  119. package/src/client/index.tsx +866 -0
  120. package/src/client/paste-images.tsx +426 -0
  121. package/src/config.ts +177 -0
  122. package/src/errors.ts +62 -0
  123. package/src/exposure.ts +227 -0
  124. package/src/index.ts +122 -0
  125. package/src/paste-images.ts +234 -0
  126. package/src/paths.ts +348 -0
  127. package/src/runtime-install.ts +723 -0
  128. package/src/runtime-manager.ts +166 -0
  129. package/src/runtime.ts +1783 -0
  130. package/src/skill.ts +143 -0
  131. package/src/tools.ts +668 -0
  132. package/src/upstream.ts +861 -0
  133. package/src/version.ts +37 -0
  134. package/src/web-request.ts +17 -0
  135. package/src/web.ts +329 -0
  136. package/vendor/agent-vision-toolkit/CHANGELOG.md +16 -0
  137. package/vendor/agent-vision-toolkit/LICENSE +21 -0
  138. package/vendor/agent-vision-toolkit/README.md +399 -0
  139. package/vendor/agent-vision-toolkit/UPSTREAM_MANIFEST.json +89 -0
  140. package/vendor/agent-vision-toolkit/bin/crop +90 -0
  141. package/vendor/agent-vision-toolkit/bin/detect +13 -0
  142. package/vendor/agent-vision-toolkit/bin/glance +93 -0
  143. package/vendor/agent-vision-toolkit/bin/ground +13 -0
  144. package/vendor/agent-vision-toolkit/bin/trace +129 -0
  145. package/vendor/agent-vision-toolkit/detect.py +56 -0
  146. package/vendor/agent-vision-toolkit/ground.py +216 -0
  147. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/dominant_colors.py +224 -0
  148. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/extract_fg.py +278 -0
  149. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/html_shot.py +108 -0
  150. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/long_screenshot_ocr.py +1245 -0
  151. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/pixel_diff.py +88 -0
  152. package/vendor/agent-vision-toolkit/vision_client.py +156 -0
@@ -0,0 +1,866 @@
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
+
24
+ const NS = 'vision-toolkit'
25
+ const SETTINGS_ROUTE = '/_dsh/vision-toolkit/settings'
26
+ const PRESENTATION_META_KEY = '$dshVisionToolkit'
27
+
28
+ const en = {
29
+ nav: 'Vision',
30
+ settingsTitle: 'Vision Toolkit',
31
+ settingsIntro: 'Configure the pinned visual engineering runtime, its external vision endpoint, and local safety limits.',
32
+ 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.',
33
+ provider: 'Vision service',
34
+ baseUrl: 'Base URL',
35
+ credential: 'Credential reference',
36
+ model: 'Model',
37
+ language: 'Output language',
38
+ limits: 'Limits',
39
+ timeout: 'Request timeout (ms)',
40
+ maxBytes: 'Maximum image bytes',
41
+ maxPixels: 'Maximum image pixels',
42
+ concurrency: 'Concurrent calls per session',
43
+ runtime: 'Runtime',
44
+ runtimeMode: 'Runtime mode',
45
+ toolkitPath: 'Pinned checkout path',
46
+ python: 'Python override',
47
+ allowedDirs: 'Additional allowed directories',
48
+ allowedDirsHint: 'One path per line. The session workspace is always allowed.',
49
+ save: 'Save and apply',
50
+ saving: 'Validating runtime…',
51
+ reload: 'Reload',
52
+ saved: 'Settings validated and applied.',
53
+ readOnly: 'The active Settings provider is read-only.',
54
+ configured: 'Configured',
55
+ missing: 'Missing',
56
+ source: 'Source',
57
+ health: 'Health',
58
+ runHealth: 'Run health check',
59
+ testConnection: 'Test connection',
60
+ testing: 'Checking…',
61
+ connectionHint: 'Connection testing explicitly sends the configured credential to GET /models. It uploads no image and creates no completion.',
62
+ pluginVersion: 'Plugin',
63
+ upstreamVersion: 'Upstream',
64
+ activeGeneration: 'Runtime generation',
65
+ runtimeUnavailable: 'Runtime unavailable',
66
+ runtimeCandidateRejected: 'Last runtime candidate was rejected; the active generation remains available.',
67
+ retry: 'Retry',
68
+ open: 'Open file',
69
+ download: 'Download',
70
+ previewUnavailable: 'HTTP preview is unavailable in this host; use Open file.',
71
+ running: 'Running…',
72
+ failed: 'Failed',
73
+ matches: 'matches',
74
+ elements: 'elements',
75
+ dimensions: 'Dimensions',
76
+ coordinates: 'Coordinates',
77
+ artifact: 'Artifact',
78
+ artifacts: 'Artifacts',
79
+ difference: 'Overall difference',
80
+ worstRegions: 'Worst regions',
81
+ colors: 'Dominant colors',
82
+ noResult: 'Structured result unavailable; inspect the raw Tool result.',
83
+ healthy: 'Healthy',
84
+ degraded: 'Needs attention',
85
+ notTested: 'Not tested',
86
+ } as const
87
+
88
+ type LocaleKey = keyof typeof en
89
+
90
+ const zh: Record<LocaleKey, string> = {
91
+ nav: '视觉工具',
92
+ settingsTitle: 'Vision Toolkit',
93
+ settingsIntro: '配置固定版本的视觉工程运行时、外部视觉服务与本地安全限制。',
94
+ externalNotice: '远程工具会把选中的图片字节发送到已配置的外部视觉 API。本地裁剪、几何恢复、像素对比、调色板、前景提取和 HTML 渲染不会上传图片。',
95
+ provider: '视觉服务',
96
+ baseUrl: '服务地址',
97
+ credential: 'Credential 引用',
98
+ model: '模型',
99
+ language: '输出语言',
100
+ limits: '限制',
101
+ timeout: '请求超时(毫秒)',
102
+ maxBytes: '最大图片字节数',
103
+ maxPixels: '最大图片像素数',
104
+ concurrency: '每个 Session 并发数',
105
+ runtime: '运行时',
106
+ runtimeMode: '运行模式',
107
+ toolkitPath: '固定上游 checkout 路径',
108
+ python: 'Python 覆盖',
109
+ allowedDirs: '额外允许目录',
110
+ allowedDirsHint: '每行一个路径;Session 工作区始终允许。',
111
+ save: '保存并应用',
112
+ saving: '正在验证运行时…',
113
+ reload: '重新加载',
114
+ saved: '设置已验证并生效。',
115
+ readOnly: '当前 Settings 提供方为只读。',
116
+ configured: '已配置',
117
+ missing: '缺失',
118
+ source: '来源',
119
+ health: '健康检查',
120
+ runHealth: '运行健康检查',
121
+ testConnection: '测试连接',
122
+ testing: '检查中…',
123
+ connectionHint: '连接测试会显式把已配置 Credential 发送到 GET /models;不会上传图片,也不会创建 completion。',
124
+ pluginVersion: '插件',
125
+ upstreamVersion: '上游',
126
+ activeGeneration: '运行时世代',
127
+ runtimeUnavailable: '运行时不可用',
128
+ runtimeCandidateRejected: '最近的运行时候选已被拒绝;当前世代仍可用。',
129
+ retry: '重试',
130
+ open: '打开文件',
131
+ download: '下载',
132
+ previewUnavailable: '当前宿主不提供 HTTP 预览,请使用“打开文件”。',
133
+ running: '运行中…',
134
+ failed: '失败',
135
+ matches: '个匹配',
136
+ elements: '个元素',
137
+ dimensions: '尺寸',
138
+ coordinates: '坐标',
139
+ artifact: '交付文件',
140
+ artifacts: '交付文件',
141
+ difference: '总体差异',
142
+ worstRegions: '差异最大区域',
143
+ colors: '主色',
144
+ noResult: '结构化结果不可用,请检查原始 Tool 结果。',
145
+ healthy: '健康',
146
+ degraded: '需要处理',
147
+ notTested: '未测试',
148
+ }
149
+
150
+ type Translate = (key: LocaleKey) => string
151
+
152
+ interface ToolCallOwnerProps {
153
+ callId: string
154
+ toolName: string
155
+ block: ToolCallBlock
156
+ cwd?: string | undefined
157
+ openFile: (path: string) => void
158
+ inspect?: (() => void) | undefined
159
+ }
160
+
161
+ declare module '@deepseek-ai/dsh-client-ui-slots' {
162
+ interface SlotMap {
163
+ /** Keyed atomic Tool call view, dispatched by wire Tool name. */
164
+ 'tool.call.toolview': { kind: 'keyed'; scope: 'session'; owner: ToolCallOwnerProps }
165
+ }
166
+
167
+ interface LocaleNamespaceMap {
168
+ /** DSH Vision Toolkit Tool cards and Settings copy. */
169
+ 'vision-toolkit': LocaleKey
170
+ }
171
+ }
172
+
173
+ type ToolCallViewProps = PropsRuntime<'tool.call.toolview'>
174
+
175
+ interface ArtifactDescriptor {
176
+ path: string
177
+ filename: string
178
+ mimeType: string
179
+ kind: 'image' | 'svg' | 'markdown' | 'json'
180
+ description: string
181
+ sourceTool: string
182
+ previewIntent: 'image' | 'svg' | 'text' | 'download'
183
+ bytes: number
184
+ }
185
+
186
+ interface ArtifactGrant {
187
+ path: string
188
+ previewUrl: string
189
+ downloadUrl: string
190
+ }
191
+
192
+ interface HealthCheck {
193
+ status: 'ok' | 'warning' | 'error' | 'not_tested'
194
+ detail: string
195
+ }
196
+
197
+ interface HealthResult {
198
+ pluginVersion: string
199
+ checks: Record<string, HealthCheck>
200
+ healthy: boolean
201
+ connectionTested: boolean
202
+ }
203
+
204
+ interface SettingsValue {
205
+ provider?: { baseUrl?: string; credential?: string; model?: string }
206
+ language?: 'zh' | 'en'
207
+ timeoutMs?: number
208
+ maxImageBytes?: number
209
+ maxImagePixels?: number
210
+ concurrency?: number
211
+ runtime?: { mode?: 'managed' | 'external'; agentVisionToolkitPath?: string; python?: string }
212
+ allowedDirs?: string[]
213
+ }
214
+
215
+ interface SettingsSnapshot {
216
+ schemaVersion: 1
217
+ writable: boolean
218
+ settings: { value: SettingsValue; revision: number; applies: 'live' }
219
+ credential: { ref: string; configured: boolean; source?: string; writable: boolean }
220
+ runtime: {
221
+ ready: boolean
222
+ generation: number
223
+ activeConfig?: SettingsValue
224
+ upstream?: {
225
+ source: 'managed' | 'external'
226
+ path: string
227
+ runtimeHome: string
228
+ python: string
229
+ pythonVersion: string
230
+ }
231
+ lastError?: string
232
+ }
233
+ release: {
234
+ pluginVersion: string
235
+ upstreamRepository: string
236
+ upstreamVersion: string
237
+ upstreamCommit: string
238
+ }
239
+ artifactRouteAvailable: boolean
240
+ }
241
+
242
+ interface ApiSuccess<T> { ok: true; value: T }
243
+ interface ApiFailure { ok: false; error: { code: string; message: string } }
244
+
245
+ function isRecord(value: unknown): value is Record<string, unknown> {
246
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
247
+ }
248
+
249
+ function textOfContent(block: ToolCallBlock): string {
250
+ if (!('kind' in block)) return ''
251
+ return block.content
252
+ .filter((entry): entry is Extract<typeof entry, { type: 'text' }> => entry.type === 'text')
253
+ .map(entry => entry.text)
254
+ .join('\n')
255
+ }
256
+
257
+ /** Decode canonical presentation metadata with a JSON-text fallback. */
258
+ export function decodeVisionResult(block: ToolCallBlock): Record<string, unknown> | undefined {
259
+ if (!('kind' in block) || block.isError) return undefined
260
+ if (isRecord(block.meta)) return block.meta
261
+ const text = textOfContent(block).trim()
262
+ if (text.length === 0) return undefined
263
+ try {
264
+ const parsed = JSON.parse(text) as unknown
265
+ return isRecord(parsed) ? parsed : undefined
266
+ } catch {
267
+ return undefined
268
+ }
269
+ }
270
+
271
+ function accessMap(value: Record<string, unknown> | undefined): Map<string, ArtifactGrant> {
272
+ const map = new Map<string, ArtifactGrant>()
273
+ if (value === undefined) return map
274
+ const envelope = value[PRESENTATION_META_KEY]
275
+ if (!isRecord(envelope) || envelope.schemaVersion !== 1 || !Array.isArray(envelope.artifacts)) return map
276
+ for (const entry of envelope.artifacts) {
277
+ if (!isRecord(entry) || typeof entry.path !== 'string' || typeof entry.previewUrl !== 'string' || typeof entry.downloadUrl !== 'string') continue
278
+ map.set(entry.path, entry as unknown as ArtifactGrant)
279
+ }
280
+ return map
281
+ }
282
+
283
+ function artifactFrom(value: unknown): ArtifactDescriptor | undefined {
284
+ if (!isRecord(value)) return undefined
285
+ if (
286
+ typeof value.path !== 'string'
287
+ || typeof value.filename !== 'string'
288
+ || typeof value.mimeType !== 'string'
289
+ || (value.kind !== 'image' && value.kind !== 'svg' && value.kind !== 'markdown' && value.kind !== 'json')
290
+ || typeof value.description !== 'string'
291
+ || typeof value.sourceTool !== 'string'
292
+ || (value.previewIntent !== 'image' && value.previewIntent !== 'svg' && value.previewIntent !== 'text' && value.previewIntent !== 'download')
293
+ || typeof value.bytes !== 'number'
294
+ ) return undefined
295
+ return value as unknown as ArtifactDescriptor
296
+ }
297
+
298
+ function collectArtifacts(value: unknown, found = new Map<string, ArtifactDescriptor>(), depth = 0): ArtifactDescriptor[] {
299
+ if (depth > 16) return [...found.values()]
300
+ const artifact = artifactFrom(value)
301
+ if (artifact !== undefined) {
302
+ found.set(artifact.path, artifact)
303
+ return [...found.values()]
304
+ }
305
+ if (Array.isArray(value)) {
306
+ for (const entry of value) collectArtifacts(entry, found, depth + 1)
307
+ } else if (isRecord(value)) {
308
+ for (const entry of Object.values(value)) collectArtifacts(entry, found, depth + 1)
309
+ }
310
+ return [...found.values()]
311
+ }
312
+
313
+ function numberOf(value: unknown): number | undefined {
314
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined
315
+ }
316
+
317
+ function stringOf(value: unknown): string | undefined {
318
+ return typeof value === 'string' ? value : undefined
319
+ }
320
+
321
+ function formatBytes(bytes: number): string {
322
+ if (bytes < 1024) return `${bytes} B`
323
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
324
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
325
+ }
326
+
327
+ function boxText(value: unknown): string {
328
+ if (!isRecord(value)) return '—'
329
+ const parts = ['x1', 'y1', 'x2', 'y2'].map(key => numberOf(value[key]))
330
+ return parts.every(part => part !== undefined) ? parts.join(', ') : '—'
331
+ }
332
+
333
+ function statusText(block: ToolCallBlock, t: Translate): string | undefined {
334
+ if (!('kind' in block)) return t('running')
335
+ if (block.isError) return textOfContent(block).split('\n')[0] || t('failed')
336
+ return undefined
337
+ }
338
+
339
+ function VisionIcon({ kind = 'scan' }: { kind?: 'scan' | 'target' | 'layers' | 'shape' | 'diff' | 'palette' }) {
340
+ const path = kind === 'target'
341
+ ? 'M8 2v2m0 8v2M2 8h2m8 0h2M5 5h6v6H5z'
342
+ : kind === 'layers'
343
+ ? 'm3 6 5-3 5 3-5 3-5-3Zm0 3 5 3 5-3M3 12l5 3 5-3'
344
+ : kind === 'shape'
345
+ ? 'M3 12 6 4l7-1-1 7-9 2Zm3-8 6 6'
346
+ : kind === 'diff'
347
+ ? 'M3 3h4v4H3V3Zm6 6h4v4H9V9Zm0-6h4M3 11h4'
348
+ : kind === 'palette'
349
+ ? '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'
350
+ : 'M3 5V3h2M11 3h2v2M13 11v2h-2M5 13H3v-2M5 8h6'
351
+ return (
352
+ <svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" fill="none" stroke="currentColor" strokeWidth="1.35" strokeLinecap="round" strokeLinejoin="round">
353
+ <path d={path} />
354
+ </svg>
355
+ )
356
+ }
357
+
358
+ function ToolShell({
359
+ block, title, summary, icon, children, t,
360
+ }: {
361
+ block: ToolCallBlock
362
+ title: string
363
+ summary?: string | undefined
364
+ icon: ReactNode
365
+ children?: ReactNode | undefined
366
+ t: Translate
367
+ }) {
368
+ const [open, setOpen] = useState(true)
369
+ const status = statusText(block, t)
370
+ const expandable = children !== undefined && children !== null
371
+ return (
372
+ <section className="dvt-tool" data-state={!('kind' in block) ? 'running' : block.isError ? 'error' : 'success'}>
373
+ <button type="button" className="dvt-tool-head" onClick={() => { if (expandable) setOpen(value => !value) }} aria-expanded={expandable ? open : undefined}>
374
+ <span className="dvt-tool-icon">{icon}</span>
375
+ <span className="dvt-tool-title">{title}</span>
376
+ {summary !== undefined && summary.length > 0 ? <span className="dvt-tool-sep" aria-hidden="true">·</span> : null}
377
+ {summary !== undefined ? <span className="dvt-tool-summary">{summary}</span> : null}
378
+ {status !== undefined ? <span className="dvt-tool-status">{status}</span> : null}
379
+ {expandable ? <span className="dvt-chevron" data-open={open || undefined}>⌄</span> : null}
380
+ </button>
381
+ {expandable && open ? <div className="dvt-tool-body">{children}</div> : null}
382
+ </section>
383
+ )
384
+ }
385
+
386
+ function ArtifactActions({ artifact, grant, openFile, t }: {
387
+ artifact: ArtifactDescriptor
388
+ grant?: ArtifactGrant | undefined
389
+ openFile: (path: string) => void
390
+ t: Translate
391
+ }) {
392
+ return (
393
+ <div className="dvt-actions">
394
+ <Button size="sm" variant="outline" onClick={() => { openFile(artifact.path) }}>{t('open')}</Button>
395
+ {grant === undefined ? null : <a className="dvt-download" href={grant.downloadUrl} download={artifact.filename}>{t('download')}</a>}
396
+ </div>
397
+ )
398
+ }
399
+
400
+ function ArtifactPreview({ artifact, grant, openFile, t }: {
401
+ artifact: ArtifactDescriptor
402
+ grant?: ArtifactGrant | undefined
403
+ openFile: (path: string) => void
404
+ t: Translate
405
+ }) {
406
+ const canPreview = grant !== undefined && (artifact.kind === 'image' || artifact.kind === 'svg')
407
+ return (
408
+ <article className="dvt-artifact">
409
+ {canPreview
410
+ ? artifact.kind === 'svg'
411
+ ? <iframe className="dvt-preview dvt-svg" sandbox="" src={grant.previewUrl} title={artifact.description} />
412
+ : <img className="dvt-preview" src={grant.previewUrl} alt={artifact.description} loading="lazy" />
413
+ : null}
414
+ <div className="dvt-artifact-meta">
415
+ <div>
416
+ <strong>{artifact.filename}</strong>
417
+ <span>{artifact.description}</span>
418
+ <small>{artifact.mimeType} · {formatBytes(artifact.bytes)}</small>
419
+ </div>
420
+ <ArtifactActions artifact={artifact} grant={grant} openFile={openFile} t={t} />
421
+ </div>
422
+ {!canPreview && grant === undefined ? <p className="dvt-muted">{t('previewUnavailable')}</p> : null}
423
+ </article>
424
+ )
425
+ }
426
+
427
+ type ViewProps = ToolCallViewProps & { t?: Translate }
428
+
429
+ function GroundView({ block, openFile, t = key => en[key] }: ViewProps) {
430
+ const value = decodeVisionResult(block)
431
+ const matches = Array.isArray(value?.matches) ? value.matches.filter(isRecord) : []
432
+ const target = stringOf(value?.target) ?? 'Ground'
433
+ const width = numberOf(value?.imageWidth)
434
+ const height = numberOf(value?.imageHeight)
435
+ const preview = artifactFrom(value?.preview)
436
+ const grants = accessMap(value)
437
+ return (
438
+ <ToolShell block={block} title="Ground" summary={matches.length > 0 ? `${target} · ${matches.length} ${t('matches')}` : target} icon={<VisionIcon kind="target" />} t={t}>
439
+ {value === undefined ? <p className="dvt-muted">{t('noResult')}</p> : (
440
+ <div className="dvt-stack">
441
+ <div className="dvt-metrics">
442
+ <div><span>{t('dimensions')}</span><strong>{width ?? '—'} × {height ?? '—'}</strong></div>
443
+ <div><span>{t('coordinates')}</span><strong>{matches[0] === undefined ? '—' : boxText(matches[0].box)}</strong></div>
444
+ </div>
445
+ {matches.length > 1 ? (
446
+ <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>
447
+ ) : null}
448
+ {preview === undefined ? null : <ArtifactPreview artifact={preview} grant={grants.get(preview.path)} openFile={openFile} t={t} />}
449
+ </div>
450
+ )}
451
+ </ToolShell>
452
+ )
453
+ }
454
+
455
+ function DetectView({ block, openFile, t = key => en[key] }: ViewProps) {
456
+ const value = decodeVisionResult(block)
457
+ const elements = Array.isArray(value?.elements) ? value.elements.filter(isRecord) : []
458
+ const width = numberOf(value?.imageWidth)
459
+ const height = numberOf(value?.imageHeight)
460
+ const preview = artifactFrom(value?.preview)
461
+ const grants = accessMap(value)
462
+ return (
463
+ <ToolShell block={block} title="Detect" summary={`${elements.length} ${t('elements')}`} icon={<VisionIcon kind="layers" />} t={t}>
464
+ {value === undefined ? <p className="dvt-muted">{t('noResult')}</p> : (
465
+ <div className="dvt-stack">
466
+ <div className="dvt-metrics">
467
+ <div><span>{t('dimensions')}</span><strong>{width ?? '—'} × {height ?? '—'}</strong></div>
468
+ <div><span>{t('elements')}</span><strong>{elements.length}</strong></div>
469
+ </div>
470
+ <div className="dvt-table-wrap"><table className="dvt-table"><thead><tr><th>#</th><th>Label</th><th>{t('coordinates')}</th></tr></thead><tbody>
471
+ {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>)}
472
+ </tbody></table></div>
473
+ {preview === undefined ? null : <ArtifactPreview artifact={preview} grant={grants.get(preview.path)} openFile={openFile} t={t} />}
474
+ </div>
475
+ )}
476
+ </ToolShell>
477
+ )
478
+ }
479
+
480
+ function TraceView({ block, openFile, t = key => en[key] }: ViewProps) {
481
+ const value = decodeVisionResult(block)
482
+ const artifact = artifactFrom(value?.artifact)
483
+ const geometry = isRecord(value?.geometry) ? value.geometry : undefined
484
+ const summary = geometry === undefined ? undefined : `${numberOf(geometry.pathCount) ?? 0} paths · ${formatBytes(numberOf(geometry.bytes) ?? 0)}`
485
+ const grants = accessMap(value)
486
+ return (
487
+ <ToolShell block={block} title="Trace SVG" summary={summary} icon={<VisionIcon kind="shape" />} t={t}>
488
+ {artifact === undefined ? <p className="dvt-muted">{t('noResult')}</p> : <ArtifactPreview artifact={artifact} grant={grants.get(artifact.path)} openFile={openFile} t={t} />}
489
+ </ToolShell>
490
+ )
491
+ }
492
+
493
+ function PixelDiffView({ block, openFile, t = key => en[key] }: ViewProps) {
494
+ const value = decodeVisionResult(block)
495
+ const pct = numberOf(value?.overallDifferencePct)
496
+ const regions = Array.isArray(value?.worstRegions) ? value.worstRegions.filter(isRecord) : []
497
+ const heatmap = artifactFrom(value?.heatmap)
498
+ const report = artifactFrom(value?.report)
499
+ const grants = accessMap(value)
500
+ return (
501
+ <ToolShell block={block} title="Pixel Diff" summary={pct === undefined ? undefined : `${pct.toFixed(3)}%`} icon={<VisionIcon kind="diff" />} t={t}>
502
+ {value === undefined ? <p className="dvt-muted">{t('noResult')}</p> : (
503
+ <div className="dvt-stack">
504
+ <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>
505
+ {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>}
506
+ {heatmap === undefined ? null : <ArtifactPreview artifact={heatmap} grant={grants.get(heatmap.path)} openFile={openFile} t={t} />}
507
+ {report === undefined ? null : <ArtifactPreview artifact={report} grant={grants.get(report.path)} openFile={openFile} t={t} />}
508
+ </div>
509
+ )}
510
+ </ToolShell>
511
+ )
512
+ }
513
+
514
+ function ArtifactView({ block, openFile, toolName, t = key => en[key] }: ViewProps) {
515
+ const value = decodeVisionResult(block)
516
+ const artifacts = collectArtifacts(value)
517
+ const grants = accessMap(value)
518
+ const title = toolName === 'vision_crop' ? 'Crop'
519
+ : toolName === 'vision_long_screenshot_ocr' ? 'Long OCR'
520
+ : toolName === 'vision_extract_foreground' ? 'Extract Foreground'
521
+ : toolName === 'vision_html_screenshot' ? 'HTML Screenshot'
522
+ : 'Vision Artifact'
523
+ return (
524
+ <ToolShell block={block} title={title} summary={artifacts.length > 0 ? `${artifacts.length} ${t('artifacts')}` : undefined} icon={<VisionIcon />} t={t}>
525
+ {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>}
526
+ </ToolShell>
527
+ )
528
+ }
529
+
530
+ function PaletteView({ block, t = key => en[key] }: ViewProps) {
531
+ const value = decodeVisionResult(block)
532
+ const analysis = isRecord(value?.analysis) ? value.analysis : undefined
533
+ const colors = Array.isArray(analysis?.colors) ? analysis.colors.filter(isRecord) : []
534
+ return (
535
+ <ToolShell block={block} title="Dominant Colors" summary={`${colors.length} ${t('colors')}`} icon={<VisionIcon kind="palette" />} t={t}>
536
+ {colors.length === 0 ? <p className="dvt-muted">{t('noResult')}</p> : <div className="dvt-palette">{colors.map((color, index) => {
537
+ const hex = stringOf(color.color) ?? '#000000'
538
+ const share = numberOf(color.sharePct)
539
+ return <div key={`${hex}-${index}`}><i style={{ background: hex }} /><span><strong>{hex}</strong><small>{share === undefined ? '' : `${share.toFixed(2)}%`}</small></span></div>
540
+ })}</div>}
541
+ </ToolShell>
542
+ )
543
+ }
544
+
545
+ async function apiRequest<T>(init?: RequestInit): Promise<T> {
546
+ const response = await fetch(SETTINGS_ROUTE, { credentials: 'same-origin', ...init })
547
+ const body = await response.json() as ApiSuccess<T> | ApiFailure
548
+ if (!response.ok || !body.ok) {
549
+ const failure = body as ApiFailure
550
+ throw new Error(failure.error?.message ?? `Vision Toolkit request failed with HTTP ${response.status}`)
551
+ }
552
+ return body.value
553
+ }
554
+
555
+ interface SettingsState {
556
+ status: 'idle' | 'loading' | 'ready' | 'error'
557
+ snapshot?: SettingsSnapshot | undefined
558
+ health?: HealthResult | undefined
559
+ action?: 'save' | 'health' | 'connection' | undefined
560
+ message?: string | undefined
561
+ error?: string | undefined
562
+ }
563
+
564
+ /** Small external store shared by the Settings route and pushed invalidations. */
565
+ export class VisionSettingsController {
566
+ private state: SettingsState = { status: 'idle' }
567
+ private listeners = new Set<() => void>()
568
+ private generation = 0
569
+
570
+ subscribe = (listener: () => void): (() => void) => {
571
+ this.listeners.add(listener)
572
+ return () => { this.listeners.delete(listener) }
573
+ }
574
+
575
+ snapshot = (): SettingsState => this.state
576
+
577
+ private set(next: SettingsState): void {
578
+ this.state = next
579
+ for (const listener of this.listeners) listener()
580
+ }
581
+
582
+ async load(): Promise<void> {
583
+ const generation = ++this.generation
584
+ this.set({ ...this.state, status: 'loading', error: undefined, message: undefined })
585
+ try {
586
+ const snapshot = await apiRequest<SettingsSnapshot>()
587
+ if (generation !== this.generation) return
588
+ this.set({ status: 'ready', snapshot, health: this.state.health })
589
+ } catch (error) {
590
+ if (generation !== this.generation) return
591
+ this.set({ ...this.state, status: 'error', error: error instanceof Error ? error.message : String(error) })
592
+ }
593
+ }
594
+
595
+ refreshIfLoaded(): void {
596
+ if (this.state.status === 'idle' || this.state.action === 'save') return
597
+ void this.load()
598
+ }
599
+
600
+ async save(value: SettingsValue, expectedRevision: number): Promise<void> {
601
+ this.set({ ...this.state, action: 'save', error: undefined, message: undefined })
602
+ try {
603
+ const snapshot = await apiRequest<SettingsSnapshot>({
604
+ method: 'POST',
605
+ headers: { 'Content-Type': 'application/json' },
606
+ body: JSON.stringify({ action: 'save', expectedRevision, value }),
607
+ })
608
+ this.set({ status: 'ready', snapshot, health: this.state.health, message: 'saved' })
609
+ } catch (error) {
610
+ this.set({ ...this.state, action: undefined, error: error instanceof Error ? error.message : String(error) })
611
+ }
612
+ }
613
+
614
+ async runHealth(testConnection: boolean): Promise<void> {
615
+ this.set({ ...this.state, action: testConnection ? 'connection' : 'health', error: undefined, message: undefined })
616
+ try {
617
+ const health = await apiRequest<HealthResult>({
618
+ method: 'POST',
619
+ headers: { 'Content-Type': 'application/json' },
620
+ body: JSON.stringify({ action: 'health', testConnection }),
621
+ })
622
+ this.set({ ...this.state, action: undefined, health })
623
+ } catch (error) {
624
+ this.set({ ...this.state, action: undefined, error: error instanceof Error ? error.message : String(error) })
625
+ }
626
+ }
627
+ }
628
+
629
+ interface Draft {
630
+ baseUrl: string
631
+ credential: string
632
+ model: string
633
+ language: 'zh' | 'en'
634
+ timeoutMs: string
635
+ maxImageBytes: string
636
+ maxImagePixels: string
637
+ concurrency: string
638
+ runtimeMode: 'managed' | 'external'
639
+ toolkitPath: string
640
+ python: string
641
+ allowedDirs: string
642
+ }
643
+
644
+ function draftOf(value: SettingsValue): Draft {
645
+ return {
646
+ baseUrl: value.provider?.baseUrl ?? 'https://api.inferera.com/v1',
647
+ credential: value.provider?.credential ?? 'VISION_API_KEY',
648
+ model: value.provider?.model ?? 'gemini-3.6-flash',
649
+ language: value.language ?? 'zh',
650
+ timeoutMs: String(value.timeoutMs ?? 60000),
651
+ maxImageBytes: String(value.maxImageBytes ?? 10485760),
652
+ maxImagePixels: String(value.maxImagePixels ?? 40000000),
653
+ concurrency: String(value.concurrency ?? 4),
654
+ runtimeMode: value.runtime?.mode ?? 'managed',
655
+ toolkitPath: value.runtime?.agentVisionToolkitPath ?? '',
656
+ python: value.runtime?.python ?? '',
657
+ allowedDirs: (value.allowedDirs ?? []).join('\n'),
658
+ }
659
+ }
660
+
661
+ function positiveInteger(raw: string, label: string): number {
662
+ const value = Number(raw)
663
+ if (!Number.isSafeInteger(value) || value <= 0) throw new Error(`${label} must be a positive integer`)
664
+ return value
665
+ }
666
+
667
+ function valueOf(draft: Draft): SettingsValue {
668
+ return {
669
+ provider: { baseUrl: draft.baseUrl.trim(), credential: draft.credential.trim(), model: draft.model.trim() },
670
+ language: draft.language,
671
+ timeoutMs: positiveInteger(draft.timeoutMs, 'timeoutMs'),
672
+ maxImageBytes: positiveInteger(draft.maxImageBytes, 'maxImageBytes'),
673
+ maxImagePixels: positiveInteger(draft.maxImagePixels, 'maxImagePixels'),
674
+ concurrency: positiveInteger(draft.concurrency, 'concurrency'),
675
+ runtime: {
676
+ mode: draft.runtimeMode,
677
+ ...(draft.runtimeMode === 'external' ? { agentVisionToolkitPath: draft.toolkitPath.trim() } : {}),
678
+ ...(draft.python.trim().length === 0 ? {} : { python: draft.python.trim() }),
679
+ },
680
+ allowedDirs: draft.allowedDirs.split(/\r?\n/).map(entry => entry.trim()).filter(Boolean),
681
+ }
682
+ }
683
+
684
+ interface SettingsInjected {
685
+ controller: VisionSettingsController
686
+ t: Translate
687
+ }
688
+
689
+ type SettingsProps = PropsRuntime<'settings.section'> & Partial<SettingsInjected>
690
+
691
+ function Field({ label, children, hint }: { label: string; children: ReactNode; hint?: string | undefined }) {
692
+ return <label className="dvt-field"><span>{label}</span>{children}{hint === undefined ? null : <small>{hint}</small>}</label>
693
+ }
694
+
695
+ function SettingsSection({ controller, t }: SettingsProps) {
696
+ if (controller === undefined || t === undefined) return null
697
+ return <LoadedSettings controller={controller} t={t} />
698
+ }
699
+
700
+ function LoadedSettings({ controller, t }: SettingsInjected) {
701
+ const state = useSyncExternalStore(controller.subscribe, controller.snapshot, controller.snapshot)
702
+ const snapshot = state.snapshot
703
+ const [draft, setDraft] = useState<Draft | undefined>(undefined)
704
+ const [draftError, setDraftError] = useState<string | undefined>(undefined)
705
+
706
+ useEffect(() => { if (state.status === 'idle') void controller.load() }, [controller, state.status])
707
+ useEffect(() => {
708
+ if (snapshot !== undefined) setDraft(draftOf(snapshot.settings.value))
709
+ }, [snapshot])
710
+
711
+ if (state.status === 'idle' || (state.status === 'loading' && snapshot === undefined)) {
712
+ return <div className="dvt-settings"><div className="dvt-loading">{t('testing')}</div></div>
713
+ }
714
+ if (snapshot === undefined || draft === undefined) {
715
+ 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>
716
+ }
717
+
718
+ const update = <K extends keyof Draft>(key: K, value: Draft[K]): void => setDraft(current => current === undefined ? current : { ...current, [key]: value })
719
+ const save = (): void => {
720
+ try {
721
+ setDraftError(undefined)
722
+ void controller.save(valueOf(draft), snapshot.settings.revision)
723
+ } catch (error) {
724
+ setDraftError(error instanceof Error ? error.message : String(error))
725
+ }
726
+ }
727
+ const busy = state.action !== undefined
728
+ const runtimeErrorTitle = snapshot.runtime.ready ? t('runtimeCandidateRejected') : t('runtimeUnavailable')
729
+
730
+ return (
731
+ <div className="dvt-settings">
732
+ <header className="dvt-settings-header">
733
+ <div><span className="dvt-kicker">DSH native plugin</span><h2>{t('settingsTitle')}</h2><p>{t('settingsIntro')}</p></div>
734
+ <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>{snapshot.runtime.generation}</strong></span></div>
735
+ </header>
736
+ <div className="dvt-alert notice">{t('externalNotice')}</div>
737
+ {!snapshot.writable ? <div className="dvt-alert warning">{t('readOnly')}</div> : null}
738
+ {draftError === undefined ? null : <div className="dvt-alert error">{draftError}</div>}
739
+ {state.error === undefined ? null : <div className="dvt-alert error">{state.error}</div>}
740
+ {state.message === 'saved' ? <div className="dvt-alert success">{t('saved')}</div> : null}
741
+ {snapshot.runtime.lastError === undefined ? null : <div className="dvt-alert error"><strong>{runtimeErrorTitle}</strong><span>{snapshot.runtime.lastError}</span></div>}
742
+
743
+ <section className="dvt-panel"><div className="dvt-panel-title"><h3>{t('provider')}</h3><span className={`dvt-badge ${snapshot.credential.configured ? 'ok' : 'error'}`}>{snapshot.credential.configured ? t('configured') : t('missing')}</span></div>
744
+ <div className="dvt-form-grid">
745
+ <Field label={t('baseUrl')}><Input value={draft.baseUrl} onChange={(event) => { update('baseUrl', event.target.value) }} /></Field>
746
+ <Field label={t('model')}><Input value={draft.model} onChange={(event) => { update('model', event.target.value) }} /></Field>
747
+ <Field label={t('credential')} hint={snapshot.credential.source === undefined ? undefined : `${t('source')}: ${snapshot.credential.source}`}><Input value={draft.credential} onChange={(event) => { update('credential', event.target.value) }} /></Field>
748
+ <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>
749
+ </div>
750
+ </section>
751
+
752
+ <section className="dvt-panel"><div className="dvt-panel-title"><h3>{t('limits')}</h3></div><div className="dvt-form-grid">
753
+ <Field label={t('timeout')}><Input inputMode="numeric" value={draft.timeoutMs} onChange={(event) => { update('timeoutMs', event.target.value) }} /></Field>
754
+ <Field label={t('maxBytes')}><Input inputMode="numeric" value={draft.maxImageBytes} onChange={(event) => { update('maxImageBytes', event.target.value) }} /></Field>
755
+ <Field label={t('maxPixels')}><Input inputMode="numeric" value={draft.maxImagePixels} onChange={(event) => { update('maxImagePixels', event.target.value) }} /></Field>
756
+ <Field label={t('concurrency')}><Input inputMode="numeric" value={draft.concurrency} onChange={(event) => { update('concurrency', event.target.value) }} /></Field>
757
+ </div></section>
758
+
759
+ <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 ?? 'ready' : t('runtimeUnavailable')}</span></div><div className="dvt-form-grid">
760
+ <Field label={t('runtimeMode')}><select value={draft.runtimeMode} onChange={(event) => { update('runtimeMode', event.target.value as 'managed' | 'external') }}><option value="managed">managed</option><option value="external">external</option></select></Field>
761
+ {draft.runtimeMode === 'external' ? <Field label={t('toolkitPath')}><Input value={draft.toolkitPath} onChange={(event) => { update('toolkitPath', event.target.value) }} /></Field> : null}
762
+ <Field label={t('python')}><Input placeholder="python3" value={draft.python} onChange={(event) => { update('python', event.target.value) }} /></Field>
763
+ <Field label={t('allowedDirs')} hint={t('allowedDirsHint')}><textarea rows={3} value={draft.allowedDirs} onChange={(event) => { update('allowedDirs', event.target.value) }} /></Field>
764
+ </div>
765
+ {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>}
766
+ </section>
767
+
768
+ <div className="dvt-save-row"><Button variant="primary" disabled={!snapshot.writable || busy} onClick={save}>{state.action === 'save' ? t('saving') : t('save')}</Button><Button variant="outline" disabled={busy} onClick={() => { void controller.load() }}>{t('reload')}</Button></div>
769
+
770
+ <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(false) }}>{state.action === 'health' ? t('testing') : t('runHealth')}</Button><Button size="sm" variant="primary" disabled={busy || !snapshot.runtime.ready} onClick={() => { void controller.runHealth(true) }}>{state.action === 'connection' ? t('testing') : t('testConnection')}</Button></div></div>
771
+ {state.health === undefined ? <p className="dvt-muted">{t('notTested')}</p> : <div className="dvt-health-grid">{Object.entries(state.health.checks).map(([name, check]) => <div key={name} data-status={check.status}><span>{name}</span><strong>{check.status}</strong><p>{check.detail}</p></div>)}</div>}
772
+ </section>
773
+ </div>
774
+ )
775
+ }
776
+
777
+ const CSS = `
778
+ .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)}
779
+ .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}
780
+ .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}
781
+ .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)}
782
+ .dvt-settings{display:grid;gap:14px;max-width:900px;padding:8px 2px 32px;color:var(--dsw-alias-label-primary)}.dvt-settings-header{display:flex;justify-content:space-between;gap:20px;align-items:flex-start;padding:8px 2px}.dvt-settings-header h2{font-size:25px;letter-spacing:-.025em;margin:3px 0 6px}.dvt-settings-header 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;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;gap:12px}.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.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;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-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}
783
+ .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-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)}
784
+ .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}
785
+ @media(max-width:720px){.dvt-settings-header{display:grid}.dvt-release{width:auto}.dvt-form-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}}
786
+ `
787
+
788
+ function installStyles(): () => void {
789
+ const id = '@anionex/dsh-vision-toolkit/client'
790
+ const existing = document.querySelector<HTMLStyleElement>(`style[data-plugin-css="${id}"]`)
791
+ if (existing !== null) return () => {}
792
+ const style = document.createElement('style')
793
+ style.dataset.plugin = '@anionex/dsh-vision-toolkit'
794
+ style.dataset.pluginCss = id
795
+ style.textContent = CSS
796
+ document.head.appendChild(style)
797
+ return () => { style.remove() }
798
+ }
799
+
800
+ /** Required client services. The pasted-image codec attaches to either trigger-service generation after load. */
801
+ export const inject = ['slots', 'locale', 'remote', 'conversation', 'sessions']
802
+
803
+ /** Register dedicated Tool views and the Vision Settings section. */
804
+ export function apply(ctx: ClientContext): void {
805
+ ctx.effect(installStyles, 'dsh-vision-toolkit: styles')
806
+ ctx.effect(() => ctx.locale.register(NS, { en, zh }), 'dsh-vision-toolkit: locale')
807
+ installPasteImages(ctx)
808
+ const t = ctx.locale.bind(NS)
809
+ const injected = () => ({ t })
810
+ const entries: Array<[string, (props: ViewProps) => ReactNode]> = [
811
+ ['vision_ground', GroundView],
812
+ ['vision_detect', DetectView],
813
+ ['vision_trace', TraceView],
814
+ ['vision_pixel_diff', PixelDiffView],
815
+ ['vision_crop', ArtifactView],
816
+ ['vision_long_screenshot_ocr', ArtifactView],
817
+ ['vision_extract_foreground', ArtifactView],
818
+ ['vision_html_screenshot', ArtifactView],
819
+ ['vision_dominant_colors', PaletteView],
820
+ ]
821
+ ctx.slots.inject('tool.call.toolview', function* () {
822
+ for (const [key, component] of entries) {
823
+ yield ctx.slots.register({ name: 'tool.call.toolview', key, inject: injected }, component)
824
+ }
825
+ })
826
+
827
+ const controller = new VisionSettingsController()
828
+ ctx.effect(() => {
829
+ const refreshSettings = (namespace: string): void => {
830
+ if (namespace === 'vision-toolkit') controller.refreshIfLoaded()
831
+ }
832
+ const refreshCredential = (ref: string): void => {
833
+ const current = controller.snapshot().snapshot
834
+ if (current?.credential.ref === ref) controller.refreshIfLoaded()
835
+ }
836
+ const legacyRemote = ctx.remote as typeof ctx.remote & {
837
+ $on?: (event: string, listener: (value: string) => void) => () => void
838
+ }
839
+ const currentEvents = ctx as unknown as {
840
+ on(event: 'settings/changed', listener: (namespace: string) => void): () => void
841
+ on(event: 'credentials/changed', listener: (ref: string) => void): () => void
842
+ }
843
+ const disposers = typeof legacyRemote.$on === 'function'
844
+ ? [
845
+ legacyRemote.$on('settings/document-updated', refreshSettings),
846
+ legacyRemote.$on('credentials/updated', refreshCredential),
847
+ ]
848
+ : [
849
+ currentEvents.on('settings/changed', (namespace) => {
850
+ refreshSettings(namespace)
851
+ }),
852
+ currentEvents.on('credentials/changed', (ref) => {
853
+ refreshCredential(ref)
854
+ }),
855
+ ]
856
+ disposers.push(ctx.on('connection/reset', () => { controller.refreshIfLoaded() }))
857
+ return () => { for (const dispose of disposers) dispose() }
858
+ }, 'dsh-vision-toolkit: Settings invalidations')
859
+ ctx.slots.inject('settings.section', () => ctx.slots.register({
860
+ name: 'settings.section',
861
+ id: 'vision-toolkit',
862
+ order: 30,
863
+ label: () => t('nav'),
864
+ inject: () => ({ controller, t }),
865
+ }, SettingsSection))
866
+ }