@anionex/dsh-vision-toolkit 0.1.6 → 0.1.7

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.
@@ -32,9 +32,17 @@ const en = {
32
32
  settingsIntro: 'Configure the pinned visual engineering runtime, its external vision endpoint, and local safety limits.',
33
33
  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.',
34
34
  provider: 'Vision service',
35
- providerHint: 'Choose the API protocol, then provide the service address, model, and credential used by online vision features.',
35
+ providerHint: 'Choose the API protocol, then provide the service address, model, and API key used by online vision features.',
36
36
  baseUrl: 'Base URL',
37
- credential: 'Credential reference',
37
+ apiKey: 'API key',
38
+ apiKeyPlaceholderMissing: 'Paste the API key',
39
+ apiKeyPlaceholderConfigured: 'Saved; leave blank to keep it',
40
+ apiKeyHint: 'The key is stored in DSH Credentials and is never shown again after saving.',
41
+ apiKeyLocked: 'The current key comes from a read-only source and cannot be replaced here.',
42
+ apiKeyBlank: 'The API key cannot contain only spaces.',
43
+ apiKeyInvalid: 'Paste only the key, without a variable name, quotes, spaces, or line breaks.',
44
+ credential: 'Credential name',
45
+ credentialHint: 'Advanced: the API key is stored under this name. Keep VISION_API_KEY unless another plugin configuration requires a different reference.',
38
46
  model: 'Model',
39
47
  protocol: 'API protocol',
40
48
  anthropicThinking: 'Anthropic thinking',
@@ -56,7 +64,7 @@ const en = {
56
64
  saving: 'Validating runtime…',
57
65
  reload: 'Reload',
58
66
  saved: 'Settings validated and applied.',
59
- readOnly: 'The active Settings provider is read-only.',
67
+ readOnly: 'Service settings are read-only. A writable API key can still be saved.',
60
68
  configured: 'Configured',
61
69
  missing: 'Missing',
62
70
  source: 'Source',
@@ -70,7 +78,7 @@ const en = {
70
78
  connectionHint: 'Connection testing explicitly sends the configured credential to GET /models. It uploads no image and creates no completion.',
71
79
  saveBeforeTesting: 'Save service changes before testing the connection.',
72
80
  advanced: 'Advanced settings',
73
- advancedHint: 'Provider compatibility, output language, resource limits, runtime source, Python, and additional readable directories.',
81
+ advancedHint: 'Credential name, provider compatibility, output language, resource limits, runtime source, Python, and additional readable directories.',
74
82
  pluginVersion: 'Plugin',
75
83
  upstreamVersion: 'Upstream',
76
84
  activeGeneration: 'Runtime generation',
@@ -164,9 +172,17 @@ const zh: Record<LocaleKey, string> = {
164
172
  settingsIntro: '在这里设置视觉模型服务、工具运行环境,以及图片和文件的本地访问范围。',
165
173
  externalNotice: '使用图像理解、目标定位、界面检测或文字识别等在线功能时,所选图片会发送到下方配置的视觉服务。图片裁剪、轮廓描摹、像素对比、主色提取、前景提取和网页截图均在本机完成,不会上传图片。',
166
174
  provider: '在线视觉服务',
167
- providerHint: '选择接口协议后,填写在线视觉功能使用的 API 地址、模型名称和密钥名称。',
175
+ providerHint: '选择接口协议后,填写在线视觉功能使用的 API 地址、模型名称和 API 密钥。',
168
176
  baseUrl: 'API 地址',
169
- credential: 'API 密钥名称',
177
+ apiKey: 'API 密钥',
178
+ apiKeyPlaceholderMissing: '粘贴 API 密钥',
179
+ apiKeyPlaceholderConfigured: '已保存;留空表示不修改',
180
+ apiKeyHint: '密钥会保存到 DSH 凭据存储,保存后不会在页面中回显。',
181
+ apiKeyLocked: '当前密钥来自只读配置,无法在此替换。',
182
+ apiKeyBlank: 'API 密钥不能只包含空格。',
183
+ apiKeyInvalid: '请只粘贴密钥本身,不要包含变量名、引号、空格或换行。',
184
+ credential: '凭据名称',
185
+ credentialHint: '高级用法:API 密钥会按此名称保存。除非其他插件配置要求不同名称,否则保持 VISION_API_KEY。',
170
186
  model: '模型名称',
171
187
  protocol: 'API 协议',
172
188
  anthropicThinking: 'Anthropic thinking',
@@ -188,7 +204,7 @@ const zh: Record<LocaleKey, string> = {
188
204
  saving: '正在检查并应用…',
189
205
  reload: '重新加载',
190
206
  saved: '设置已保存并生效。',
191
- readOnly: '当前设置由只读配置提供,无法在此修改。',
207
+ readOnly: '服务设置来自只读配置;如果 API 密钥可写,仍可在此保存密钥。',
192
208
  configured: '已就绪',
193
209
  missing: '未配置',
194
210
  source: '配置来源',
@@ -202,7 +218,7 @@ const zh: Record<LocaleKey, string> = {
202
218
  connectionHint: '“检查本地环境”只检查本机依赖和目录;“测试服务连接”会携带已配置的 API 密钥请求 /models,但不会上传图片或调用模型。',
203
219
  saveBeforeTesting: '修改服务配置后,请先保存,再测试连接。',
204
220
  advanced: '高级设置',
205
- advancedHint: '服务兼容参数、结果语言、资源限制、运行环境来源、Python 和额外可读目录。一般无需修改。',
221
+ advancedHint: '凭据名称、服务兼容参数、结果语言、资源限制、运行环境来源、Python 和额外可读目录。一般无需修改。',
206
222
  pluginVersion: '插件版本',
207
223
  upstreamVersion: '工具包版本',
208
224
  activeGeneration: '本次运行已应用',
@@ -779,17 +795,50 @@ export class VisionSettingsController {
779
795
  void this.load()
780
796
  }
781
797
 
782
- async save(value: SettingsValue, expectedRevision: number): Promise<void> {
798
+ async save(
799
+ value: SettingsValue,
800
+ expectedRevision: number,
801
+ credentialValue: string | undefined,
802
+ writeSettings: boolean,
803
+ ): Promise<boolean> {
783
804
  this.set({ ...this.state, action: 'save', error: undefined, message: undefined })
805
+ let snapshot = this.state.snapshot
784
806
  try {
785
- const snapshot = await apiRequest<SettingsSnapshot>({
786
- method: 'POST',
787
- headers: { 'Content-Type': 'application/json' },
788
- body: JSON.stringify({ action: 'save', expectedRevision, value }),
789
- })
807
+ if (writeSettings) {
808
+ snapshot = await apiRequest<SettingsSnapshot>({
809
+ method: 'POST',
810
+ headers: { 'Content-Type': 'application/json' },
811
+ body: JSON.stringify({ action: 'save', expectedRevision, value }),
812
+ })
813
+ }
814
+ if (snapshot === undefined) throw new Error('Vision Toolkit Settings are unavailable')
815
+ if (credentialValue !== undefined) {
816
+ try {
817
+ snapshot = await apiRequest<SettingsSnapshot>({
818
+ method: 'POST',
819
+ headers: { 'Content-Type': 'application/json' },
820
+ body: JSON.stringify({
821
+ action: 'credential',
822
+ expectedRevision: snapshot.settings.revision,
823
+ ref: snapshot.credential.ref,
824
+ value: credentialValue,
825
+ }),
826
+ })
827
+ } catch (error) {
828
+ this.set({
829
+ status: 'ready',
830
+ snapshot,
831
+ health: this.state.health,
832
+ error: error instanceof Error ? error.message : String(error),
833
+ })
834
+ return false
835
+ }
836
+ }
790
837
  this.set({ status: 'ready', snapshot, health: this.state.health, message: 'saved' })
838
+ return true
791
839
  } catch (error) {
792
840
  this.set({ ...this.state, action: undefined, error: error instanceof Error ? error.message : String(error) })
841
+ return false
793
842
  }
794
843
  }
795
844
 
@@ -852,6 +901,16 @@ function positiveInteger(raw: string, label: string, t: Translate): number {
852
901
  return value
853
902
  }
854
903
 
904
+ function apiKeyFailure(value: string, t: Translate): string | undefined {
905
+ if (value.length === 0) return undefined
906
+ const trimmed = value.trim()
907
+ if (trimmed.length === 0) return t('apiKeyBlank')
908
+ const quoted = trimmed.length > 1 && ['"', '\'', '`'].includes(trimmed[0] ?? '') && trimmed.endsWith(trimmed[0] ?? '')
909
+ const environmentLine = /^[A-Z][A-Z0-9_]*=[^=]/u.test(trimmed)
910
+ if (quoted || environmentLine || !/^[\x21-\x7E]+$/u.test(trimmed)) return t('apiKeyInvalid')
911
+ return undefined
912
+ }
913
+
855
914
  function valueOf(draft: Draft, t: Translate): SettingsValue {
856
915
  return {
857
916
  provider: {
@@ -959,6 +1018,7 @@ function LoadedSettings({ controller, t }: SettingsInjected) {
959
1018
  const state = useSyncExternalStore(controller.subscribe, controller.snapshot, controller.snapshot)
960
1019
  const snapshot = state.snapshot
961
1020
  const [draft, setDraft] = useState<Draft | undefined>(undefined)
1021
+ const [apiKey, setApiKey] = useState('')
962
1022
  const [draftError, setDraftError] = useState<string | undefined>(undefined)
963
1023
 
964
1024
  useEffect(() => { if (state.status === 'idle') void controller.load() }, [controller, state.status])
@@ -976,13 +1036,27 @@ function LoadedSettings({ controller, t }: SettingsInjected) {
976
1036
  const update = <K extends keyof Draft>(key: K, value: Draft[K]): void => setDraft(current => current === undefined ? current : { ...current, [key]: value })
977
1037
  const save = (): void => {
978
1038
  try {
1039
+ const keyFailure = apiKeyFailure(apiKey, t)
1040
+ if (keyFailure !== undefined) {
1041
+ setDraftError(keyFailure)
1042
+ return
1043
+ }
1044
+ const credentialValue = apiKey.length === 0 ? undefined : apiKey.trim()
979
1045
  setDraftError(undefined)
980
- void controller.save(valueOf(draft, t), snapshot.settings.revision)
1046
+ void controller.save(
1047
+ valueOf(draft, t),
1048
+ snapshot.settings.revision,
1049
+ credentialValue,
1050
+ snapshot.writable,
1051
+ ).then(saved => { if (saved) setApiKey('') })
981
1052
  } catch (error) {
982
1053
  setDraftError(error instanceof Error ? error.message : String(error))
983
1054
  }
984
1055
  }
985
1056
  const busy = state.action !== undefined
1057
+ const credentialMatchesSnapshot = draft.credential.trim() === snapshot.credential.ref
1058
+ const keyLocked = credentialMatchesSnapshot && !snapshot.credential.writable
1059
+ const canSave = snapshot.writable || (apiKey.length > 0 && !keyLocked)
986
1060
  const runtimeErrorTitle = snapshot.runtime.ready ? t('runtimeCandidateRejected') : t('runtimeUnavailable')
987
1061
 
988
1062
  return (
@@ -996,14 +1070,14 @@ function LoadedSettings({ controller, t }: SettingsInjected) {
996
1070
 
997
1071
  <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>
998
1072
  <div className="dvt-form-grid">
999
- <Field label={t('protocol')}><select 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>
1000
- <Field label={t('baseUrl')}><Input value={draft.baseUrl} onChange={(event) => { update('baseUrl', event.target.value) }} /></Field>
1001
- <Field label={t('model')}><Input value={draft.model} onChange={(event) => { update('model', event.target.value) }} /></Field>
1002
- <Field label={t('credential')} hint={snapshot.credential.source === undefined ? undefined : t('sourceHint', { source: t('source'), value: credentialSource(snapshot.credential.source, t) })}><Input value={draft.credential} onChange={(event) => { update('credential', event.target.value) }} /></Field>
1073
+ <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>
1074
+ <Field label={t('baseUrl')}><Input disabled={!snapshot.writable || busy} value={draft.baseUrl} onChange={(event) => { update('baseUrl', event.target.value) }} /></Field>
1075
+ <Field label={t('model')}><Input disabled={!snapshot.writable || busy} value={draft.model} onChange={(event) => { update('model', event.target.value) }} /></Field>
1076
+ <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>
1003
1077
  </div>
1004
1078
  </section>
1005
1079
 
1006
- <div className="dvt-save-row"><Button variant="primary" disabled={!snapshot.writable || busy} onClick={save}>{state.action === 'save' ? t('saving') : t('save')}</Button><Button variant="outline" disabled={busy} onClick={() => { void controller.load() }}>{t('reload')}</Button></div>
1080
+ <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>
1007
1081
 
1008
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>
1009
1083
  <p className="dvt-muted">{t('saveBeforeTesting')}</p>
@@ -1014,6 +1088,7 @@ function LoadedSettings({ controller, t }: SettingsInjected) {
1014
1088
  <summary><span><strong>{t('advanced')}</strong><small>{t('advancedHint')}</small></span><span className="dvt-details-chevron" aria-hidden="true">⌄</span></summary>
1015
1089
  <div className="dvt-advanced-body">
1016
1090
  <section className="dvt-panel"><div className="dvt-panel-title"><h3>{t('provider')}</h3></div><div className="dvt-form-grid">
1091
+ <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>
1017
1092
  {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}
1018
1093
  <Field label={t('userAgent')}><Input value={draft.userAgent} onChange={(event) => { update('userAgent', event.target.value) }} /></Field>
1019
1094
  </div></section>
package/src/web.ts CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  import type { IncomingMessage, ServerResponse } from 'node:http'
9
9
  import type { Context } from '@deepseek-ai/cordis'
10
- import type { CredentialInfo } from '@deepseek-ai/dsh-credentials'
10
+ import type { CredentialInfo, CredentialRef } from '@deepseek-ai/dsh-credentials'
11
11
  import { credentialRef } from '@deepseek-ai/dsh-credentials'
12
12
  import { SettingsConflictError, type SettingsDescriptor } from '@deepseek-ai/dsh-settings'
13
13
  // Type-only import activates the optional webServer Context declaration.
@@ -70,7 +70,14 @@ interface HealthRequest {
70
70
  testConnection: boolean
71
71
  }
72
72
 
73
- type SettingsRequest = SaveRequest | HealthRequest
73
+ interface CredentialRequest {
74
+ action: 'credential'
75
+ expectedRevision: number
76
+ ref: CredentialRef
77
+ value: string
78
+ }
79
+
80
+ type SettingsRequest = SaveRequest | HealthRequest | CredentialRequest
74
81
 
75
82
  interface JsonError {
76
83
  ok: false
@@ -101,6 +108,8 @@ function isRecord(value: unknown): value is Record<string, unknown> {
101
108
  return typeof value === 'object' && value !== null && !Array.isArray(value)
102
109
  }
103
110
 
111
+ class CredentialReferenceConflictError extends Error {}
112
+
104
113
  function descriptorOf(ctx: Context): SettingsDescriptor {
105
114
  const descriptor = ctx.settings.describe().find(row => row.ns === VISION_TOOLKIT_SETTINGS_NAMESPACE)
106
115
  if (descriptor === undefined) throw new Error('vision-toolkit Settings namespace is not registered')
@@ -154,6 +163,27 @@ function parseRequest(value: unknown): SettingsRequest {
154
163
  value: value.value as VisionToolkitConfig,
155
164
  }
156
165
  }
166
+ if (value.action === 'credential') {
167
+ if (!Number.isSafeInteger(value.expectedRevision) || (value.expectedRevision as number) < 0) {
168
+ throw new TypeError('credential.expectedRevision must be a non-negative integer')
169
+ }
170
+ if (typeof value.ref !== 'string') throw new TypeError('credential.ref must be a string')
171
+ if (typeof value.value !== 'string') throw new TypeError('credential.value must be a string')
172
+ const secret = value.value.trim()
173
+ if (secret.length === 0) throw new TypeError('API key cannot be blank')
174
+ const first = secret[0]
175
+ const quoted = secret.length > 1 && (first === '"' || first === '\'' || first === '`') && secret.endsWith(first)
176
+ const environmentLine = /^[A-Z][A-Z0-9_]*=[^=]/u.test(secret)
177
+ if (quoted || environmentLine || !/^[\x21-\x7E]+$/u.test(secret)) {
178
+ throw new TypeError('paste only the API key, without a variable name, quotes, spaces, or line breaks')
179
+ }
180
+ return {
181
+ action: 'credential',
182
+ expectedRevision: value.expectedRevision as number,
183
+ ref: credentialRef(value.ref),
184
+ value: secret,
185
+ }
186
+ }
157
187
  throw new TypeError(`unsupported action: ${value.action}`)
158
188
  }
159
189
 
@@ -227,6 +257,26 @@ export class VisionToolkitWebBackend {
227
257
  return this.snapshot()
228
258
  }
229
259
 
260
+ private async saveCredential(request: CredentialRequest): Promise<VisionToolkitSettingsSnapshot> {
261
+ const descriptor = descriptorOf(this.ctx)
262
+ if (descriptor.revision !== request.expectedRevision) {
263
+ throw new SettingsConflictError(
264
+ VISION_TOOLKIT_SETTINGS_NAMESPACE,
265
+ request.expectedRevision,
266
+ descriptor.revision,
267
+ )
268
+ }
269
+ const resolved = resolveConfig(descriptor.value as VisionToolkitConfig)
270
+ const currentRef = credentialRef(String(resolved.provider.credential))
271
+ if (currentRef !== request.ref) {
272
+ throw new CredentialReferenceConflictError(
273
+ `credential reference changed from "${request.ref}" to "${currentRef}"; reload Settings and try again`,
274
+ )
275
+ }
276
+ await this.ctx.credentials.set(currentRef, request.value)
277
+ return this.snapshot()
278
+ }
279
+
230
280
  private async health(request: HealthRequest, req: IncomingMessage): Promise<VisionToolkitHealthResult> {
231
281
  if (!this.manager.ready) throw new Error('runtime is not ready; fix Settings and save a valid configuration first')
232
282
  const controller = new AbortController()
@@ -273,15 +323,30 @@ export class VisionToolkitWebBackend {
273
323
  return
274
324
  }
275
325
  try {
276
- if (parsed.action === 'health') {
277
- responseJson(res, 200, { ok: true, value: await this.health(parsed, req) })
278
- } else {
279
- responseJson(res, 200, { ok: true, value: await this.save(parsed) })
326
+ switch (parsed.action) {
327
+ case 'health':
328
+ responseJson(res, 200, { ok: true, value: await this.health(parsed, req) })
329
+ break
330
+ case 'save':
331
+ responseJson(res, 200, { ok: true, value: await this.save(parsed) })
332
+ break
333
+ case 'credential':
334
+ responseJson(res, 200, { ok: true, value: await this.saveCredential(parsed) })
335
+ break
280
336
  }
281
337
  } catch (error) {
282
- const conflict = error instanceof SettingsConflictError
283
- const code = conflict ? 'settings-conflict' : parsed.action === 'health' ? 'health-failed' : 'settings-rejected'
284
- const status = conflict ? 409 : parsed.action === 'health' ? 503 : 400
338
+ const settingsConflict = error instanceof SettingsConflictError
339
+ const credentialConflict = error instanceof CredentialReferenceConflictError
340
+ const code = settingsConflict
341
+ ? 'settings-conflict'
342
+ : credentialConflict
343
+ ? 'credential-conflict'
344
+ : parsed.action === 'health'
345
+ ? 'health-failed'
346
+ : parsed.action === 'credential'
347
+ ? 'credential-rejected'
348
+ : 'settings-rejected'
349
+ const status = settingsConflict || credentialConflict ? 409 : parsed.action === 'health' ? 503 : 400
285
350
  this.ctx.logger.warn('dsh-vision-toolkit Web action=%s failed: %s', parsed.action, publicMessage(error))
286
351
  requestError(res, status, code, publicMessage(error))
287
352
  }