@mengruo/dsh-vision-toolkit 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +17 -42
  3. package/README.zh.md +16 -41
  4. package/assets/logo_eapi_dark.png +0 -0
  5. package/docs/aihubmix-gemini-vision.i18n.yaml +2 -2
  6. package/docs/aihubmix-gemini-vision.md +2 -2
  7. package/docs/aihubmix-gemini-vision.zh.md +2 -2
  8. package/lib/client.js +246 -68
  9. package/lib/client.js.map +1 -1
  10. package/lib/config.js +143 -24
  11. package/lib/config.js.map +1 -1
  12. package/lib/evidence-cache.js +14 -0
  13. package/lib/evidence-cache.js.map +1 -1
  14. package/lib/image-input-variants.js +39 -27
  15. package/lib/image-input-variants.js.map +1 -1
  16. package/lib/runtime-install.js +204 -19
  17. package/lib/runtime-install.js.map +1 -1
  18. package/lib/runtime.js +310 -154
  19. package/lib/runtime.js.map +1 -1
  20. package/lib/tools.js +1 -0
  21. package/lib/tools.js.map +1 -1
  22. package/lib/types/client/index.d.ts +45 -3
  23. package/lib/types/client/index.d.ts.map +1 -1
  24. package/lib/types/config.d.ts +53 -0
  25. package/lib/types/config.d.ts.map +1 -1
  26. package/lib/types/evidence-cache.d.ts.map +1 -1
  27. package/lib/types/image-input-variants.d.ts +1 -6
  28. package/lib/types/image-input-variants.d.ts.map +1 -1
  29. package/lib/types/runtime-install.d.ts +21 -0
  30. package/lib/types/runtime-install.d.ts.map +1 -1
  31. package/lib/types/runtime.d.ts +45 -10
  32. package/lib/types/runtime.d.ts.map +1 -1
  33. package/lib/types/tools.d.ts.map +1 -1
  34. package/lib/types/upstream.d.ts +3 -0
  35. package/lib/types/upstream.d.ts.map +1 -1
  36. package/lib/types/web.d.ts +8 -0
  37. package/lib/types/web.d.ts.map +1 -1
  38. package/lib/upstream.js +11 -6
  39. package/lib/upstream.js.map +1 -1
  40. package/lib/web.js +50 -7
  41. package/lib/web.js.map +1 -1
  42. package/package.json +1 -1
  43. package/src/client/index.tsx +331 -88
  44. package/src/config.ts +210 -28
  45. package/src/evidence-cache.ts +14 -0
  46. package/src/image-input-variants.ts +39 -27
  47. package/src/runtime-install.ts +231 -20
  48. package/src/runtime.ts +333 -180
  49. package/src/tools.ts +1 -0
  50. package/src/upstream.ts +15 -8
  51. package/src/web.ts +67 -8
@@ -31,8 +31,6 @@ 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
- const AIHUBMIX_TUTORIAL_URL_EN = 'https://github.com/Anionex/dsh-vision-toolkit/blob/main/docs/aihubmix-gemini-vision.md'
35
- const AIHUBMIX_TUTORIAL_URL_ZH = 'https://github.com/Anionex/dsh-vision-toolkit/blob/main/docs/aihubmix-gemini-vision.zh.md'
36
34
 
