@anionex/dsh-vision-toolkit 0.1.8 → 0.1.9

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.
@@ -73,9 +73,11 @@ const en = {
73
73
  sourceFile: 'Credential file',
74
74
  health: 'Health',
75
75
  runHealth: 'Run health check',
76
- testConnection: 'Test connection',
76
+ testConnection: 'Test API connection',
77
+ testModel: 'Test vision model',
77
78
  testing: 'Checking…',
78
- connectionHint: 'Connection testing explicitly sends the configured credential to GET /models. It uploads no image and creates no completion.',
79
+ testingModel: 'Testing model…',
80
+ connectionHint: 'The API connection test only queries GET /models. The vision model test sends the bundled diagnostic image and verifies one real multimodal request.',
79
81
  saveBeforeTesting: 'Save service changes before testing the connection.',
80
82
  advanced: 'Advanced settings',
81
83
  advancedHint: 'Credential name, provider compatibility, output language, resource limits, runtime source, Python, and additional readable directories.',
@@ -140,6 +142,7 @@ const en = {
140
142
  healthArtifactDirectory: 'Artifact directory',
141
143
  healthTempDirectory: 'Temporary directory',
142
144
  healthService: 'Vision service',
145
+ healthModel: 'Vision model',
143
146
  statusOk: 'OK',
144
147
  statusWarning: 'Warning',
145
148
  statusError: 'Error',
@@ -154,7 +157,7 @@ const en = {
154
157
  healthDirectoryWritable: '{directory} is writable: {path}',
155
158
  healthDirectoryNotWritable: '{directory} is not writable: {path}',
156
159
  healthArtifactDirectoryFailed: 'Could not prepare the artifact directory.',
157
- healthConnectionNotTested: 'Connection not tested. Use Test connection to query /models.',
160
+ healthConnectionNotTested: 'API connection not tested. Use Test API connection to query /models.',
158
161
  healthConnectionCredentialMissing: 'Connection test skipped because the credential is unavailable.',
159
162
  healthServiceResponded: 'Service responded at {endpoint} (HTTP {status}).',
160
163
  healthServiceRejectedCredential: 'Service rejected the configured credential (HTTP {status}).',
@@ -162,6 +165,13 @@ const en = {
162
165
  healthServiceRateLimited: 'Service is reachable, but the connection test was rate-limited (HTTP 429).',
163
166
  healthServiceHttpFailed: 'Connection test failed with HTTP {status}.',
164
167
  healthServiceUnreachable: 'Could not reach {endpoint}.',
168
+ healthModelNotTested: 'Vision model not tested. Run Test vision model to make one real multimodal request.',
169
+ healthModelCredentialMissing: 'Vision model test skipped because the credential is unavailable.',
170
+ healthModelReady: 'Model {model} completed a real multimodal request.',
171
+ healthModelFailed: 'Real multimodal request failed: {detail}',
172
+ modelTestVerifiedTag: 'Verified',
173
+ modelTestNotRunTag: 'Not tested',
174
+ modelTestFailedTag: 'Test failed',
165
175
  } as const
166
176
 
167
177
  type LocaleKey = keyof typeof en
@@ -213,10 +223,12 @@ const zh: Record<LocaleKey, string> = {
213
223
  sourceFile: '凭据文件',
214
224
  health: '运行检查',
215
225
  runHealth: '检查本地环境',
216
- testConnection: '测试服务连接',
226
+ testConnection: '测试 API 连接',
227
+ testModel: '测试视觉模型',
217
228
  testing: '检查中…',
218
- connectionHint: '“检查本地环境”只检查本机依赖和目录;“测试服务连接”会携带已配置的 API 密钥请求 /models,但不会上传图片或调用模型。',
219
- saveBeforeTesting: '修改服务配置后,请先保存,再测试连接。',
229
+ testingModel: '正在测试模型…',
230
+ connectionHint: '“测试 API 连接”只请求 GET /models;“测试视觉模型”会发送插件自带的诊断图片,验证一次真实多模态调用。',
231
+ saveBeforeTesting: '修改服务配置后,请先保存,再执行 API 或视觉模型测试。',
220
232
  advanced: '高级设置',
221
233
  advancedHint: '凭据名称、服务兼容参数、结果语言、资源限制、运行环境来源、Python 和额外可读目录。一般无需修改。',
222
234
  pluginVersion: '插件版本',
@@ -280,6 +292,7 @@ const zh: Record<LocaleKey, string> = {
280
292
  healthArtifactDirectory: '输出目录',
281
293
  healthTempDirectory: '临时目录',
282
294
  healthService: '视觉服务',
295
+ healthModel: '视觉模型',
283
296
  statusOk: '正常',
284
297
  statusWarning: '注意',
285
298
  statusError: '异常',
@@ -294,7 +307,7 @@ const zh: Record<LocaleKey, string> = {
294
307
  healthDirectoryWritable: '{directory}可写:{path}',
295
308
  healthDirectoryNotWritable: '{directory}不可写:{path}',
296
309
  healthArtifactDirectoryFailed: '无法准备输出目录。',
297
- healthConnectionNotTested: '尚未测试服务连接。点击“测试服务连接”可请求 /models。',
310
+ healthConnectionNotTested: '尚未测试 API 连接。点击“测试 API 连接”可请求 /models。',
298
311
  healthConnectionCredentialMissing: 'API 密钥不可用,未执行连接测试。',
299
312
  healthServiceResponded: '服务已响应:{endpoint}(HTTP {status})。',
300
313
  healthServiceRejectedCredential: '服务拒绝了当前 API 密钥(HTTP {status})。',
@@ -302,6 +315,13 @@ const zh: Record<LocaleKey, string> = {
302
315
  healthServiceRateLimited: '服务可以访问,但本次连接测试触发了限流(HTTP 429)。',
303
316
  healthServiceHttpFailed: '连接测试失败(HTTP {status})。',
304
317
  healthServiceUnreachable: '无法连接到 {endpoint}。',
318
+ healthModelNotTested: '尚未测试视觉模型。点击“测试视觉模型”可执行一次真实多模态请求。',
319
+ healthModelCredentialMissing: '视觉模型测试已跳过,因为当前 API 密钥不可用。',
320
+ healthModelReady: '模型 {model} 已完成一次真实多模态请求。',
321
+ healthModelFailed: '真实多模态请求失败:{detail}',
322
+ modelTestVerifiedTag: '已实测',
323
+ modelTestNotRunTag: '未测试',
324
+ modelTestFailedTag: '测试失败',
305
325
  }
306
326
 
307
327
  type Translate = (key: LocaleKey, params?: Record<string, unknown>) => string
@@ -356,6 +376,7 @@ interface HealthResult {
356
376
  checks: Record<string, HealthCheck>
357
377
  healthy: boolean
358
378
  connectionTested: boolean
379
+ modelTested: boolean
359
380
  }
360
381
 
361
382
  interface SettingsValue {
@@ -754,7 +775,7 @@ interface SettingsState {
754
775
  status: 'idle' | 'loading' | 'ready' | 'error'
755
776
  snapshot?: SettingsSnapshot | undefined
756
777
  health?: HealthResult | undefined
757
- action?: 'save' | 'health' | 'connection' | undefined
778
+ action?: 'save' | 'health' | 'connection' | 'model' | undefined
758
779
  message?: string | undefined
759
780
  error?: string | undefined
760
781
  }
@@ -842,13 +863,15 @@ export class VisionSettingsController {
842
863
  }
843
864
  }
844
865
 
845
- async runHealth(testConnection: boolean): Promise<void> {
846
- this.set({ ...this.state, action: testConnection ? 'connection' : 'health', error: undefined, message: undefined })
866
+ async runHealth(mode: 'health' | 'connection' | 'model'): Promise<void> {
867
+ const testConnection = mode !== 'health'
868
+ const testModel = mode === 'model'
869
+ this.set({ ...this.state, action: mode, error: undefined, message: undefined })
847
870
  try {
848
871
  const health = await apiRequest<HealthResult>({
849
872
  method: 'POST',
850
873
  headers: { 'Content-Type': 'application/json' },
851
- body: JSON.stringify({ action: 'health', testConnection }),
874
+ body: JSON.stringify({ action: 'health', testConnection, testModel }),
852
875
  })
853
876
  this.set({ ...this.state, action: undefined, health })
854
877
  } catch (error) {
@@ -959,6 +982,7 @@ const HEALTH_NAME_KEYS: Record<string, LocaleKey> = {
959
982
  artifactDirectory: 'healthArtifactDirectory',
960
983
  tempDirectory: 'healthTempDirectory',
961
984
  service: 'healthService',
985
+ model: 'healthModel',
962
986
  }
963
987
 
964
988
  const HEALTH_STATUS_KEYS: Record<HealthCheck['status'], LocaleKey> = {
@@ -1005,9 +1029,21 @@ function healthDetail(name: string, detail: string, t: Translate): string {
1005
1029
  if (match !== null) return t('healthServiceHttpFailed', { status: match[1] })
1006
1030
  match = /^Service could not be reached at (.+)$/u.exec(detail)
1007
1031
  if (match !== null) return t('healthServiceUnreachable', { endpoint: match[1] })
1032
+ if (detail === 'Vision model was not tested; run an explicit model test to send the bundled diagnostic image') return t('healthModelNotTested')
1033
+ if (detail === 'Vision model test skipped because the configured credential is unavailable') return t('healthModelCredentialMissing')
1034
+ match = /^Vision model (.+) completed a multimodal request$/u.exec(detail)
1035
+ if (match !== null) return t('healthModelReady', { model: match[1] })
1036
+ match = /^Vision model test failed: (.+)$/u.exec(detail)
1037
+ if (match !== null) return t('healthModelFailed', { detail: match[1] })
1008
1038
  return detail
1009
1039
  }
1010
1040
 
1041
+ function modelTestTag(health: HealthResult, check: HealthCheck): { status: 'ok' | 'warning' | 'error'; label: LocaleKey } {
1042
+ if (!health.modelTested) return { status: 'warning', label: 'modelTestNotRunTag' }
1043
+ if (check.status === 'ok') return { status: 'ok', label: 'modelTestVerifiedTag' }
1044
+ return { status: 'error', label: 'modelTestFailedTag' }
1045
+ }
1046
+
1011
1047
  function credentialSource(source: string, t: Translate): string {
1012
1048
  if (source === 'env') return t('sourceEnv')
1013
1049
  if (source === 'file') return t('sourceFile')
@@ -1079,9 +1115,12 @@ function LoadedSettings({ controller, t }: SettingsInjected) {
1079
1115
 
1080
1116
  <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>
1081
1117
 
1082
- <section className="dvt-panel"><div className="dvt-panel-title"><div><h3>{t('health')}</h3><p>{t('connectionHint')}</p></div><div className="dvt-actions"><Button size="sm" variant="outline" disabled={busy || !snapshot.runtime.ready} onClick={() => { void controller.runHealth(false) }}>{state.action === 'health' ? t('testing') : t('runHealth')}</Button><Button size="sm" variant="primary" disabled={busy || !snapshot.runtime.ready} onClick={() => { void controller.runHealth(true) }}>{state.action === 'connection' ? t('testing') : t('testConnection')}</Button></div></div>
1118
+ <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>
1083
1119
  <p className="dvt-muted">{t('saveBeforeTesting')}</p>
1084
- {state.health === undefined ? <p className="dvt-muted">{t('notTested')}</p> : <div className="dvt-health-grid">{Object.entries(state.health.checks).map(([name, check]) => <div key={name} data-status={check.status}><span>{t(HEALTH_NAME_KEYS[name] ?? 'health')}</span><strong>{t(HEALTH_STATUS_KEYS[check.status])}</strong><p>{healthDetail(name, check.detail, t)}</p></div>)}</div>}
1120
+ {state.health === undefined ? <p className="dvt-muted">{t('notTested')}</p> : <div className="dvt-health-grid">{Object.entries(state.health.checks).map(([name, check]) => {
1121
+ const testTag = name === 'model' ? modelTestTag(state.health as HealthResult, check) : undefined
1122
+ 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>
1123
+ })}</div>}
1085
1124
  </section>
1086
1125
 
1087
1126
  <details className="dvt-advanced">
@@ -1128,7 +1167,7 @@ const CSS = `
1128
1167
  .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)}
1129
1168
  .dvt-settings{display:grid;gap:14px;max-width:900px;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;gap:12px;padding:15px;border:1px solid var(--dsw-alias-border-l1);border-radius:14px;background:var(--dsw-alias-bg-layer-1);box-shadow:var(--dsw-shadow-lv1)}.dvt-panel-title{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}.dvt-panel-title h3{font-size:14px;margin:0}.dvt-panel-title p{font-size:11px;line-height:1.45;color:var(--dsw-alias-label-secondary);margin:4px 0 0;max-width:620px}.dvt-badge{font-size:10px;padding:3px 7px;border-radius:999px;font-weight:650}.dvt-badge.ok{background:color-mix(in srgb,var(--dsw-alias-state-success-primary) 12%,transparent);color:var(--dsw-alias-state-success-primary)}.dvt-badge.error{background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 10%,transparent);color:var(--dsw-alias-state-error-primary)}.dvt-form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.dvt-field{display:grid;gap:6px;align-content:start}.dvt-field>span{font-size:11px;font-weight:600}.dvt-field>small{font-size:10px;color:var(--dsw-alias-label-secondary);line-height:1.4}.dvt-field select,.dvt-field textarea{width:100%;box-sizing:border-box;border:1px solid var(--dsw-alias-border-l1);border-radius:9px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font:inherit;font-size:12px;padding:8px 10px}.dvt-field select{height:36px}.dvt-field textarea{resize:vertical;min-height:76px}.dvt-runtime-facts{display:grid;gap:4px;padding:9px 10px;border-radius:9px;background:var(--dsw-alias-bg-layer-2);overflow:auto}.dvt-runtime-facts code{font-size:10px;white-space:nowrap;color:var(--dsw-alias-label-secondary)}.dvt-save-row{display:flex;gap:8px;padding:2px 0}
1130
1169
  .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;gap:12px;padding:0 12px 12px}.dvt-advanced-body>.dvt-panel{box-shadow:none}
1131
- .dvt-health-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:8px}.dvt-health-grid>div{padding:9px 10px;border-radius:9px;background:var(--dsw-alias-bg-layer-2);border-left:3px solid var(--dsw-alias-border-l4)}.dvt-health-grid>div[data-status=ok]{border-left-color:var(--dsw-alias-state-success-primary)}.dvt-health-grid>div[data-status=warning],.dvt-health-grid>div[data-status=not_tested]{border-left-color:var(--dsw-alias-state-warn-primary)}.dvt-health-grid>div[data-status=error]{border-left-color:var(--dsw-alias-state-error-primary)}.dvt-health-grid span{font-size:10px;text-transform:capitalize}.dvt-health-grid strong{float:right;font-size:9px;text-transform:uppercase;color:var(--dsw-alias-label-secondary)}.dvt-health-grid p{clear:both;margin:5px 0 0;font-size:10px;line-height:1.4;color:var(--dsw-alias-label-secondary)}.dvt-loading{padding:24px;border-radius:12px;background:var(--dsw-alias-bg-layer-2);font-size:12px;color:var(--dsw-alias-label-secondary)}
1170
+ .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)}
1132
1171
  .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}
1133
1172
  @media(max-width:720px){.dvt-settings-footer{display:grid}.dvt-release{width:auto}.dvt-form-grid{grid-template-columns:1fr}.dvt-metrics{grid-template-columns:1fr}.dvt-artifact-meta{align-items:flex-start;flex-direction:column}.dvt-panel-title{flex-direction:column}}
1134
1173
  `
package/src/runtime.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  import { createHash, randomUUID } from 'node:crypto'
10
10
  import { readFile, rm, stat, writeFile } from 'node:fs/promises'
11
11
  import { basename, extname, join } from 'node:path'
12
+ import { fileURLToPath } from 'node:url'
12
13
  import type { Context } from '@deepseek-ai/cordis'
13
14
  import type { ResolvedCredential } from '@deepseek-ai/dsh-credentials'
14
15
  import { SaxesParser } from 'saxes'
@@ -49,6 +50,8 @@ import {
49
50
  import { PLUGIN_VERSION } from './version.ts'
50
51
 
51
52
  const SVG_NAMESPACE = 'http://www.w3.org/2000/svg'
53
+ const VISION_MODEL_TEST_IMAGE = fileURLToPath(new URL('../assets/vision-model-test.png', import.meta.url))
54
+ const VISION_MODEL_TEST_PROMPT = 'This is an explicit service readiness test. Reply with one short sentence confirming that you received the image.'
52
55
 
53
56
  function svgDocumentPathCount(svg: string): number | undefined {
54
57
  const parser = new SaxesParser({ xmlns: true })
@@ -444,9 +447,11 @@ export interface VisionToolkitHealthResult {
444
447
  artifactDirectory: HealthCheck
445
448
  tempDirectory: HealthCheck
446
449
  service: HealthCheck
450
+ model: HealthCheck
447
451
  }
448
452
  healthy: boolean
449
453
  connectionTested: boolean
454
+ modelTested: boolean
450
455
  }
451
456
 
452
457
  /** Shared per-call execution options. */
@@ -764,6 +769,10 @@ export class VisionToolkitRuntime {
764
769
  `credential ${this.config.provider.credential} is not configured; set it through DSH credentials`,
765
770
  )
766
771
  }
772
+ return this.visionEnv(resolved)
773
+ }
774
+
775
+ private visionEnv(resolved: ResolvedCredential): UpstreamEnvironment {
767
776
  return {
768
777
  VISION_API_KEY: resolved.value,
769
778
  VISION_BASE_URL: this.config.provider.baseUrl,
@@ -1690,8 +1699,8 @@ export class VisionToolkitRuntime {
1690
1699
  }
1691
1700
  }
1692
1701
 
1693
- /** health: inspect local readiness and optionally probe the configured `/models` endpoint. */
1694
- async health(testConnection: boolean, options: ToolCallOptions): Promise<VisionToolkitHealthResult> {
1702
+ /** Health: inspect local readiness, optionally probe `/models`, and explicitly test one real multimodal request. */
1703
+ async health(testConnection: boolean, options: ToolCallOptions, testModel = false): Promise<VisionToolkitHealthResult> {
1695
1704
  return this.runOperation('vision_toolkit_health', options, async (operation) => {
1696
1705
  const info = this.upstreamVersion
1697
1706
  const python: HealthCheck = { status: 'ok', detail: `${info.pythonVersion} via ${info.python}` }
@@ -1733,6 +1742,10 @@ export class VisionToolkitRuntime {
1733
1742
  status: 'not_tested',
1734
1743
  detail: 'Connection was not tested; pass testConnection=true to query the configured /models endpoint',
1735
1744
  }
1745
+ let model: HealthCheck = {
1746
+ status: 'not_tested',
1747
+ detail: 'Vision model was not tested; run an explicit model test to send the bundled diagnostic image',
1748
+ }
1736
1749
  if (testConnection) {
1737
1750
  if (resolvedCredential === undefined) {
1738
1751
  service = { status: 'error', detail: 'Connection test skipped because the configured credential is unavailable' }
@@ -1775,7 +1788,32 @@ export class VisionToolkitRuntime {
1775
1788
  }
1776
1789
  }
1777
1790
  }
1778
- const checks = { python, dependencies, chrome, credential, artifactDirectory, tempDirectory, service }
1791
+ if (testModel) {
1792
+ if (resolvedCredential === undefined) {
1793
+ model = { status: 'error', detail: 'Vision model test skipped because the configured credential is unavailable' }
1794
+ } else {
1795
+ try {
1796
+ const result = await this.runUpstream(
1797
+ 'glance',
1798
+ [VISION_MODEL_TEST_IMAGE, '-q', VISION_MODEL_TEST_PROMPT],
1799
+ operation,
1800
+ this.visionEnv(resolvedCredential),
1801
+ )
1802
+ if (result.stdout.trim().length === 0) {
1803
+ throw new VisionToolkitError('output', 'glance: vision API returned an empty description')
1804
+ }
1805
+ model = {
1806
+ status: 'ok',
1807
+ detail: `Vision model ${this.config.provider.model} completed a multimodal request`,
1808
+ }
1809
+ } catch (error) {
1810
+ if (operation.signal.aborted) throw error
1811
+ const detail = error instanceof Error ? error.message : String(error)
1812
+ model = { status: 'error', detail: `Vision model test failed: ${detail.slice(0, 600)}` }
1813
+ }
1814
+ }
1815
+ }
1816
+ const checks = { python, dependencies, chrome, credential, artifactDirectory, tempDirectory, service, model }
1779
1817
  const healthy = Object.values(checks).every(check => check.status !== 'error')
1780
1818
  return {
1781
1819
  pluginVersion: PLUGIN_VERSION,
@@ -1783,6 +1821,7 @@ export class VisionToolkitRuntime {
1783
1821
  checks,
1784
1822
  healthy,
1785
1823
  connectionTested: testConnection,
1824
+ modelTested: testModel,
1786
1825
  }
1787
1826
  })
1788
1827
  }
package/src/web.ts CHANGED
@@ -74,6 +74,7 @@ interface SaveRequest {
74
74
  interface HealthRequest {
75
75
  action: 'health'
76
76
  testConnection: boolean
77
+ testModel: boolean
77
78
  }
78
79
 
79
80
  interface CredentialRequest {
@@ -156,7 +157,10 @@ function parseRequest(value: unknown): SettingsRequest {
156
157
  if (!isRecord(value) || typeof value.action !== 'string') throw new TypeError('request action is required')
157
158
  if (value.action === 'health') {
158
159
  if (typeof value.testConnection !== 'boolean') throw new TypeError('health.testConnection must be boolean')
159
- return { action: 'health', testConnection: value.testConnection }
160
+ const testModel = value.testModel === undefined ? false : value.testModel
161
+ if (typeof testModel !== 'boolean') throw new TypeError('health.testModel must be boolean')
162
+ if (testModel && !value.testConnection) throw new TypeError('health.testModel requires health.testConnection')
163
+ return { action: 'health', testConnection: value.testConnection, testModel }
160
164
  }
161
165
  if (value.action === 'save') {
162
166
  if (!Number.isSafeInteger(value.expectedRevision) || (value.expectedRevision as number) < 0) {
@@ -294,7 +298,7 @@ export class VisionToolkitWebBackend {
294
298
  signal: controller.signal,
295
299
  workspace: process.cwd(),
296
300
  sessionId: 'vision-toolkit-settings',
297
- })
301
+ }, request.testModel)
298
302
  } finally {
299
303
  req.off('aborted', abort)
300
304
  req.socket.off('close', abort)