@mengruo/dsh-vision-toolkit 0.1.3 → 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 (101) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +15 -70
  3. package/README.zh.md +15 -69
  4. package/docs/requirements-traceability/README.i18n.yaml +2 -2
  5. package/docs/requirements-traceability/README.md +1 -1
  6. package/docs/requirements-traceability/README.zh.md +1 -1
  7. package/lib/artifact-access.js +20 -2
  8. package/lib/artifact-access.js.map +1 -1
  9. package/lib/client.js +128 -12
  10. package/lib/client.js.map +1 -1
  11. package/lib/config.js +69 -0
  12. package/lib/config.js.map +1 -1
  13. package/lib/errors.js +25 -2
  14. package/lib/errors.js.map +1 -1
  15. package/lib/evidence-cache.js +2 -1
  16. package/lib/evidence-cache.js.map +1 -1
  17. package/lib/exposure.js +14 -1
  18. package/lib/exposure.js.map +1 -1
  19. package/lib/image-input-variants.js +22 -12
  20. package/lib/image-input-variants.js.map +1 -1
  21. package/lib/index.js +53 -6
  22. package/lib/index.js.map +1 -1
  23. package/lib/object-storage.js +141 -0
  24. package/lib/object-storage.js.map +1 -0
  25. package/lib/paste-images.js +67 -19
  26. package/lib/paste-images.js.map +1 -1
  27. package/lib/paths.js +214 -28
  28. package/lib/paths.js.map +1 -1
  29. package/lib/runtime-manager.js +76 -10
  30. package/lib/runtime-manager.js.map +1 -1
  31. package/lib/runtime.js +219 -38
  32. package/lib/runtime.js.map +1 -1
  33. package/lib/storage-history.js +154 -0
  34. package/lib/storage-history.js.map +1 -0
  35. package/lib/types/artifact-access.d.ts.map +1 -1
  36. package/lib/types/client/index.d.ts +45 -2
  37. package/lib/types/client/index.d.ts.map +1 -1
  38. package/lib/types/client/paste-images.d.ts +2 -0
  39. package/lib/types/client/paste-images.d.ts.map +1 -1
  40. package/lib/types/config.d.ts +55 -0
  41. package/lib/types/config.d.ts.map +1 -1
  42. package/lib/types/errors.d.ts +18 -2
  43. package/lib/types/errors.d.ts.map +1 -1
  44. package/lib/types/evidence-cache.d.ts +1 -1
  45. package/lib/types/evidence-cache.d.ts.map +1 -1
  46. package/lib/types/exposure.d.ts.map +1 -1
  47. package/lib/types/image-input-variants.d.ts +5 -3
  48. package/lib/types/image-input-variants.d.ts.map +1 -1
  49. package/lib/types/index.d.ts.map +1 -1
  50. package/lib/types/object-storage.d.ts +54 -0
  51. package/lib/types/object-storage.d.ts.map +1 -0
  52. package/lib/types/paste-images.d.ts +12 -4
  53. package/lib/types/paste-images.d.ts.map +1 -1
  54. package/lib/types/paths.d.ts +31 -5
  55. package/lib/types/paths.d.ts.map +1 -1
  56. package/lib/types/runtime-manager.d.ts +28 -4
  57. package/lib/types/runtime-manager.d.ts.map +1 -1
  58. package/lib/types/runtime.d.ts +31 -1
  59. package/lib/types/runtime.d.ts.map +1 -1
  60. package/lib/types/storage-history.d.ts +63 -0
  61. package/lib/types/storage-history.d.ts.map +1 -0
  62. package/lib/types/upstream.d.ts +1 -0
  63. package/lib/types/upstream.d.ts.map +1 -1
  64. package/lib/types/web.d.ts +7 -0
  65. package/lib/types/web.d.ts.map +1 -1
  66. package/lib/upstream.js +34 -8
  67. package/lib/upstream.js.map +1 -1
  68. package/lib/web.js +44 -9
  69. package/lib/web.js.map +1 -1
  70. package/package.json +3 -1
  71. package/src/artifact-access.ts +22 -2
  72. package/src/client/index.tsx +169 -9
  73. package/src/client/paste-images.tsx +14 -4
  74. package/src/config.ts +128 -0
  75. package/src/errors.ts +25 -2
  76. package/src/evidence-cache.ts +2 -1
  77. package/src/exposure.ts +16 -2
  78. package/src/image-input-variants.ts +21 -6
  79. package/src/index.ts +65 -6
  80. package/src/object-storage.ts +174 -0
  81. package/src/paste-images.ts +81 -19
  82. package/src/paths.ts +249 -28
  83. package/src/runtime-manager.ts +93 -10
  84. package/src/runtime.ts +215 -36
  85. package/src/storage-history.ts +172 -0
  86. package/src/upstream.ts +36 -7
  87. package/src/web.ts +54 -9
  88. package/vendor/agent-vision-toolkit/UPSTREAM_MANIFEST.json +11 -11
  89. package/vendor/agent-vision-toolkit/__pycache__/detect.cpython-314.pyc +0 -0
  90. package/vendor/agent-vision-toolkit/__pycache__/ground.cpython-314.pyc +0 -0
  91. package/vendor/agent-vision-toolkit/__pycache__/vision_client.cpython-314.pyc +0 -0
  92. package/vendor/agent-vision-toolkit/bin/__pycache__/glancecpython-314.pyc +0 -0
  93. package/vendor/agent-vision-toolkit/bin/glance +8 -1
  94. package/vendor/agent-vision-toolkit/detect.py +13 -7
  95. package/vendor/agent-vision-toolkit/ground.py +43 -18
  96. package/vendor/agent-vision-toolkit/tests/test_vision_client.py +88 -0
  97. package/vendor/agent-vision-toolkit/vision_client.py +84 -6
  98. package/assets/community-group-qr.png +0 -0
  99. package/assets/logo_aihubmix.png +0 -0
  100. package/assets/logo_eapi_dark.png +0 -0
  101. package/assets/wechat-reward.png +0 -0