37
35
  const en = {
38
36
  nav: 'Vision',
@@ -40,7 +38,22 @@ const en = {
40
38
  settingsIntro: 'Configure the pinned visual engineering runtime, its external vision endpoint, and local safety limits.',
41
39
  externalNotice: 'Remote tools send the selected image bytes to the configured external vision API. Local crop, trace, pixel diff, palette, foreground extraction, and HTML rendering do not upload images.',
42
40
  provider: 'Vision service',
43
- providerHint: 'Choose the API protocol, then provide the service address, model, and API key used by online vision features.',
41
+ providerHint: 'Add multiple API endpoints and order them by failover priority. A failed or rate-limited request automatically moves to the next provider; when every provider fails, the request reports an error.',
42
+ providers: 'Vision providers',
43
+ name: 'Label',
44
+ enabled: 'Enabled',
45
+ priority: 'Priority {index}',
46
+ attempts: 'Attempts',
47
+ attemptsHint: 'Total attempts against this provider before failing over to the next one.',
48
+ addProvider: 'Add provider',
49
+ moveUp: 'Move up',
50
+ moveDown: 'Move down',
51
+ removeProvider: 'Remove',
52
+ prioritySort: 'Provider priority',
53
+ prioritySortHint: 'Order the providers by failover priority. The top one is tried first; request order here is the priority order.',
54
+ runtimeCheck: 'Runtime check',
55
+ runtimeCheckHint: 'Checks the local Python environment (with its libraries) and the browser used for HTML screenshots.',
56
+ apiKeyBuiltInFree: 'The built-in free provider needs no user key.',
44
57
  aihubmixTutorial: 'Need an AIHubMix key for free Gemini 3.7 Flash vision? Follow the signup guide →',
45
58
  baseUrl: 'Base URL',
46
59
  apiKey: 'API key',
@@ -228,7 +241,22 @@ const zh: Record<LocaleKey, string> = {
228
241
  settingsIntro: '在这里设置视觉模型服务、工具运行环境,以及图片和文件的本地访问范围。',
229
242
  externalNotice: '使用图像理解、目标定位、界面检测或文字识别等在线功能时,所选图片会发送到下方配置的视觉服务。图片裁剪、轮廓描摹、像素对比、主色提取、前景提取和网页截图均在本机完成,不会上传图片。',
230
243
  provider: '在线视觉服务',
231
- providerHint: '选择接口协议后,填写在线视觉功能使用的 API 地址、模型名称和 API 密钥。',
244
+ providerHint: '添加多个 API 并按故障转移优先级排序;某个 API 失败或限流后自动切换到下一个,全部失败才返回错误。',
245
+ providers: '视觉服务列表',
246
+ name: '名称',
247
+ enabled: '启用',
248
+ priority: '优先级 {index}',
249
+ attempts: '尝试次数',
250
+ attemptsHint: '该服务在故障转移到下一个之前最多尝试的次数。',
251
+ addProvider: '添加服务',
252
+ moveUp: '上移',
253
+ moveDown: '下移',
254
+ removeProvider: '移除',
255
+ prioritySort: '服务优先级',
256
+ prioritySortHint: '按故障转移优先级排列服务;排在最上面的最先尝试,这里的顺序就是优先级顺序。',
257
+ runtimeCheck: '运行检查',
258
+ runtimeCheckHint: '检查本地 Python 环境(含依赖库)和用于网页截图的浏览器。',
259
+ apiKeyBuiltInFree: '内置免费服务无需填写密钥。',
232
260
  aihubmixTutorial: '想申请 AIHubMix Key,并用免费 Gemini 3.7 Flash 识图?看这篇图文教程 →',
233
261
  baseUrl: 'API 地址',
234
262
  apiKey: 'API 密钥',
@@ -463,6 +491,23 @@ interface HealthResult {
463
491
  modelTested: boolean
464
492
  }
465
493
 
494
+ interface ProviderValue {
495
+ id?: string
496
+ name?: string
497
+ enabled?: boolean
498
+ baseUrl?: string
499
+ credential?: string
500
+ model?: string
501
+ protocol?: 'openai' | 'anthropic'
502
+ anthropicThinking?: 'omit' | 'disabled' | 'adaptive'
503
+ userAgent?: string
504
+ timeoutMs?: number
505
+ maxImageBytes?: number
506
+ maxImagePixels?: number
507
+ concurrency?: number
508
+ attempts?: number
509
+ }
510
+
466
511
  interface SettingsValue {
467
512
  provider?: {
468
513
  baseUrl?: string
@@ -472,6 +517,7 @@ interface SettingsValue {
472
517
  anthropicThinking?: 'omit' | 'disabled' | 'adaptive'
473
518
  userAgent?: string
474
519
  }
520
+ providers?: ProviderValue[]
475
521
  language?: 'zh' | 'en'
476
522
  timeoutMs?: number
477
523
  maxImageBytes?: number
@@ -533,6 +579,7 @@ interface SettingsSnapshot {
533
579
  writable: boolean
534
580
  settings: { value: SettingsValue; revision: number; applies: 'live' }
535
581
  credential: { ref: string; configured: boolean; source?: string; writable: boolean }
582
+ credentials: Array<{ ref: string; configured: boolean; source?: string; writable: boolean }>
536
583
  runtime: {
537
584
  ready: boolean
538
585
  generation: number
@@ -907,6 +954,7 @@ interface SettingsState {
907
954
  status: 'idle' | 'loading' | 'ready' | 'error'
908
955
  snapshot?: SettingsSnapshot | undefined
909
956
  health?: HealthResult | undefined
957
+ providerHealth?: Record<number, HealthResult> | undefined
910
958
  update?: PluginUpdateCheck | undefined
911
959
  restart?: PluginUpdateResult | undefined
912
960
  action?: 'save' | 'health' | 'connection' | 'model' | 'check-update' | 'apply-update' | undefined
@@ -959,7 +1007,7 @@ export class VisionSettingsController {
959
1007
  async save(
960
1008
  value: SettingsValue,
961
1009
  expectedRevision: number,
962
- credentialValue: string | undefined,
1010
+ credentials: Array<{ ref: string; value: string }>,
963
1011
  writeSettings: boolean,
964
1012
  ): Promise<boolean> {
965
1013
  this.set({ ...this.state, action: 'save', error: undefined, message: undefined })
@@ -973,7 +1021,7 @@ export class VisionSettingsController {
973
1021
  })
974
1022
  }
975
1023
  if (snapshot === undefined) throw new Error('Vision Toolkit Settings are unavailable')
976
- if (credentialValue !== undefined) {
1024
+ for (const credential of credentials) {
977
1025
  try {
978
1026
  snapshot = await apiRequest<SettingsSnapshot>({
979
1027
  method: 'POST',
@@ -981,8 +1029,8 @@ export class VisionSettingsController {
981
1029
  body: JSON.stringify({
982
1030
  action: 'credential',
983
1031
  expectedRevision: snapshot.settings.revision,
984
- ref: snapshot.credential.ref,
985
- value: credentialValue,
1032
+ ref: credential.ref,
1033
+ value: credential.value,
986
1034
  }),
987
1035
  })
988
1036
  } catch (error) {
@@ -990,6 +1038,7 @@ export class VisionSettingsController {
990
1038
  status: 'ready',
991
1039
  snapshot,
992
1040
  health: this.state.health,
1041
+ providerHealth: this.state.providerHealth,
993
1042
  update: this.state.update,
994
1043
  restart: this.state.restart,
995
1044
  error: error instanceof Error ? error.message : String(error),
@@ -1001,6 +1050,7 @@ export class VisionSettingsController {
1001
1050
  status: 'ready',
1002
1051
  snapshot,
1003
1052
  health: this.state.health,
1053
+ providerHealth: this.state.providerHealth,
1004
1054
  update: this.state.update,
1005
1055
  restart: this.state.restart,
1006
1056
  message: 'saved',
@@ -1016,7 +1066,7 @@ export class VisionSettingsController {
1016
1066
  }
1017
1067
  }
1018
1068
 
1019
- async runHealth(mode: 'health' | 'connection' | 'model'): Promise<void> {
1069
+ async runHealth(mode: 'health' | 'connection' | 'model', providerIndex?: number): Promise<void> {
1020
1070
  const testConnection = mode !== 'health'
1021
1071
  const testModel = mode === 'model'
1022
1072
  this.set({ ...this.state, action: mode, error: undefined, message: undefined })
@@ -1024,9 +1074,19 @@ export class VisionSettingsController {
1024
1074
  const health = await apiRequest<HealthResult>({
1025
1075
  method: 'POST',
1026
1076
  headers: { 'Content-Type': 'application/json' },
1027
- body: JSON.stringify({ action: 'health', testConnection, testModel }),
1077
+ body: JSON.stringify({
1078
+ action: 'health',
1079
+ testConnection,
1080
+ testModel,
1081
+ ...(providerIndex === undefined ? {} : { providerIndex }),
1082
+ }),
1028
1083
  })
1029
- this.set({ ...this.state, action: undefined, health })
1084
+ if (providerIndex === undefined) {
1085
+ this.set({ ...this.state, action: undefined, health })
1086
+ } else {
1087
+ const providerHealth = { ...(this.state.providerHealth ?? {}), [providerIndex]: health }
1088
+ this.set({ ...this.state, action: undefined, providerHealth })
1089
+ }
1030
1090
  } catch (error) {
1031
1091
  this.set({ ...this.state, action: undefined, error: error instanceof Error ? error.message : String(error) })
1032
1092
  }
@@ -1070,13 +1130,25 @@ export class VisionSettingsController {
1070
1130
  }
1071
1131
  }
1072
1132
 
1073
- interface Draft {
1133
+ interface ProviderDraft {
1134
+ id: string
1135
+ name: string
1136
+ enabled: boolean
1074
1137
  baseUrl: string
1075
1138
  credential: string
1076
1139
  model: string
1077
1140
  protocol: 'openai' | 'anthropic'
1078
1141
  anthropicThinking: 'omit' | 'disabled' | 'adaptive'
1079
1142
  userAgent: string
1143
+ timeoutMs: string
1144
+ maxImageBytes: string
1145
+ maxImagePixels: string
1146
+ concurrency: string
1147
+ attempts: string
1148
+ }
1149
+
1150
+ interface Draft {
1151
+ providers: ProviderDraft[]
1080
1152
  language: 'zh' | 'en'
1081
1153
  timeoutMs: string
1082
1154
  maxImageBytes: string
@@ -1092,19 +1164,86 @@ interface Draft {
1092
1164
  variantAutoSwitch: boolean
1093
1165
  }
1094
1166
 
1167
+ function randomProviderId(): string {
1168
+ const bytes = new Uint8Array(6)
1169
+ if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {
1170
+ crypto.getRandomValues(bytes)
1171
+ } else {
1172
+ for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256)
1173
+ }
1174
+ return Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('').toUpperCase()
1175
+ }
1176
+
1177
+ function autoCredentialName(id: string): string {
1178
+ return `VISION_PROVIDER_${id}`
1179
+ }
1180
+
1181
+ interface ProviderDefaults {
1182
+ timeoutMs: number
1183
+ maxImageBytes: number
1184
+ maxImagePixels: number
1185
+ concurrency: number
1186
+ }
1187
+
1188
+ function emptyProviderDraft(defaults: ProviderDefaults): ProviderDraft {
1189
+ const id = randomProviderId()
1190
+ return {
1191
+ id,
1192
+ name: '',
1193
+ enabled: true,
1194
+ baseUrl: '',
1195
+ credential: autoCredentialName(id),
1196
+ model: '',
1197
+ protocol: 'openai',
1198
+ anthropicThinking: 'omit',
1199
+ userAgent: DEFAULT_USER_AGENT,
1200
+ timeoutMs: String(defaults.timeoutMs),
1201
+ maxImageBytes: String(defaults.maxImageBytes),
1202
+ maxImagePixels: String(defaults.maxImagePixels),
1203
+ concurrency: String(defaults.concurrency),
1204
+ attempts: '3',
1205
+ }
1206
+ }
1207
+
1208
+ function providerDraftOf(value: ProviderValue | undefined, fallback: ProviderValue | undefined, defaults: ProviderDefaults): ProviderDraft {
1209
+ const source = value ?? fallback
1210
+ const id = source?.id ?? ''
1211
+ return {
1212
+ id,
1213
+ name: source?.name ?? '',
1214
+ enabled: source?.enabled !== false,
1215
+ baseUrl: source?.baseUrl ?? '',
1216
+ credential: source?.credential ?? autoCredentialName(id),
1217
+ model: source?.model ?? '',
1218
+ protocol: source?.protocol ?? 'openai',
1219
+ anthropicThinking: source?.anthropicThinking ?? 'omit',
1220
+ userAgent: source?.userAgent ?? DEFAULT_USER_AGENT,
1221
+ timeoutMs: String(source?.timeoutMs ?? defaults.timeoutMs),
1222
+ maxImageBytes: String(source?.maxImageBytes ?? defaults.maxImageBytes),
1223
+ maxImagePixels: String(source?.maxImagePixels ?? defaults.maxImagePixels),
1224
+ concurrency: String(source?.concurrency ?? defaults.concurrency),
1225
+ attempts: String(source?.attempts ?? 3),
1226
+ }
1227
+ }
1228
+
1095
1229
  function draftOf(value: SettingsValue): Draft {
1230
+ const globalDefaults: ProviderDefaults = {
1231
+ timeoutMs: value.timeoutMs ?? 30000,
1232
+ maxImageBytes: value.maxImageBytes ?? 4194304,
1233
+ maxImagePixels: value.maxImagePixels ?? 20000000,
1234
+ concurrency: value.concurrency ?? 4,
1235
+ }
1236
+ const configured = value.providers ?? []
1237
+ const providers = configured.length > 0
1238
+ ? configured.map(entry => providerDraftOf(entry, undefined, globalDefaults))
1239
+ : [providerDraftOf(undefined, value.provider, globalDefaults)]
1096
1240
  return {
1097
- baseUrl: value.provider?.baseUrl ?? BUILT_IN_FREE_VISION_BASE_URL,
1098
- credential: value.provider?.credential ?? BUILT_IN_FREE_VISION_CREDENTIAL,
1099
- model: value.provider?.model ?? BUILT_IN_FREE_VISION_MODEL,
1100
- protocol: value.provider?.protocol ?? 'openai',
1101
- anthropicThinking: value.provider?.anthropicThinking ?? 'omit',
1102
- userAgent: value.provider?.userAgent ?? DEFAULT_USER_AGENT,
1241
+ providers,
1103
1242
  language: value.language ?? 'zh',
1104
- timeoutMs: String(value.timeoutMs ?? 30000),
1105
- maxImageBytes: String(value.maxImageBytes ?? 4194304),
1106
- maxImagePixels: String(value.maxImagePixels ?? 20000000),
1107
- concurrency: String(value.concurrency ?? 4),
1243
+ timeoutMs: String(globalDefaults.timeoutMs),
1244
+ maxImageBytes: String(globalDefaults.maxImageBytes),
1245
+ maxImagePixels: String(globalDefaults.maxImagePixels),
1246
+ concurrency: String(globalDefaults.concurrency),
1108
1247
  runtimeMode: value.runtime?.mode ?? 'managed',
1109
1248
  toolkitPath: value.runtime?.agentVisionToolkitPath ?? '',
1110
1249
  python: value.runtime?.python ?? '',
@@ -1133,15 +1272,33 @@ function apiKeyFailure(value: string, t: Translate): string | undefined {
1133
1272
  }
1134
1273
 
1135
1274
  function valueOf(draft: Draft, t: Translate): SettingsValue {
1275
+ const providers: ProviderValue[] = draft.providers.map(provider => ({
1276
+ ...(provider.id.length === 0 ? {} : { id: provider.id }),
1277
+ ...(provider.name.trim().length === 0 ? {} : { name: provider.name.trim() }),
1278
+ ...(provider.enabled ? {} : { enabled: false }),
1279
+ ...(provider.baseUrl.trim().length === 0 ? {} : { baseUrl: provider.baseUrl.trim() }),
1280
+ ...(provider.credential.trim().length === 0 ? {} : { credential: provider.credential.trim() }),
1281
+ ...(provider.model.trim().length === 0 ? {} : { model: provider.model.trim() }),
1282
+ protocol: provider.protocol,
1283
+ anthropicThinking: provider.anthropicThinking,
1284
+ ...(provider.userAgent.trim() === DEFAULT_USER_AGENT ? {} : { userAgent: provider.userAgent.trim() }),
1285
+ ...(provider.timeoutMs.trim().length === 0 ? {} : { timeoutMs: positiveInteger(provider.timeoutMs, t('timeout'), t) }),
1286
+ ...(provider.maxImageBytes.trim().length === 0 ? {} : { maxImageBytes: positiveInteger(provider.maxImageBytes, t('maxBytes'), t) }),
1287
+ ...(provider.maxImagePixels.trim().length === 0 ? {} : { maxImagePixels: positiveInteger(provider.maxImagePixels, t('maxPixels'), t) }),
1288
+ ...(provider.concurrency.trim().length === 0 ? {} : { concurrency: positiveInteger(provider.concurrency, t('concurrency'), t) }),
1289
+ ...(provider.attempts.trim().length === 0 ? {} : { attempts: positiveInteger(provider.attempts, t('attempts'), t) }),
1290
+ }))
1291
+ const primary = draft.providers[0]
1136
1292
  return {
1137
1293
  provider: {
1138
- baseUrl: draft.baseUrl.trim(),
1139
- credential: draft.credential.trim(),
1140
- model: draft.model.trim(),
1141
- protocol: draft.protocol,
1142
- anthropicThinking: draft.anthropicThinking,
1143
- userAgent: draft.userAgent.trim(),
1294
+ baseUrl: primary?.baseUrl.trim() || BUILT_IN_FREE_VISION_BASE_URL,
1295
+ credential: primary?.credential.trim() || BUILT_IN_FREE_VISION_CREDENTIAL,
1296
+ model: primary?.model.trim() || BUILT_IN_FREE_VISION_MODEL,
1297
+ protocol: primary?.protocol ?? 'openai',
1298
+ anthropicThinking: primary?.anthropicThinking ?? 'omit',
1299
+ userAgent: primary?.userAgent.trim() || DEFAULT_USER_AGENT,
1144
1300
  },
1301
+ providers,
1145
1302
  language: draft.language,
1146
1303
  timeoutMs: positiveInteger(draft.timeoutMs, t('timeout'), t),
1147
1304
  maxImageBytes: positiveInteger(draft.maxImageBytes, t('maxBytes'), t),
@@ -1172,13 +1329,6 @@ function settingsDraftChanged(draft: Draft, saved: SettingsValue, t: Translate):
1172
1329
  }
1173
1330
  }
1174
1331
 
1175
- function isBuiltInFreeVisionDraft(draft: Draft): boolean {
1176
- return draft.baseUrl.trim().replace(/\/+$/, '') === BUILT_IN_FREE_VISION_BASE_URL
1177
- && draft.credential.trim() === BUILT_IN_FREE_VISION_CREDENTIAL
1178
- && draft.model.trim() === BUILT_IN_FREE_VISION_MODEL
1179
- && draft.protocol === 'openai'
1180
- }
1181
-
1182
1332
  interface SettingsInjected {
1183
1333
  controller: VisionSettingsController
1184
1334
  t: Translate
@@ -1261,18 +1411,6 @@ function healthDetail(name: string, detail: string, t: Translate): string {
1261
1411
  return detail
1262
1412
  }
1263
1413
 
1264
- function modelTestTag(health: HealthResult, check: HealthCheck): { status: 'ok' | 'warning' | 'error'; label: LocaleKey } {
1265
- if (!health.modelTested) return { status: 'warning', label: 'modelTestNotRunTag' }
1266
- if (check.status === 'ok') return { status: 'ok', label: 'modelTestVerifiedTag' }
1267
- return { status: 'error', label: 'modelTestFailedTag' }
1268
- }
1269
-
1270
- function credentialSource(source: string, t: Translate): string {
1271
- if (source === 'env') return t('sourceEnv')
1272
- if (source === 'file') return t('sourceFile')
1273
- return source
1274
- }
1275
-
1276
1414
  const UPDATE_REASON_KEYS: Record<PluginUpdateUnavailableReason, LocaleKey> = {
1277
1415
  'profile-not-found': 'updateReasonProfileNotFound',
1278
1416
  'not-direct-dependency': 'updateReasonNotDependency',
@@ -1292,7 +1430,8 @@ function LoadedSettings({ controller, t }: SettingsInjected) {
1292
1430
  const state = useSyncExternalStore(controller.subscribe, controller.snapshot, controller.snapshot)
1293
1431
  const snapshot = state.snapshot
1294
1432
  const [draft, setDraft] = useState<Draft | undefined>(undefined)
1295
- const [apiKey, setApiKey] = useState('')
1433
+ const [selectedIndex, setSelectedIndex] = useState(0)
1434
+ const [keys, setKeys] = useState<Record<string, string>>({})
1296
1435
  const [draftError, setDraftError] = useState<string | undefined>(undefined)
1297
1436
  const [copiedCommand, setCopiedCommand] = useState(false)
1298
1437
 
@@ -1338,43 +1477,106 @@ function LoadedSettings({ controller, t }: SettingsInjected) {
1338
1477
  }
1339
1478
 
1340
1479
  const update = <K extends keyof Draft>(key: K, value: Draft[K]): void => setDraft(current => current === undefined ? current : { ...current, [key]: value })
1480
+ const updateProvider = <K extends keyof ProviderDraft>(index: number, key: K, value: ProviderDraft[K]): void => {
1481
+ setDraft(current => {
1482
+ if (current === undefined) return current
1483
+ const providers = current.providers.map((provider, i) => i === index ? { ...provider, [key]: value } : provider)
1484
+ return { ...current, providers }
1485
+ })
1486
+ }
1487
+ const addProvider = (): void => {
1488
+ setDraft(current => current === undefined ? current : {
1489
+ ...current,
1490
+ providers: [...current.providers, emptyProviderDraft({
1491
+ timeoutMs: Number(current.timeoutMs) || 30000,
1492
+ maxImageBytes: Number(current.maxImageBytes) || 4194304,
1493
+ maxImagePixels: Number(current.maxImagePixels) || 20000000,
1494
+ concurrency: Number(current.concurrency) || 4,
1495
+ })],
1496
+ })
1497
+ setSelectedIndex(draft.providers.length)
1498
+ }
1499
+ const removeProvider = (index: number): void => {
1500
+ const removed = draft.providers[index]
1501
+ if (removed !== undefined) {
1502
+ // Deleting a provider also deletes its stored credential.
1503
+ void apiRequest<SettingsSnapshot>({
1504
+ method: 'POST',
1505
+ headers: { 'Content-Type': 'application/json' },
1506
+ body: JSON.stringify({ action: 'delete-credential', ref: removed.credential }),
1507
+ }).catch(() => {})
1508
+ }
1509
+ setDraft(current => current === undefined ? current : { ...current, providers: current.providers.filter((_, i) => i !== index) })
1510
+ setSelectedIndex(prev => {
1511
+ if (index < prev) return prev - 1
1512
+ if (prev >= draft.providers.length - 1) return Math.max(0, draft.providers.length - 2)
1513
+ return prev
1514
+ })
1515
+ }
1516
+ const moveProvider = (index: number, delta: number): void => {
1517
+ setDraft(current => {
1518
+ if (current === undefined) return current
1519
+ const target = index + delta
1520
+ if (target < 0 || target >= current.providers.length) return current
1521
+ const providers = [...current.providers]
1522
+ const [entry] = providers.splice(index, 1)
1523
+ if (entry === undefined) return current
1524
+ providers.splice(target, 0, entry)
1525
+ return { ...current, providers }
1526
+ })
1527
+ setSelectedIndex(prev => {
1528
+ const target = index + delta
1529
+ if (prev === index) return target
1530
+ if (target < index && prev >= target && prev < index) return prev + 1
1531
+ if (target > index && prev > index && prev <= target) return prev - 1
1532
+ return prev
1533
+ })
1534
+ }
1341
1535
  const save = (): void => {
1342
1536
  try {
1343
- const keyFailure = apiKeyFailure(apiKey, t)
1344
- if (keyFailure !== undefined) {
1345
- setDraftError(keyFailure)
1346
- return
1537
+ for (const value of Object.values(keys)) {
1538
+ const failure = apiKeyFailure(value, t)
1539
+ if (failure !== undefined) {
1540
+ setDraftError(failure)
1541
+ return
1542
+ }
1347
1543
  }
1348
- const credentialValue = apiKey.length === 0 ? undefined : apiKey.trim()
1544
+ const credentials = Object.entries(keys)
1545
+ .filter(([, value]) => value.trim().length > 0)
1546
+ .map(([ref, value]) => ({ ref, value: value.trim() }))
1547
+ const savedRefs = new Set(credentials.map(credential => credential.ref))
1349
1548
  setDraftError(undefined)
1350
1549
  void controller.save(
1351
1550
  valueOf(draft, t),
1352
1551
  snapshot.settings.revision,
1353
- credentialValue,
1552
+ credentials,
1354
1553
  snapshot.writable,
1355
- ).then(saved => { if (saved) setApiKey('') })
1554
+ ).then(saved => {
1555
+ if (saved) {
1556
+ setKeys(current => Object.fromEntries(Object.entries(current).filter(([ref]) => !savedRefs.has(ref))))
1557
+ }
1558
+ })
1356
1559
  } catch (error) {
1357
1560
  setDraftError(error instanceof Error ? error.message : String(error))
1358
1561
  }
1359
1562
  }
1360
1563
  const busy = state.action !== undefined
1361
- const credentialMatchesSnapshot = draft.credential.trim() === snapshot.credential.ref
1362
- const builtInCredentialChangedProvider = snapshot.credential.source === 'built-in-free'
1363
- && !isBuiltInFreeVisionDraft(draft)
1364
- const keyLocked = credentialMatchesSnapshot
1365
- && !snapshot.credential.writable
1366
- && !builtInCredentialChangedProvider
1367
- const canSave = snapshot.writable || (apiKey.length > 0 && !keyLocked)
1564
+ const selectedProvider = draft.providers[selectedIndex]
1565
+ const providerCredentials = snapshot.credentials ?? []
1566
+ const selectedCredential = providerCredentials[selectedIndex]
1567
+ const selectedKey = selectedProvider === undefined ? '' : (keys[selectedProvider.credential] ?? '')
1568
+ const selectedKeyLocked = selectedCredential !== undefined && !selectedCredential.writable
1569
+ const selectedBuiltInFree = selectedCredential?.source === 'built-in-free'
1570
+ const canSave = snapshot.writable || Object.values(keys).some(value => value.trim().length > 0)
1368
1571
  const runtimeErrorTitle = snapshot.runtime.ready ? t('runtimeCandidateRejected') : t('runtimeUnavailable')
1369
1572
  const pluginUpdate = state.update
1370
1573
  const updateCapability = pluginUpdate ?? snapshot.release.update
1371
1574
  const latestVersion = pluginUpdate?.latestVersion
1372
1575
  const updateReason = updateCapability.reason === undefined ? undefined : t(UPDATE_REASON_KEYS[updateCapability.reason])
1373
1576
  const updateCheckSupported = updateCapability.checkSupported ?? updateCapability.supported
1374
- const updateHasUnsavedChanges = apiKey.length > 0 || settingsDraftChanged(draft, snapshot.settings.value, t)
1577
+ const updateHasUnsavedChanges = Object.values(keys).some(value => value.trim().length > 0) || settingsDraftChanged(draft, snapshot.settings.value, t)
1375
1578
  const manualUpdateProfile = updateCapability.profile ?? 'web'
1376
1579
  const manualUpdateCommand = `dsh plugin --profile ${manualUpdateProfile} add @mengruo/dsh-vision-toolkit@latest --registry=https://registry.npmjs.org/`
1377
- const aihubmixTutorialUrl = draft?.language === 'en' ? AIHUBMIX_TUTORIAL_URL_EN : AIHUBMIX_TUTORIAL_URL_ZH
1378
1580
  const copyManualUpdate = (): void => {
1379
1581
  void navigator.clipboard?.writeText(manualUpdateCommand)
1380
1582
  .then(() => {
@@ -1388,6 +1590,7 @@ function LoadedSettings({ controller, t }: SettingsInjected) {
1388
1590
  if (!window.confirm(t('updateConfirm', { version: latestVersion }))) return
1389
1591
  void controller.applyUpdate(latestVersion)
1390
1592
  }
1593
+ const providerHealth = state.providerHealth?.[selectedIndex]
1391
1594
 
1392
1595
  return (
1393
1596
  <div className="dvt-settings">
@@ -1400,27 +1603,76 @@ function LoadedSettings({ controller, t }: SettingsInjected) {
1400
1603
  {state.message === 'manual-restart-required' && state.restart !== undefined ? <div className="dvt-alert success">{t('manualRestartRequired', { version: state.restart.toVersion })}</div> : null}
1401
1604
  {snapshot.runtime.lastError === undefined ? null : <div className="dvt-alert error"><strong>{runtimeErrorTitle}</strong><span>{snapshot.runtime.lastError}</span></div>}
1402
1605
 
1403
- <section className="dvt-panel dvt-essential"><div className="dvt-panel-title"><div><h3>{t('provider')}</h3><p>{t('providerHint')}</p></div><span className={`dvt-badge ${snapshot.credential.configured ? 'ok' : 'error'}`}>{snapshot.credential.configured ? t('configured') : t('missing')}</span></div>
1404
- <p className="dvt-tutorial-link"><a href={aihubmixTutorialUrl} target="_blank" rel="noreferrer">{t('aihubmixTutorial')}</a></p>
1405
- <div className="dvt-form-grid">
1406
- <Field label={t('protocol')}><select disabled={!snapshot.writable || busy} value={draft.protocol} onChange={(event) => { update('protocol', event.target.value as 'openai' | 'anthropic') }}><option value="openai">OpenAI Chat Completions</option><option value="anthropic">Anthropic Messages</option></select></Field>
1407
- <Field label={t('baseUrl')}><Input disabled={!snapshot.writable || busy} value={draft.baseUrl} onChange={(event) => { update('baseUrl', event.target.value) }} /></Field>
1408
- <Field label={t('model')}><Input disabled={!snapshot.writable || busy} value={draft.model} onChange={(event) => { update('model', event.target.value) }} /></Field>
1409
- <Field label={t('apiKey')} hint={keyLocked ? t('apiKeyLocked') : snapshot.credential.source === undefined ? t('apiKeyHint') : `${t('apiKeyHint')} ${t('sourceHint', { source: t('source'), value: credentialSource(snapshot.credential.source, t) })}`}><Input aria-label={t('apiKey')} type="password" autoComplete="new-password" disabled={busy || keyLocked} placeholder={snapshot.credential.configured ? t('apiKeyPlaceholderConfigured') : t('apiKeyPlaceholderMissing')} value={apiKey} onChange={(event) => { setApiKey(event.target.value); setDraftError(undefined) }} /></Field>
1606
+ <section className="dvt-panel dvt-essential"><div className="dvt-panel-title"><div><h3>{t('providers')}</h3><p>{t('providerHint')}</p></div></div>
1607
+ <div className="dvt-provider-select">
1608
+ <select aria-label={t('providers')} disabled={!snapshot.writable || busy} value={selectedIndex} onChange={(event) => { setSelectedIndex(Number(event.target.value)) }}>
1609
+ {draft.providers.map((provider, index) => (
1610
+ <option key={index} value={index}>{index + 1}. {provider.name.trim() || provider.model.trim() || t('provider')}</option>
1611
+ ))}
1612
+ </select>
1613
+ <Button size="sm" variant="outline" disabled={!snapshot.writable || busy || draft.providers.length >= 32} onClick={addProvider}>{t('addProvider')}</Button>
1614
+ <Button size="sm" variant="outline" disabled={!snapshot.writable || busy || draft.providers.length <= 1} onClick={() => { removeProvider(selectedIndex) }}>{t('removeProvider')}</Button>
1410
1615
  </div>
1616
+ {selectedProvider === undefined ? null : (
1617
+ <div className="dvt-provider-card">
1618
+ <div className="dvt-provider-head">
1619
+ <label className="dvt-check"><input type="checkbox" checked={selectedProvider.enabled} disabled={!snapshot.writable || busy} onChange={(event) => { updateProvider(selectedIndex, 'enabled', event.target.checked) }} /><span>{t('enabled')}</span></label>
1620
+ <span className="dvt-provider-priority">{t('priority', { index: selectedIndex + 1 })}</span>
1621
+ </div>
1622
+ <div className="dvt-form-grid">
1623
+ <Field label={t('name')}><Input aria-label={t('name')} value={selectedProvider.name} onChange={(event) => { updateProvider(selectedIndex, 'name', event.target.value) }} placeholder={selectedProvider.model || t('model')} /></Field>
1624
+ <Field label={t('model')}><Input aria-label={t('model')} disabled={!snapshot.writable || busy} value={selectedProvider.model} onChange={(event) => { updateProvider(selectedIndex, 'model', event.target.value) }} /></Field>
1625
+ <Field label={t('baseUrl')}><Input aria-label={t('baseUrl')} disabled={!snapshot.writable || busy} value={selectedProvider.baseUrl} onChange={(event) => { updateProvider(selectedIndex, 'baseUrl', event.target.value) }} /></Field>
1626
+ <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>
1627
+ {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}
1628
+ <Field label={t('userAgent')}><Input aria-label={t('userAgent')} value={selectedProvider.userAgent} onChange={(event) => { updateProvider(selectedIndex, 'userAgent', event.target.value) }} /></Field>
1629
+ <Field label={t('timeout')}><Input aria-label={t('timeout')} inputMode="numeric" value={selectedProvider.timeoutMs} onChange={(event) => { updateProvider(selectedIndex, 'timeoutMs', event.target.value) }} /></Field>
1630
+ <Field label={t('maxBytes')}><Input aria-label={t('maxBytes')} inputMode="numeric" value={selectedProvider.maxImageBytes} onChange={(event) => { updateProvider(selectedIndex, 'maxImageBytes', event.target.value) }} /></Field>
1631
+ <Field label={t('maxPixels')}><Input aria-label={t('maxPixels')} inputMode="numeric" value={selectedProvider.maxImagePixels} onChange={(event) => { updateProvider(selectedIndex, 'maxImagePixels', event.target.value) }} /></Field>
1632
+ <Field label={t('concurrency')}><Input aria-label={t('concurrency')} inputMode="numeric" value={selectedProvider.concurrency} onChange={(event) => { updateProvider(selectedIndex, 'concurrency', event.target.value) }} /></Field>
1633
+ <Field label={t('attempts')} hint={t('attemptsHint')}><Input aria-label={t('attempts')} inputMode="numeric" value={selectedProvider.attempts} onChange={(event) => { updateProvider(selectedIndex, 'attempts', event.target.value) }} /></Field>
1634
+ </div>
1635
+ <Field label={t('apiKey')} hint={selectedBuiltInFree ? t('apiKeyBuiltInFree') : selectedKeyLocked ? t('apiKeyLocked') : t('apiKeyHint')}><Input aria-label={t('apiKey')} type="password" autoComplete="new-password" disabled={busy || selectedKeyLocked || selectedBuiltInFree} placeholder={selectedCredential?.configured ? t('apiKeyPlaceholderConfigured') : t('apiKeyPlaceholderMissing')} value={selectedKey} onChange={(event) => { setKeys(current => ({ ...current, [selectedProvider.credential]: event.target.value })); setDraftError(undefined) }} /></Field>
1636
+ <div className="dvt-provider-tests">
1637
+ <Button size="sm" variant="outline" disabled={busy || !snapshot.runtime.ready} onClick={() => { void controller.runHealth('connection', selectedIndex) }}>{state.action === 'connection' ? t('testing') : t('testConnection')}</Button>
1638
+ <Button size="sm" variant="primary" disabled={busy || !snapshot.runtime.ready} onClick={() => { void controller.runHealth('model', selectedIndex) }}>{state.action === 'model' ? t('testingModel') : t('testModel')}</Button>
1639
+ </div>
1640
+ {providerHealth === undefined ? null : <div className="dvt-provider-test-result">{(['service', 'model'] as const).map(name => {
1641
+ const check = providerHealth.checks[name]
1642
+ if (check === undefined) return null
1643
+ return <div key={name} data-status={check.status}><span>{t(HEALTH_NAME_KEYS[name] ?? 'health')}</span><strong>{t(HEALTH_STATUS_KEYS[check.status])}</strong><p>{healthDetail(name, check.detail, t)}</p></div>
1644
+ })}</div>}
1645
+ </div>
1646
+ )}
1411
1647
  </section>
1412
1648
 
1413
1649
  <div className="dvt-save-row"><Button variant="primary" disabled={!canSave || busy} onClick={save}>{state.action === 'save' ? t('saving') : t('save')}</Button><Button variant="outline" disabled={busy} onClick={() => { void controller.load() }}>{t('reload')}</Button></div>
1414
1650
 
1415
- <section className="dvt-panel"><div className="dvt-panel-title"><div><h3>{t('health')}</h3><p>{t('connectionHint')}</p></div><div className="dvt-actions"><Button size="sm" variant="outline" disabled={busy || !snapshot.runtime.ready} onClick={() => { void controller.runHealth('health') }}>{state.action === 'health' ? t('testing') : t('runHealth')}</Button><Button size="sm" variant="outline" disabled={busy || !snapshot.runtime.ready} onClick={() => { void controller.runHealth('connection') }}>{state.action === 'connection' ? t('testing') : t('testConnection')}</Button><Button size="sm" variant="primary" disabled={busy || !snapshot.runtime.ready} onClick={() => { void controller.runHealth('model') }}>{state.action === 'model' ? t('testingModel') : t('testModel')}</Button></div></div>
1416
- <p className="dvt-muted">{t('saveBeforeTesting')}</p>
1417
- {state.health === undefined ? <p className="dvt-muted">{t('notTested')}</p> : <div className="dvt-health-grid">{Object.entries(state.health.checks).map(([name, check]) => {
1418
- const testTag = name === 'model' ? modelTestTag(state.health as HealthResult, check) : undefined
1419
- return <div key={name} data-status={check.status}><span>{t(HEALTH_NAME_KEYS[name] ?? 'health')}</span>{testTag === undefined ? null : <em className="dvt-health-test-tag" data-status={testTag.status}>{t(testTag.label)}</em>}<strong>{t(HEALTH_STATUS_KEYS[check.status])}</strong><p>{healthDetail(name, check.detail, t)}</p></div>
1420
- })}</div>}
1651
+ <section className="dvt-panel"><div className="dvt-panel-title"><div><h3>{t('prioritySort')}</h3><p>{t('prioritySortHint')}</p></div></div>
1652
+ <div className="dvt-sorter">
1653
+ {draft.providers.map((provider, index) => (
1654
+ <div key={index} className="dvt-sorter-row">
1655
+ <span className="dvt-sorter-index">{index + 1}</span>
1656
+ <span className="dvt-sorter-name">{provider.name.trim() || provider.model.trim() || t('provider')}</span>
1657
+ <div className="dvt-provider-actions">
1658
+ <button type="button" aria-label={t('moveUp')} title={t('moveUp')} disabled={!snapshot.writable || busy || index === 0} onClick={() => { moveProvider(index, -1) }}>↑</button>
1659
+ <button type="button" aria-label={t('moveDown')} title={t('moveDown')} disabled={!snapshot.writable || busy || index === draft.providers.length - 1} onClick={() => { moveProvider(index, 1) }}>↓</button>
1660
+ </div>
1661
+ </div>
1662
+ ))}
1663
+ </div>
1421
1664
  </section>
1422
1665
 
1423
1666
  <section className="dvt-panel dvt-update-panel">
1667
+ <div className="dvt-panel-title">
1668
+ <div><h3>{t('runtimeCheck')}</h3><p>{t('runtimeCheckHint')}</p></div>
1669
+ <div className="dvt-actions"><Button size="sm" variant="outline" disabled={busy || !snapshot.runtime.ready} onClick={() => { void controller.runHealth('health') }}>{state.action === 'health' ? t('testing') : t('runHealth')}</Button></div>
1670
+ </div>
1671
+ {state.health === undefined ? <p className="dvt-muted">{t('notTested')}</p> : <div className="dvt-health-grid">{(['python', 'dependencies', 'chrome'] as const).map(name => {
1672
+ const check = state.health?.checks[name]
1673
+ if (check === undefined) return null
1674
+ return <div key={name} data-status={check.status}><span>{t(HEALTH_NAME_KEYS[name] ?? 'health')}</span><strong>{t(HEALTH_STATUS_KEYS[check.status])}</strong><p>{healthDetail(name, check.detail, t)}</p></div>
1675
+ })}</div>}
1424
1676
  <div className="dvt-panel-title">
1425
1677
  <div><h3>{t('updates')}</h3><p>{t('updatesHint')}</p></div>
1426
1678
  <span className={`dvt-badge ${pluginUpdate?.updateAvailable ? 'warning' : pluginUpdate !== undefined && pluginUpdate.supported ? 'ok' : ''}`}>
@@ -1447,18 +1699,8 @@ function LoadedSettings({ controller, t }: SettingsInjected) {
1447
1699
  <details className="dvt-advanced">
1448
1700
  <summary><span><strong>{t('advanced')}</strong><small>{t('advancedHint')}</small></span><span className="dvt-details-chevron" aria-hidden="true">⌄</span></summary>
1449
1701
  <div className="dvt-advanced-body">
1450
- <section className="dvt-panel"><div className="dvt-panel-title"><h3>{t('provider')}</h3></div><div className="dvt-form-grid">
1451
- <Field label={t('credential')} hint={t('credentialHint')}><Input aria-label={t('credential')} disabled={!snapshot.writable || busy} value={draft.credential} onChange={(event) => { update('credential', event.target.value) }} /></Field>
1452
- {draft.protocol === 'anthropic' ? <Field label={t('anthropicThinking')} hint={t('anthropicThinkingHint')}><select aria-label={t('anthropicThinking')} value={draft.anthropicThinking} onChange={(event) => { update('anthropicThinking', event.target.value as 'omit' | 'disabled' | 'adaptive') }}><option value="omit">omit (widest compatibility)</option><option value="disabled">disabled (model support required)</option><option value="adaptive">adaptive (model support required)</option></select></Field> : null}
1453
- <Field label={t('userAgent')}><Input value={draft.userAgent} onChange={(event) => { update('userAgent', event.target.value) }} /></Field>
1454
- </div></section>
1455
-
1456
- <section className="dvt-panel"><div className="dvt-panel-title"><h3>{t('limits')}</h3></div><div className="dvt-form-grid">
1702
+ <section className="dvt-panel"><div className="dvt-panel-title"><h3>{t('language')}</h3></div><div className="dvt-form-grid">
1457
1703
  <Field label={t('language')}><select value={draft.language} onChange={(event) => { update('language', event.target.value as 'zh' | 'en') }}><option value="zh">中文</option><option value="en">English</option></select></Field>
1458
- <Field label={t('timeout')}><Input inputMode="numeric" value={draft.timeoutMs} onChange={(event) => { update('timeoutMs', event.target.value) }} /></Field>
1459
- <Field label={t('maxBytes')}><Input inputMode="numeric" value={draft.maxImageBytes} onChange={(event) => { update('maxImageBytes', event.target.value) }} /></Field>
1460
- <Field label={t('maxPixels')}><Input inputMode="numeric" value={draft.maxImagePixels} onChange={(event) => { update('maxImagePixels', event.target.value) }} /></Field>
1461
- <Field label={t('concurrency')}><Input inputMode="numeric" value={draft.concurrency} onChange={(event) => { update('concurrency', event.target.value) }} /></Field>
1462
1704
  </div></section>
1463
1705
 
1464
1706
  <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>
@@ -1492,6 +1734,7 @@ const CSS = `
1492
1734
  .dvt-settings-footer{margin-top:8px;padding:20px 2px 4px;border-top:1px solid var(--dsw-alias-border-l1);opacity:.82}.dvt-settings-footer h2{font-size:18px;letter-spacing:-.015em;margin:3px 0 5px}.dvt-settings-footer p{font-size:11px;line-height:1.5}.dvt-release{min-width:220px}.dvt-release span{white-space:nowrap}.dvt-essential{border-color:color-mix(in srgb,var(--dsw-alias-state-business-primary) 30%,var(--dsw-alias-border-l1));box-shadow:var(--dsw-shadow-lv1),0 0 0 3px color-mix(in srgb,var(--dsw-alias-state-business-primary) 5%,transparent)}.dvt-advanced{border:1px solid var(--dsw-alias-border-l1);border-radius:14px;background:var(--dsw-alias-bg-layer-1);overflow:hidden}.dvt-advanced>summary{display:flex;align-items:center;justify-content:space-between;gap:14px;padding:14px 15px;cursor:pointer;list-style:none}.dvt-advanced>summary::-webkit-details-marker{display:none}.dvt-advanced>summary>span:first-child{display:grid;gap:3px}.dvt-advanced>summary strong{font-size:13px}.dvt-advanced>summary small{font-size:10px;line-height:1.45;color:var(--dsw-alias-label-secondary);font-weight:400}.dvt-details-chevron{font-size:15px;opacity:.55;transition:transform .16s ease}.dvt-advanced[open] .dvt-details-chevron{transform:rotate(180deg)}.dvt-advanced-body{display:grid;grid-template-columns:minmax(0,1fr);gap:12px;padding:0 12px 12px}.dvt-advanced-body>.dvt-panel{box-shadow:none}
1493
1735
  .dvt-health-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:8px}.dvt-health-grid>div{padding:9px 10px;border-radius:9px;background:var(--dsw-alias-bg-layer-2);border-left:3px solid var(--dsw-alias-border-l4)}.dvt-health-grid>div[data-status=ok]{border-left-color:var(--dsw-alias-state-success-primary)}.dvt-health-grid>div[data-status=warning],.dvt-health-grid>div[data-status=not_tested]{border-left-color:var(--dsw-alias-state-warn-primary)}.dvt-health-grid>div[data-status=error]{border-left-color:var(--dsw-alias-state-error-primary)}.dvt-health-grid span{font-size:10px;text-transform:capitalize}.dvt-health-grid strong{float:right;font-size:9px;text-transform:uppercase;color:var(--dsw-alias-label-secondary)}.dvt-health-test-tag{display:inline-flex;margin-left:6px;padding:1px 6px;border-radius:999px;background:var(--dsw-alias-bg-layer-1);font-size:9px;font-style:normal;font-weight:600;color:var(--dsw-alias-label-secondary)}.dvt-health-test-tag[data-status=ok]{background:color-mix(in srgb,var(--dsw-alias-state-success-primary) 12%,transparent);color:var(--dsw-alias-state-success-primary)}.dvt-health-test-tag[data-status=warning]{background:color-mix(in srgb,var(--dsw-alias-state-warn-primary) 12%,transparent);color:var(--dsw-alias-state-warn-label)}.dvt-health-test-tag[data-status=error]{background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 12%,transparent);color:var(--dsw-alias-state-error-primary)}.dvt-health-grid p{clear:both;margin:5px 0 0;font-size:10px;line-height:1.4;color:var(--dsw-alias-label-secondary)}.dvt-loading{padding:24px;border-radius:12px;background:var(--dsw-alias-bg-layer-2);font-size:12px;color:var(--dsw-alias-label-secondary)}
1494
1736
  .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}
1737
+ .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}
1495
1738
  @media(max-width:720px){.dvt-settings-footer{display:grid}.dvt-release{width:auto}.dvt-form-grid,.dvt-update-grid{grid-template-columns:1fr}.dvt-metrics{grid-template-columns:1fr}.dvt-artifact-meta{align-items:flex-start;flex-direction:column}.dvt-panel-title{flex-direction:column}}
1496
1739
  `
1497
1740