@@ -31,6 +31,8 @@ const DEFAULT_USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKi
31
31
  const BUILT_IN_FREE_VISION_BASE_URL = 'https://vision.anionex.me/v1'
32
32
  const BUILT_IN_FREE_VISION_CREDENTIAL = 'ANIONEX_FREE_VISION'
33
33
  const BUILT_IN_FREE_VISION_MODEL = 'gemini-3.7-flash'
34
+ // Fixed credential reference holding the combined "accessKeyId:secretAccessKey".
35
+ const OBJECT_STORAGE_CREDENTIAL = 'VISION_OBJECT_STORAGE'
34
36
 
35
37
  const en = {
36
38
  nav: 'Vision',
@@ -42,6 +44,7 @@ const en = {
42
44
  providers: 'Vision providers',
43
45
  name: 'Label',
44
46
  enabled: 'Enabled',
47
+ disabled: 'Disabled',
45
48
  priority: 'Priority {index}',
46
49
  attempts: 'Attempts',
47
50
  attemptsHint: 'Total attempts against this provider before failing over to the next one.',
@@ -70,6 +73,10 @@ const en = {
70
73
  anthropicThinking: 'Anthropic thinking',
71
74
  anthropicThinkingHint: 'omit has the broadest compatibility. Use disabled or adaptive only when the selected model documents that mode; restore omit first after HTTP 400.',
72
75
  userAgent: 'User-Agent',
76
+ stream: 'Streaming',
77
+ streamHint: 'Request a streamed (SSE) completion instead of one JSON response. Use it for endpoints with weak non-streaming support or that time out on long outputs.',
78
+ uploadViaUrl: 'Transfer images via URL',
79
+ uploadViaUrlHint: 'Upload each image to the configured object storage and send the model a URL instead of base64. Requires object storage (below); region crops fall back to base64.',
73
80
  language: 'Output language',
74
81
  limits: 'Timeout and concurrency',
75
82
  timeout: 'Request timeout (ms)',
@@ -90,12 +97,29 @@ const en = {
90
97
  runtimeMode: 'Runtime mode',
91
98
  toolkitPath: 'Pinned checkout path',
92
99
  python: 'Python override',
100
+ storage: 'Local files',
101
+ storageDir: 'Default save directory',
102
+ storageDirHint: 'Leave blank to keep .dsh-vision-toolkit in each workspace. On POSIX systems, set an absolute shared root such as /tmp/dsh-vision-toolkit to store artifacts, pasted images, and caches in an automatically generated per-user, workspace-specific child directory. Windows shared roots remain disabled until their ACLs can be verified safely.',
93
103
  allowedDirs: 'Additional allowed directories',
94
104
  allowedDirsHint: 'One path per line. The session workspace is always allowed.',
105
+ objectStorage: 'Object storage',
106
+ objectStorageHint: 'S3-compatible storage used when a provider enables URL image transfer. The access key is stored as a DSH credential and never shown again after saving.',
107
+ storageEndpoint: 'Endpoint',
108
+ storageBucket: 'Bucket',
109
+ storageAccessKeyId: 'Access Key ID',
110
+ storageSecretAccessKey: 'Secret Access Key',
111
+ storagePublicBase: 'Public base URL (optional)',
112
+ storagePublicBaseHint: 'Custom domain or r2.dev URL for permanent public links. Leave blank to use temporary presigned URLs.',
113
+ testStorage: 'Test object storage',
114
+ testingStorage: 'Testing…',
115
+ storageKeyPair: 'Access Key ID and Secret Access Key must both be filled together.',
95
116
  save: 'Save and apply',
96
117
  saving: 'Validating runtime…',
97
118
  reload: 'Reload',
98
119
  saved: 'Settings validated and applied.',
120
+ unsavedChanges: 'Changed but not saved',
121
+ discardChanges: 'Discard changes',
122
+ noUnsavedChanges: 'Settings unchanged',
99
123
  readOnly: 'Service settings are read-only. A writable API key can still be saved.',
100
124
  configured: 'Configured',
101
125
  missing: 'Missing',
@@ -112,7 +136,7 @@ const en = {
112
136
  connectionHint: 'The API connection test only queries GET /models. The vision model test sends the bundled diagnostic image and verifies one real multimodal request.',
113
137
  saveBeforeTesting: 'Save service changes before testing the connection.',
114
138
  advanced: 'Advanced settings',
115
- advancedHint: 'Credential name, provider compatibility, output language, resource limits, runtime source, Python, and additional readable directories.',
139
+ advancedHint: 'Credential name, provider compatibility, output language, resource limits, default save directory, runtime source, Python, and additional readable directories.',
116
140
  imageInput: 'Image input',
117
141
  hiddenVariants: 'Transparent variant routing',
118
142
  hiddenVariantsLabel: 'Keep the original model names and enable images automatically',
@@ -255,6 +279,7 @@ const zh: Record<LocaleKey, string> = {
255
279
  providers: '视觉服务列表',
256
280
  name: '名称',
257
281
  enabled: '启用',
282
+ disabled: '禁用',
258
283
  priority: '优先级 {index}',
259
284
  attempts: '尝试次数',
260
285
  attemptsHint: '该服务在故障转移到下一个之前最多尝试的次数。',
@@ -283,6 +308,10 @@ const zh: Record<LocaleKey, string> = {
283
308
  anthropicThinking: 'Anthropic thinking',
284
309
  anthropicThinkingHint: 'omit 兼容性最好。仅当所选模型明确支持时使用 disabled 或 adaptive;遇到 HTTP 400 时先恢复 omit。',
285
310
  userAgent: 'User-Agent',
311
+ stream: '流式请求',
312
+ streamHint: '以流式(SSE)方式请求补全,而非一次性返回 JSON。适合对非流式支持不佳、长输出易连接超时的端点。',
313
+ uploadViaUrl: '通过 URL 传输图片',
314
+ uploadViaUrlHint: '把每张图片上传到下方配置的对象存储,将 URL 传给模型而不是 base64。需要先配置对象存储;使用 region 裁剪时回退为 base64。',
286
315
  language: '结果语言',
287
316
  limits: '超时与并发限制',
288
317
  timeout: '单次请求超时(毫秒)',
@@ -303,12 +332,29 @@ const zh: Record<LocaleKey, string> = {
303
332
  runtimeMode: '环境来源',
304
333
  toolkitPath: 'agent-vision-toolkit 目录',
305
334
  python: 'Python 解释器(可选)',
335
+ storage: '本地文件',
336
+ storageDir: '默认保存目录',
337
+ storageDirHint: '留空时继续在各工作区创建 .dsh-vision-toolkit。在 POSIX 系统上填写绝对路径共享根目录(例如 /tmp/dsh-vision-toolkit)后,产物、粘贴图片和缓存会保存到自动生成的当前用户、工作区专属子目录中。Windows 共享目录会保持禁用,直到能够安全校验 ACL 权限。',
306
338
  allowedDirs: '允许读取的其他目录',
307
339
  allowedDirsHint: '每行填写一个目录。当前会话的工作目录始终可以读取,无需重复填写。',
340
+ objectStorage: '对象存储',
341
+ objectStorageHint: '用于「通过 URL 传输图片」的 S3 兼容存储。访问密钥以 DSH 凭据保存,保存后不再显示。',
342
+ storageEndpoint: 'Endpoint',
343
+ storageBucket: 'Bucket',
344
+ storageAccessKeyId: 'Access Key ID',
345
+ storageSecretAccessKey: 'Secret Access Key',
346
+ storagePublicBase: '公网 URL 前缀(可选)',
347
+ storagePublicBaseHint: '自定义域名或 r2.dev 地址,用于生成永久公网链接。留空则使用临时预签名 URL。',
348
+ testStorage: '测试对象存储',
349
+ testingStorage: '测试中…',
350
+ storageKeyPair: 'Access Key ID 与 Secret Access Key 必须同时填写。',
308
351
  save: '保存设置',
309
352
  saving: '正在检查并应用…',
310
353
  reload: '重新加载',
311
354
  saved: '设置已保存并生效。',
355
+ unsavedChanges: '已更改但尚未保存',
356
+ discardChanges: '丢弃更改',
357
+ noUnsavedChanges: '设置项未变更',
312
358
  readOnly: '服务设置来自只读配置;如果 API 密钥可写,仍可在此保存密钥。',
313
359
  configured: '已就绪',
314
360
  missing: '未配置',
@@ -325,7 +371,7 @@ const zh: Record<LocaleKey, string> = {
325
371
  connectionHint: '“测试 API 连接”只请求 GET /models;“测试视觉模型”会发送插件自带的诊断图片,验证一次真实多模态调用。',
326
372
  saveBeforeTesting: '修改服务配置后,请先保存,再执行 API 或视觉模型测试。',
327
373
  advanced: '高级设置',
328
- advancedHint: '凭据名称、服务兼容参数、结果语言、资源限制、运行环境来源、Python 和额外可读目录。一般无需修改。',
374
+ advancedHint: '凭据名称、服务兼容参数、结果语言、资源限制、默认保存目录、运行环境来源、Python 和额外可读目录。一般无需修改。',
329
375
  imageInput: '图片输入',
330
376
  hiddenVariants: '透明变体路由',
331
377
  hiddenVariantsLabel: '保留原模型名并自动启用图片能力',
@@ -521,6 +567,8 @@ interface ProviderValue {
521
567
  protocol?: 'openai' | 'anthropic'
522
568
  anthropicThinking?: 'omit' | 'disabled' | 'adaptive'
523
569
  userAgent?: string
570
+ stream?: boolean
571
+ uploadViaUrl?: boolean
524
572
  t1Seconds?: number
525
573
  t2Seconds?: number
526
574
  maxImageBytes?: number
@@ -537,6 +585,8 @@ interface SettingsValue {
537
585
  protocol?: 'openai' | 'anthropic'
538
586
  anthropicThinking?: 'omit' | 'disabled' | 'adaptive'
539
587
  userAgent?: string
588
+ stream?: boolean
589
+ uploadViaUrl?: boolean
540
590
  }
541
591
  providers?: ProviderValue[]
542
592
  language?: 'zh' | 'en'
@@ -546,7 +596,15 @@ interface SettingsValue {
546
596
  maxImageBytes?: number
547
597
  maxImagePixels?: number
548
598
  concurrency?: number
599
+ objectStorage?: {
600
+ endpoint?: string
601
+ bucket?: string
602
+ credential?: string
603
+ publicBase?: string
604
+ }
549
605
  runtime?: { mode?: 'managed' | 'external'; agentVisionToolkitPath?: string; python?: string }
606
+ storageDir?: string
607
+ storageHistory?: string[]
550
608
  allowedDirs?: string[]
551
609
  imageInputVariants?: {
552
610
  enabled?: boolean
@@ -603,6 +661,7 @@ interface SettingsSnapshot {
603
661
  settings: { value: SettingsValue; revision: number; applies: 'live' }
604
662
  credential: { ref: string; configured: boolean; source?: string; writable: boolean }
605
663
  credentials: Array<{ ref: string; configured: boolean; source?: string; writable: boolean }>
664
+ objectStorageCredential: { ref: string; configured: boolean; writable: boolean }
606
665
  runtime: {
607
666
  ready: boolean
608
667
  generation: number
@@ -742,6 +801,15 @@ function VisionIcon({ kind = 'scan' }: { kind?: 'scan' | 'target' | 'layers' | '
742
801
  )
743
802
  }
744
803
 
804
+ function ReloadIcon() {
805
+ return (
806
+ <svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
807
+ <path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8" />
808
+ <path d="M21 3v5h-5" />
809
+ </svg>
810
+ )
811
+ }
812
+
745
813
  function ToolShell({
746
814
  block, title, summary, icon, children, t,
747
815
  }: {
@@ -980,7 +1048,8 @@ interface SettingsState {
980
1048
  providerHealth?: Record<number, HealthResult> | undefined
981
1049
  update?: PluginUpdateCheck | undefined
982
1050
  restart?: PluginUpdateResult | undefined
983
- action?: 'save' | 'health' | 'connection' | 'model' | 'check-update' | 'apply-update' | undefined
1051
+ storageTest?: { detail: string } | undefined
1052
+ action?: 'save' | 'health' | 'connection' | 'model' | 'check-update' | 'apply-update' | 'test-storage' | undefined
984
1053
  message?: string | undefined
985
1054
  error?: string | undefined
986
1055
  }
@@ -1115,6 +1184,20 @@ export class VisionSettingsController {
1115
1184
  }
1116
1185
  }
1117
1186
 
1187
+ async testStorage(): Promise<void> {
1188
+ this.set({ ...this.state, action: 'test-storage', error: undefined, message: undefined })
1189
+ try {
1190
+ const result = await apiRequest<{ detail: string }>({
1191
+ method: 'POST',
1192
+ headers: { 'Content-Type': 'application/json' },
1193
+ body: JSON.stringify({ action: 'test-storage' }),
1194
+ })
1195
+ this.set({ ...this.state, action: undefined, storageTest: result })
1196
+ } catch (error) {
1197
+ this.set({ ...this.state, action: undefined, error: error instanceof Error ? error.message : String(error) })
1198
+ }
1199
+ }
1200
+
1118
1201
  async checkUpdate(): Promise<void> {
1119
1202
  this.set({ ...this.state, action: 'check-update', error: undefined, message: undefined })
1120
1203
  try {
@@ -1163,6 +1246,8 @@ interface ProviderDraft {
1163
1246
  protocol: 'openai' | 'anthropic'
1164
1247
  anthropicThinking: 'omit' | 'disabled' | 'adaptive'
1165
1248
  userAgent: string
1249
+ stream: boolean
1250
+ uploadViaUrl: boolean
1166
1251
  t1Seconds: string
1167
1252
  t2Seconds: string
1168
1253
  maxImageBytes: string
@@ -1180,9 +1265,13 @@ interface Draft {
1180
1265
  maxImageBytes: string
1181
1266
  maxImagePixels: string
1182
1267
  concurrency: string
1268
+ storageEndpoint: string
1269
+ storageBucket: string
1270
+ storagePublicBase: string
1183
1271
  runtimeMode: 'managed' | 'external'
1184
1272
  toolkitPath: string
1185
1273
  python: string
1274
+ storageDir: string
1186
1275
  allowedDirs: string
1187
1276
  hiddenVariants: boolean
1188
1277
  variantEnabled: boolean
@@ -1222,6 +1311,8 @@ function emptyProviderDraft(defaults: ProviderDefaults): ProviderDraft {
1222
1311
  protocol: 'openai',
1223
1312
  anthropicThinking: 'omit',
1224
1313
  userAgent: DEFAULT_USER_AGENT,
1314
+ stream: false,
1315
+ uploadViaUrl: false,
1225
1316
  t1Seconds: '90',
1226
1317
  t2Seconds: '90',
1227
1318
  maxImageBytes: String(defaults.maxImageBytes),
@@ -1244,6 +1335,8 @@ function providerDraftOf(value: ProviderValue | undefined, fallback: ProviderVal
1244
1335
  protocol: source?.protocol ?? 'openai',
1245
1336
  anthropicThinking: source?.anthropicThinking ?? 'omit',
1246
1337
  userAgent: source?.userAgent ?? DEFAULT_USER_AGENT,
1338
+ stream: source?.stream === true,
1339
+ uploadViaUrl: source?.uploadViaUrl === true,
1247
1340
  t1Seconds: String(source?.t1Seconds ?? 90),
1248
1341
  t2Seconds: String(source?.t2Seconds ?? 90),
1249
1342
  maxImageBytes: String(source?.maxImageBytes ?? defaults.maxImageBytes),
@@ -1272,9 +1365,13 @@ function draftOf(value: SettingsValue): Draft {
1272
1365
  maxImageBytes: String(globalDefaults.maxImageBytes),
1273
1366
  maxImagePixels: String(globalDefaults.maxImagePixels),
1274
1367
  concurrency: String(globalDefaults.concurrency),
1368
+ storageEndpoint: value.objectStorage?.endpoint ?? '',
1369
+ storageBucket: value.objectStorage?.bucket ?? '',
1370
+ storagePublicBase: value.objectStorage?.publicBase ?? '',
1275
1371
  runtimeMode: value.runtime?.mode ?? 'managed',
1276
1372
  toolkitPath: value.runtime?.agentVisionToolkitPath ?? '',
1277
1373
  python: value.runtime?.python ?? '',
1374
+ storageDir: value.storageDir ?? '',
1278
1375
  allowedDirs: (value.allowedDirs ?? []).join('\n'),
1279
1376
  hiddenVariants: value.imageInputVariants?.hidden ?? true,
1280
1377
  variantEnabled: value.imageInputVariants?.enabled ?? true,
@@ -1310,6 +1407,8 @@ function valueOf(draft: Draft, t: Translate): SettingsValue {
1310
1407
  protocol: provider.protocol,
1311
1408
  anthropicThinking: provider.anthropicThinking,
1312
1409
  ...(provider.userAgent.trim() === DEFAULT_USER_AGENT ? {} : { userAgent: provider.userAgent.trim() }),
1410
+ ...(provider.stream ? { stream: true } : {}),
1411
+ ...(provider.uploadViaUrl ? { uploadViaUrl: true } : {}),
1313
1412
  ...(provider.t1Seconds.trim().length === 0 ? {} : { t1Seconds: positiveInteger(provider.t1Seconds, t('t1'), t) }),
1314
1413
  ...(provider.t2Seconds.trim().length === 0 ? {} : { t2Seconds: positiveInteger(provider.t2Seconds, t('t2'), t) }),
1315
1414
  ...(provider.maxImageBytes.trim().length === 0 ? {} : { maxImageBytes: positiveInteger(provider.maxImageBytes, t('maxBytes'), t) }),
@@ -1326,6 +1425,8 @@ function valueOf(draft: Draft, t: Translate): SettingsValue {
1326
1425
  protocol: primary?.protocol ?? 'openai',
1327
1426
  anthropicThinking: primary?.anthropicThinking ?? 'omit',
1328
1427
  userAgent: primary?.userAgent.trim() || DEFAULT_USER_AGENT,
1428
+ stream: primary?.stream === true,
1429
+ uploadViaUrl: primary?.uploadViaUrl === true,
1329
1430
  },
1330
1431
  providers,
1331
1432
  language: draft.language,
@@ -1335,11 +1436,22 @@ function valueOf(draft: Draft, t: Translate): SettingsValue {
1335
1436
  maxImageBytes: positiveInteger(draft.maxImageBytes, t('maxBytes'), t),
1336
1437
  maxImagePixels: positiveInteger(draft.maxImagePixels, t('maxPixels'), t),
1337
1438
  concurrency: positiveInteger(draft.concurrency, t('concurrency'), t),
1439
+ ...(draft.storageEndpoint.trim().length === 0 && draft.storageBucket.trim().length === 0
1440
+ ? {}
1441
+ : {
1442
+ objectStorage: {
1443
+ endpoint: draft.storageEndpoint.trim(),
1444
+ bucket: draft.storageBucket.trim(),
1445
+ credential: OBJECT_STORAGE_CREDENTIAL,
1446
+ ...(draft.storagePublicBase.trim().length === 0 ? {} : { publicBase: draft.storagePublicBase.trim() }),
1447
+ },
1448
+ }),
1338
1449
  runtime: {
1339
1450
  mode: draft.runtimeMode,
1340
1451
  ...(draft.runtimeMode === 'external' ? { agentVisionToolkitPath: draft.toolkitPath.trim() } : {}),
1341
1452
  ...(draft.python.trim().length === 0 ? {} : { python: draft.python.trim() }),
1342
1453
  },
1454
+ ...(draft.storageDir.trim().length === 0 ? {} : { storageDir: draft.storageDir.trim() }),
1343
1455
  allowedDirs: draft.allowedDirs.split(/\r?\n/).map(entry => entry.trim()).filter(Boolean),
1344
1456
  imageInputVariants: {
1345
1457
  ...(draft.variantEnabled ? {} : { enabled: false }),
@@ -1463,6 +1575,7 @@ function LoadedSettings({ controller, t }: SettingsInjected) {
1463
1575
  const [draft, setDraft] = useState<Draft | undefined>(undefined)
1464
1576
  const [selectedIndex, setSelectedIndex] = useState(0)
1465
1577
  const [keys, setKeys] = useState<Record<string, string>>({})
1578
+ const [storageKeys, setStorageKeys] = useState<{ accessKeyId: string; secretAccessKey: string }>({ accessKeyId: '', secretAccessKey: '' })
1466
1579
  const [draftError, setDraftError] = useState<string | undefined>(undefined)
1467
1580
  const [copiedCommand, setCopiedCommand] = useState(false)
1468
1581
 
@@ -1571,9 +1684,18 @@ function LoadedSettings({ controller, t }: SettingsInjected) {
1571
1684
  return
1572
1685
  }
1573
1686
  }
1687
+ const accessKeyId = storageKeys.accessKeyId.trim()
1688
+ const secretAccessKey = storageKeys.secretAccessKey.trim()
1689
+ if ((accessKeyId.length === 0) !== (secretAccessKey.length === 0)) {
1690
+ setDraftError(t('storageKeyPair'))
1691
+ return
1692
+ }
1574
1693
  const credentials = Object.entries(keys)
1575
1694
  .filter(([, value]) => value.trim().length > 0)
1576
1695
  .map(([ref, value]) => ({ ref, value: value.trim() }))
1696
+ if (accessKeyId.length > 0 && (draft.storageEndpoint.trim().length > 0 || draft.storageBucket.trim().length > 0)) {
1697
+ credentials.push({ ref: OBJECT_STORAGE_CREDENTIAL, value: `${accessKeyId}:${secretAccessKey}` })
1698
+ }
1577
1699
  const savedRefs = new Set(credentials.map(credential => credential.ref))
1578
1700
  setDraftError(undefined)
1579
1701
  void controller.save(
@@ -1584,12 +1706,19 @@ function LoadedSettings({ controller, t }: SettingsInjected) {
1584
1706
  ).then(saved => {
1585
1707
  if (saved) {
1586
1708
  setKeys(current => Object.fromEntries(Object.entries(current).filter(([ref]) => !savedRefs.has(ref))))
1709
+ if (savedRefs.has(OBJECT_STORAGE_CREDENTIAL)) setStorageKeys({ accessKeyId: '', secretAccessKey: '' })
1587
1710
  }
1588
1711
  })
1589
1712
  } catch (error) {
1590
1713
  setDraftError(error instanceof Error ? error.message : String(error))
1591
1714
  }
1592
1715
  }
1716
+ const discard = (): void => {
1717
+ setDraft(draftOf(snapshot.settings.value))
1718
+ setKeys({})
1719
+ setStorageKeys({ accessKeyId: '', secretAccessKey: '' })
1720
+ setDraftError(undefined)
1721
+ }
1593
1722
  const busy = state.action !== undefined
1594
1723
  const selectedProvider = draft.providers[selectedIndex]
1595
1724
  const providerCredentials = snapshot.credentials ?? []
@@ -1597,14 +1726,18 @@ function LoadedSettings({ controller, t }: SettingsInjected) {
1597
1726
  const selectedKey = selectedProvider === undefined ? '' : (keys[selectedProvider.credential] ?? '')
1598
1727
  const selectedKeyLocked = selectedCredential !== undefined && !selectedCredential.writable
1599
1728
  const selectedBuiltInFree = selectedCredential?.source === 'built-in-free'
1600
- const canSave = snapshot.writable || Object.values(keys).some(value => value.trim().length > 0)
1729
+ const objectStorageCredential = snapshot.objectStorageCredential ?? { ref: '', configured: false, writable: false }
1730
+ const canSave = snapshot.writable || Object.values(keys).some(value => value.trim().length > 0) || storageKeys.accessKeyId.trim().length > 0 || storageKeys.secretAccessKey.trim().length > 0
1601
1731
  const runtimeErrorTitle = snapshot.runtime.ready ? t('runtimeCandidateRejected') : t('runtimeUnavailable')
1602
1732
  const pluginUpdate = state.update
1603
1733
  const updateCapability = pluginUpdate ?? snapshot.release.update
1604
1734
  const latestVersion = pluginUpdate?.latestVersion
1605
1735
  const updateReason = updateCapability.reason === undefined ? undefined : t(UPDATE_REASON_KEYS[updateCapability.reason])
1606
1736
  const updateCheckSupported = updateCapability.checkSupported ?? updateCapability.supported
1607
- const updateHasUnsavedChanges = Object.values(keys).some(value => value.trim().length > 0) || settingsDraftChanged(draft, snapshot.settings.value, t)
1737
+ const updateHasUnsavedChanges = Object.values(keys).some(value => value.length > 0)
1738
+ || storageKeys.accessKeyId.length > 0
1739
+ || storageKeys.secretAccessKey.length > 0
1740
+ || settingsDraftChanged(draft, snapshot.settings.value, t)
1608
1741
  const manualUpdateProfile = updateCapability.profile ?? 'web'
1609
1742
  const manualUpdateCommand = `dsh plugin --profile ${manualUpdateProfile} add @mengruo/dsh-vision-toolkit@latest --registry=https://registry.npmjs.org/`
1610
1743
  const copyManualUpdate = (): void => {
@@ -1624,6 +1757,18 @@ function LoadedSettings({ controller, t }: SettingsInjected) {
1624
1757
 
1625
1758
  return (
1626
1759
  <div className="dvt-settings">
1760
+ <div className="dvt-save-dock" data-dirty={updateHasUnsavedChanges ? 'true' : undefined}>
1761
+ <button type="button" className="dvt-save-dock-reload" aria-label={t('reload')} title={t('reload')} disabled={busy} onClick={() => { void controller.load() }}><ReloadIcon /></button>
1762
+ {updateHasUnsavedChanges ? (
1763
+ <>
1764
+ <span className="dvt-save-dock-text">{t('unsavedChanges')}</span>
1765
+ <Button size="sm" variant="outline" disabled={busy} onClick={discard}>{t('discardChanges')}</Button>
1766
+ <Button size="sm" variant="primary" disabled={!canSave || busy} onClick={save}>{state.action === 'save' ? t('saving') : t('save')}</Button>
1767
+ </>
1768
+ ) : (
1769
+ <span className="dvt-save-dock-text">{t('noUnsavedChanges')}</span>
1770
+ )}
1771
+ </div>
1627
1772
  <div className="dvt-alert notice">{t('externalNotice')}</div>
1628
1773
  {!snapshot.writable ? <div className="dvt-alert warning">{t('readOnly')}</div> : null}
1629
1774
  {draftError === undefined ? null : <div className="dvt-alert error">{draftError}</div>}
@@ -1656,6 +1801,8 @@ function LoadedSettings({ controller, t }: SettingsInjected) {
1656
1801
  <Field label={t('protocol')}><select aria-label={t('protocol')} disabled={!snapshot.writable || busy} value={selectedProvider.protocol} onChange={(event) => { updateProvider(selectedIndex, 'protocol', event.target.value as 'openai' | 'anthropic') }}><option value="openai">OpenAI Chat Completions</option><option value="anthropic">Anthropic Messages</option></select></Field>
1657
1802
  {selectedProvider.protocol === 'anthropic' ? <Field label={t('anthropicThinking')} hint={t('anthropicThinkingHint')}><select aria-label={t('anthropicThinking')} value={selectedProvider.anthropicThinking} onChange={(event) => { updateProvider(selectedIndex, '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}
1658
1803
  <Field label={t('userAgent')}><Input aria-label={t('userAgent')} value={selectedProvider.userAgent} onChange={(event) => { updateProvider(selectedIndex, 'userAgent', event.target.value) }} /></Field>
1804
+ <Field label={t('stream')} hint={t('streamHint')}><select aria-label={t('stream')} disabled={!snapshot.writable || busy} value={selectedProvider.stream ? 'enabled' : 'disabled'} onChange={(event) => { updateProvider(selectedIndex, 'stream', event.target.value === 'enabled') }}><option value="disabled">{t('disabled')}</option><option value="enabled">{t('enabled')}</option></select></Field>
1805
+ <Field label={t('uploadViaUrl')} hint={t('uploadViaUrlHint')}><select aria-label={t('uploadViaUrl')} disabled={!snapshot.writable || busy} value={selectedProvider.uploadViaUrl ? 'enabled' : 'disabled'} onChange={(event) => { updateProvider(selectedIndex, 'uploadViaUrl', event.target.value === 'enabled') }}><option value="disabled">{t('disabled')}</option><option value="enabled">{t('enabled')}</option></select></Field>
1659
1806
  <Field label={t('t1')} hint={t('t1Hint')}><Input aria-label={t('t1')} inputMode="numeric" value={selectedProvider.t1Seconds} onChange={(event) => { updateProvider(selectedIndex, 't1Seconds', event.target.value) }} /></Field>
1660
1807
  <Field label={t('t2')} hint={t('t2Hint')}><Input aria-label={t('t2')} inputMode="numeric" value={selectedProvider.t2Seconds} onChange={(event) => { updateProvider(selectedIndex, 't2Seconds', event.target.value) }} /></Field>
1661
1808
  <Field label={t('maxBytes')}><Input aria-label={t('maxBytes')} inputMode="numeric" value={selectedProvider.maxImageBytes} onChange={(event) => { updateProvider(selectedIndex, 'maxImageBytes', event.target.value) }} /></Field>
@@ -1677,8 +1824,6 @@ function LoadedSettings({ controller, t }: SettingsInjected) {
1677
1824
  )}
1678
1825
  </section>
1679
1826
 
1680
- <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>
1681
-
1682
1827
  <section className="dvt-panel"><div className="dvt-panel-title"><div><h3>{t('prioritySort')}</h3><p>{t('prioritySortHint')}</p></div></div>
1683
1828
  <div className="dvt-sorter">
1684
1829
  {draft.providers.map((provider, index) => (
@@ -1742,11 +1887,25 @@ function LoadedSettings({ controller, t }: SettingsInjected) {
1742
1887
 
1743
1888
  <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>
1744
1889
 
1890
+ <section className="dvt-panel"><div className="dvt-panel-title"><h3>{t('storage')}</h3></div><div className="dvt-form-grid">
1891
+ <Field label={t('storageDir')} hint={t('storageDirHint')}><Input aria-label={t('storageDir')} placeholder="/tmp/dsh-vision-toolkit" value={draft.storageDir} onChange={(event) => { update('storageDir', event.target.value) }} /></Field>
1892
+ <Field label={t('allowedDirs')} hint={t('allowedDirsHint')}><textarea rows={3} value={draft.allowedDirs} onChange={(event) => { update('allowedDirs', event.target.value) }} /></Field>
1893
+ </div></section>
1894
+
1895
+ <section className="dvt-panel"><div className="dvt-panel-title"><div><h3>{t('objectStorage')}</h3><p>{t('objectStorageHint')}</p></div><div className="dvt-actions"><Button size="sm" variant="outline" disabled={busy || !snapshot.runtime.ready} onClick={() => { void controller.testStorage() }}>{state.action === 'test-storage' ? t('testingStorage') : t('testStorage')}</Button></div></div><div className="dvt-form-grid">
1896
+ <Field label={t('storageEndpoint')}><Input aria-label={t('storageEndpoint')} disabled={!snapshot.writable || busy} value={draft.storageEndpoint} onChange={(event) => { update('storageEndpoint', event.target.value) }} placeholder="https://<account>.r2.cloudflarestorage.com" /></Field>
1897
+ <Field label={t('storageBucket')}><Input aria-label={t('storageBucket')} disabled={!snapshot.writable || busy} value={draft.storageBucket} onChange={(event) => { update('storageBucket', event.target.value) }} /></Field>
1898
+ <Field label={t('storageAccessKeyId')}><Input aria-label={t('storageAccessKeyId')} type="password" autoComplete="new-password" disabled={busy || !snapshot.writable} placeholder={objectStorageCredential.configured ? t('apiKeyPlaceholderConfigured') : t('apiKeyPlaceholderMissing')} value={storageKeys.accessKeyId} onChange={(event) => { setStorageKeys(current => ({ ...current, accessKeyId: event.target.value })); setDraftError(undefined) }} /></Field>
1899
+ <Field label={t('storageSecretAccessKey')}><Input aria-label={t('storageSecretAccessKey')} type="password" autoComplete="new-password" disabled={busy || !snapshot.writable} placeholder={objectStorageCredential.configured ? t('apiKeyPlaceholderConfigured') : t('apiKeyPlaceholderMissing')} value={storageKeys.secretAccessKey} onChange={(event) => { setStorageKeys(current => ({ ...current, secretAccessKey: event.target.value })); setDraftError(undefined) }} /></Field>
1900
+ <Field label={t('storagePublicBase')} hint={t('storagePublicBaseHint')}><Input aria-label={t('storagePublicBase')} disabled={!snapshot.writable || busy} value={draft.storagePublicBase} onChange={(event) => { update('storagePublicBase', event.target.value) }} placeholder="https://cdn.example.com" /></Field>
1901
+ </div>
1902
+ {state.storageTest === undefined ? null : <div className="dvt-provider-test-result"><div data-status="ok"><span>{t('objectStorage')}</span><strong>{t('statusOk')}</strong><p>{state.storageTest.detail}</p></div></div>}
1903
+ </section>
1904
+
1745
1905
  <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">
1746
1906
  <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>
1747
1907
  {draft.runtimeMode === 'external' ? <Field label={t('toolkitPath')}><Input value={draft.toolkitPath} onChange={(event) => { update('toolkitPath', event.target.value) }} /></Field> : null}
1748
1908
  <Field label={t('python')}><Input placeholder="python3" value={draft.python} onChange={(event) => { update('python', event.target.value) }} /></Field>
1749
- <Field label={t('allowedDirs')} hint={t('allowedDirsHint')}><textarea rows={3} value={draft.allowedDirs} onChange={(event) => { update('allowedDirs', event.target.value) }} /></Field>
1750
1909
  </div>
1751
1910
  {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>}
1752
1911
  </section>
@@ -1767,11 +1926,12 @@ const CSS = `
1767
1926
  .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}
1768
1927
  .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}
1769
1928
  .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)}
1770
- .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}
1929
+ .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:flex;flex-direction:column;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-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}
1771
1930
  .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}
1772
1931
  .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)}
1773
1932
  .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}
1774
1933
  .dvt-provider-card{border:1px solid var(--dsw-alias-border-l1);border-radius:10px;padding:9px 10px;display:grid;gap:8px;background:var(--dsw-alias-bg-layer-2)}.dvt-provider-head{display:flex;align-items:center;gap:10px}.dvt-provider-priority{margin-left:auto;font-size:11px;color:var(--dsw-alias-label-secondary);white-space:nowrap}.dvt-provider-actions{display:flex;gap:4px}.dvt-provider-actions button{width:26px;height:26px;display:grid;place-items:center;border:1px solid var(--dsw-alias-border-l1);border-radius:7px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);cursor:pointer;font-size:14px;line-height:1}.dvt-provider-actions button:hover{background:var(--dsw-alias-interactive-bg-hover)}.dvt-provider-actions button:disabled{opacity:.35;cursor:default}.dvt-provider-select{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.dvt-provider-select select{flex:1;min-width:180px;height:36px;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:0 10px}.dvt-provider-tests{display:flex;align-items:center;gap:7px;flex-wrap:wrap}.dvt-provider-test-result{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:8px}.dvt-provider-test-result>div{padding:9px 10px;border-radius:9px;background:var(--dsw-alias-bg-layer-2);border-left:3px solid var(--dsw-alias-border-l4)}.dvt-provider-test-result>div[data-status=ok]{border-left-color:var(--dsw-alias-state-success-primary)}.dvt-provider-test-result>div[data-status=warning],.dvt-provider-test-result>div[data-status=not_tested]{border-left-color:var(--dsw-alias-state-warn-primary)}.dvt-provider-test-result>div[data-status=error]{border-left-color:var(--dsw-alias-state-error-primary)}.dvt-provider-test-result span{font-size:10px;text-transform:capitalize}.dvt-provider-test-result strong{float:right;font-size:9px;text-transform:uppercase;color:var(--dsw-alias-label-secondary)}.dvt-provider-test-result p{clear:both;margin:5px 0 0;font-size:10px;line-height:1.4;color:var(--dsw-alias-label-secondary)}.dvt-sorter{display:grid;gap:6px}.dvt-sorter-row{display:flex;align-items:center;gap:10px;padding:6px 8px;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;background:var(--dsw-alias-bg-layer-2)}.dvt-sorter-index{flex:none;width:24px;height:24px;display:grid;place-items:center;border-radius:6px;background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 12%,transparent);color:var(--dsw-alias-state-business-primary);font-size:11px;font-weight:700}.dvt-sorter-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px}
1934
+ .dvt-save-dock{position:sticky;top:8px;align-self:flex-end;z-index:50;display:flex;align-items:center;gap:10px;max-width:calc(100% - 16px);padding:11px 14px;border:1px solid var(--dsw-alias-border-l1);border-radius:12px;background:var(--dsw-alias-bg-layer-1);box-shadow:var(--dsw-shadow-lv1)}.dvt-save-dock[data-dirty]{border-color:color-mix(in srgb,var(--dsw-alias-state-warn-primary) 45%,var(--dsw-alias-border-l1))}.dvt-save-dock-text{font-size:12px;color:var(--dsw-alias-label-secondary)}.dvt-save-dock[data-dirty] .dvt-save-dock-text{color:var(--dsw-alias-state-warn-label);font-weight:600}.dvt-save-dock-reload{flex:none;display:grid;place-items:center;width:26px;height:26px;border:1px solid transparent;border-radius:7px;background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;padding:0}.dvt-save-dock-reload:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}.dvt-save-dock-reload:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:1px}.dvt-save-dock-reload:disabled{opacity:.4;cursor:default}
1775
1935
  @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}}
1776
1936
  `
1777
1937
 
@@ -58,6 +58,8 @@ interface PasteOccurrence {
58
58
  source: string
59
59
  ref: string
60
60
  offset: number
61
+ /** DSH rc.8+ stores the full @label text; older releases used one placeholder. */
62
+ length?: number
61
63
  label: string
62
64
  }
63
65
 
@@ -166,6 +168,10 @@ function pasteLabel(file: File, index: number): string {
166
168
  return file.name.trim() || `clipboard-image-${index + 1}`
167
169
  }
168
170
 
171
+ function occurrenceEnd(occurrence: PasteOccurrence): number {
172
+ return occurrence.offset + (occurrence.length ?? 1)
173
+ }
174
+
169
175
  /** Owns browser File objects until DSH serializes the corresponding text references. */
170
176
  export class PasteImageController {
171
177
  private readonly records = new Map<string, PasteRecord>()
@@ -241,6 +247,7 @@ export class PasteImageController {
241
247
  if (before !== '' && !/\s$/u.test(before)) cursor = this.insertText(input, ' ', cursor)
242
248
  for (const [index, file] of files.entries()) {
243
249
  const ref = id()
250
+ const label = pasteLabel(file, index)
244
251
  const record: PasteRecord = { ref, file, batch, status: 'ready' }
245
252
  batch.records.push(record)
246
253
  this.records.set(ref, record)
@@ -248,11 +255,14 @@ export class PasteImageController {
248
255
  const accepted = input.insertReference({
249
256
  source: SOURCE,
250
257
  ref,
251
- label: pasteLabel(file, index),
252
- clipboardText: `[pasted image: ${pasteLabel(file, index)}]`,
258
+ label,
259
+ clipboardText: `[pasted image: ${label}]`,
253
260
  }, { start: cursor, end: cursor, draftRev: snapshot.draftRev })
254
261
  if (!accepted) throw new Error('The composer changed before pasted images could be inserted')
255
- cursor += 1
262
+ const inserted = input.state.getSnapshot().occurrences.find(occurrence =>
263
+ occurrence.source === SOURCE && occurrence.ref === ref)
264
+ if (inserted === undefined) throw new Error('The pasted image reference was not present after insertion')
265
+ cursor = occurrenceEnd(inserted)
256
266
  const hasNext = index + 1 < files.length
257
267
  const suffix = input.state.getSnapshot().draft.slice(cursor)
258
268
  if (hasNext || (suffix !== '' && !/^\s/u.test(suffix))) cursor = this.insertText(input, ' ', cursor)
@@ -594,7 +604,7 @@ export class PasteImageController {
594
604
  insertText: (text: string, span: { start: number; end: number; draftRev: number }) => boolean
595
605
  }).insertText('', {
596
606
  start: current.offset,
597
- end: current.offset + 1,
607
+ end: occurrenceEnd(current),
598
608
  draftRev: snapshot.draftRev,
599
609
  })
600
610
  if (!accepted) return