@wxip/dsh-sub2api 0.2.2

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.
@@ -0,0 +1,847 @@
1
+ /**
2
+ * Settings section for dsh-sub2api.
3
+ *
4
+ * One base URL, three provider cards (OpenAI / Claude / Grok), each
5
+ * with a key field and a structured model catalog. Keys are written to the
6
+ * harness credential store through the host HTTP bridge; the base URL and
7
+ * model catalogs land in the `llm-sub2api:` settings section.
8
+ *
9
+ * @module dsh-sub2api/client/settings
10
+ */
11
+
12
+ import { useCallback, useEffect, useRef, useState } from 'react'
13
+ import { ProviderIcon } from './icons.tsx'
14
+ import type { ProviderIconName } from './icons.tsx'
15
+
16
+ const BASE = '/plugins/dsh-sub2api'
17
+ const MODELS_DEV_API = 'https://models.dev/api.json'
18
+
19
+ /** Model-row controls matching the official Models page. */
20
+ function IconTrash() {
21
+ return (
22
+ <svg width="18" height="18" viewBox="0 0 20 20" fill="none" aria-hidden>
23
+ <path
24
+ d="M3.5 5.25h13M8 5.25V3.5h4v1.75M5.25 5.25l.8 10.1c.05.65.6 1.15 1.25 1.15h5.4c.65 0 1.2-.5 1.25-1.15l.8-10.1M8.25 8v5.75M11.75 8v5.75"
25
+ stroke="currentColor"
26
+ strokeWidth="1.5"
27
+ strokeLinecap="round"
28
+ strokeLinejoin="round"
29
+ />
30
+ </svg>
31
+ )
32
+ }
33
+
34
+ function IconChevron({ expanded }: { expanded: boolean }) {
35
+ return (
36
+ <svg
37
+ width="20"
38
+ height="20"
39
+ viewBox="0 0 20 20"
40
+ fill="none"
41
+ aria-hidden
42
+ style={{ transform: expanded ? 'rotate(90deg)' : undefined, transition: 'transform 120ms ease' }}
43
+ >
44
+ <path d="m7.5 4.75 5.25 5.25-5.25 5.25" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
45
+ </svg>
46
+ )
47
+ }
48
+
49
+ interface ProviderDefinition {
50
+ key: string
51
+ label: string
52
+ icon: ProviderIconName
53
+ placeholder: string
54
+ modelsDevProvider: string
55
+ }
56
+
57
+ const PROVIDERS: ProviderDefinition[] = [
58
+ { key: 'openai', label: 'OpenAI', icon: 'openai', placeholder: 'sk-…', modelsDevProvider: 'openai' },
59
+ { key: 'claude', label: 'Claude', icon: 'claude', placeholder: 'sk-ant-…', modelsDevProvider: 'anthropic' },
60
+ { key: 'grok', label: 'Grok', icon: 'grok', placeholder: 'xai-…', modelsDevProvider: 'xai' },
61
+ ]
62
+
63
+ const CSS_ID = 'dsh-sub2api/settings.css'
64
+
65
+ const css = `
66
+ .s2a_section{max-width:720px;color:var(--dsw-alias-label-primary);flex-direction:column;gap:12px;display:flex}
67
+ .s2a_title{color:var(--dsw-alias-label-primary);margin:0;font-size:16px;font-weight:500;line-height:24px}
68
+ .s2a_intro{color:var(--dsw-alias-label-tertiary);margin:0;font-size:13px;line-height:20px}
69
+ .s2a_notice{color:var(--dsw-alias-state-warn-label);margin:0;font-size:12px;line-height:18px}
70
+ .s2a_field{flex-direction:column;gap:5px;display:flex}
71
+ .s2a_fieldLabel{color:var(--dsw-alias-label-secondary);font-size:12px;font-weight:500;line-height:18px}
72
+ .s2a_input{box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2);width:100%;height:32px;font:inherit;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 10px;font-size:13px;line-height:22px}
73
+ .s2a_input:focus{border-color:var(--dsw-alias-brand-primary);outline:none}
74
+ .s2a_input::placeholder{color:var(--dsw-alias-label-dimmed)}
75
+ .s2a_rows{flex-direction:column;gap:10px;margin:4px 0 0;padding:0;list-style:none;display:flex}
76
+ .s2a_rowCard{border:1px solid var(--dsw-alias-border-l2);border-radius:8px;flex-direction:column;gap:12px;padding:14px 16px;display:flex}
77
+ .s2a_rowHead{align-items:center;gap:10px;display:flex}
78
+ .s2a_rowIdentity{flex:1;align-items:center;gap:8px;min-width:0;display:inline-flex}
79
+ .s2a_rowName{color:var(--dsw-alias-label-primary);font-size:14px;font-weight:500;line-height:22px}
80
+ .s2a_rowTag{border:1px solid var(--dsw-alias-border-l3);color:var(--dsw-alias-label-secondary);border-radius:4px;flex:none;padding:1px 6px;font-size:11px;line-height:16px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
81
+ .s2a_editor{background:var(--dsw-alias-bg-module-platform);border-radius:8px;flex-direction:column;gap:12px;padding:12px 14px;display:flex}
82
+ .s2a_rowActions,.s2a_modelActions{align-items:center;gap:4px;margin-left:auto;display:inline-flex}
83
+ .s2a_btn,.s2a_primary{box-sizing:border-box;height:32px;font:inherit;cursor:pointer;border:none;justify-content:center;align-items:center;gap:4px;font-size:13px;line-height:20px;display:inline-flex}
84
+ .s2a_btn,.s2a_primary{border-radius:16px;padding:0 14px}
85
+ .s2a_primary{background:var(--dsw-alias-button-primary-fill);color:var(--dsw-alias-label-primary-foreground)}
86
+ .s2a_primary:hover:not(:disabled){background:var(--dsw-alias-button-primary-hover)}
87
+ .s2a_btn{border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-primary);background:transparent}
88
+ .s2a_btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}
89
+ .s2a_btn:disabled,.s2a_primary:disabled{opacity:.4;cursor:default}
90
+ .s2a_btn:focus-visible,.s2a_primary:focus-visible{box-shadow:0 0 0 2px var(--dsw-alias-border-l3);outline:none}
91
+ /* Square, label-free icon affordance matching the official Models page: the
92
+ row's inputs carry the meaning, the trash glyph announces deletion. */
93
+ .s2a_iconBtn{box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border:none;border-radius:6px;background:transparent;color:var(--dsw-alias-label-tertiary);cursor:pointer;padding:0}
94
+ .s2a_iconBtn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
95
+ .s2a_iconBtn:disabled{opacity:.4;cursor:default}
96
+ .s2a_iconBtn:focus-visible{box-shadow:0 0 0 2px var(--dsw-alias-border-l3);outline:none}
97
+ .s2a_trash:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover-danger);color:var(--dsw-alias-state-error-primary)}
98
+ .s2a_models{flex-direction:column;gap:10px;display:flex}
99
+ .s2a_modelItem{border:1px solid var(--dsw-alias-border-l2);border-radius:8px;overflow:hidden;background:var(--dsw-alias-bg-layer-1)}
100
+ .s2a_modelSummary{grid-template-columns:minmax(0,1fr) minmax(0,.72fr) 44px 32px;align-items:center;gap:10px;padding:9px 10px 9px 12px;display:grid}
101
+ .s2a_modelSummary .s2a_input{height:38px;border-radius:7px;padding:0 12px;font-size:14px}
102
+ .s2a_expandBtn{width:44px;height:38px;border-radius:7px;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary)}
103
+ .s2a_modelDetails{border-top:1px solid var(--dsw-alias-border-l2);grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:10px 12px;padding:10px 12px 12px;display:grid}
104
+ .s2a_modelDetails .s2a_fieldLabel{font-size:12px;font-weight:400}
105
+ .s2a_reasoningField{grid-column:1/-1}
106
+ .s2a_modelEmpty{border:1px solid var(--dsw-alias-border-l2);border-radius:8px;color:var(--dsw-alias-label-tertiary);margin:0;padding:16px 10px;text-align:center;font-size:12px;line-height:18px}
107
+ .s2a_modelFooter{align-items:center;justify-content:space-between;gap:8px;display:flex}
108
+ .s2a_modelSource{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px}
109
+ .s2a_modelSource a{color:inherit;text-decoration:underline;text-underline-offset:2px}
110
+ .s2a_actions{position:sticky;bottom:0;z-index:5;align-items:center;justify-content:flex-end;gap:8px;margin-top:4px;padding:12px 0;background:var(--dsw-alias-bg-layer-1,#fff);border-top:1px solid var(--dsw-alias-border-l2,#ddd);display:flex}
111
+ .s2a_actions::after{content:"";position:absolute;top:100%;left:0;right:0;height:var(--s2a-footer-inset,24px);background:inherit}
112
+ .s2a_toast{position:fixed;top:24px;right:24px;z-index:10000;display:flex;align-items:flex-start;gap:12px;box-sizing:border-box;max-width:min(480px,calc(100vw - 32px));max-height:40vh;overflow:auto;padding:12px 14px;border:1px solid var(--dsw-alias-border-l2,#ddd);border-radius:10px;background:var(--dsw-alias-bg-layer-1,#fff);box-shadow:0 6px 24px #0002}.s2a_toast .s2a_status{overflow-wrap:anywhere;flex:1}.s2a_toast button{flex-shrink:0}
113
+ .s2a_status{margin:0;font-size:12px;line-height:18px;white-space:pre-wrap;color:var(--dsw-alias-label-secondary)}
114
+ .s2a_statusOk{color:var(--dsw-alias-state-success-primary)}
115
+ .s2a_statusErr{color:var(--dsw-alias-state-error-primary)}
116
+ @media(max-width:620px){
117
+ .s2a_section,.s2a_rowIdentity{min-width:0}.s2a_rowCard{padding:10px 8px}.s2a_editor{padding:8px 0}
118
+ .s2a_rowHead{align-items:flex-start;flex-wrap:wrap}.s2a_rowIdentity{flex-wrap:wrap}.s2a_rowTag{box-sizing:border-box;width:100%;max-width:100%;min-width:0;flex:1 1 100%;white-space:normal;overflow-wrap:anywhere}.s2a_rowActions{width:100%;margin-left:0;flex-direction:column;align-items:stretch}.s2a_rowActions .s2a_btn{width:100%}
119
+ .s2a_modelSummary{grid-template-columns:minmax(0,1fr) 44px 32px;gap:8px;padding:8px}.s2a_modelSummary>div:nth-child(2){grid-column:1/2;grid-row:2}.s2a_modelSummary>.s2a_expandBtn{grid-column:2;grid-row:1/3}.s2a_modelSummary>.s2a_trash{grid-column:3;grid-row:1/3}
120
+ .s2a_modelDetails{grid-template-columns:minmax(0,1fr)}.s2a_reasoningField{grid-column:auto}
121
+ .s2a_modelFooter{align-items:stretch;flex-direction:column}.s2a_modelActions{width:100%;margin-left:0;align-items:stretch;flex-direction:column}.s2a_modelActions .s2a_btn{width:100%;padding:0 8px}
122
+ }
123
+ `
124
+
125
+ function ensureCss(): void {
126
+ if (typeof document === 'undefined') return
127
+ if (document.querySelector(`style[data-plugin-css=${JSON.stringify(CSS_ID)}]`)) return
128
+ const tag = document.createElement('style')
129
+ tag.dataset.plugin = 'dsh-sub2api'
130
+ tag.dataset.pluginCss = CSS_ID
131
+ tag.textContent = css
132
+ document.head.appendChild(tag)
133
+ }
134
+
135
+ interface CatalogModel {
136
+ id: string
137
+ name?: string
138
+ contextWindow?: number
139
+ maxTokens?: number
140
+ input?: Array<'text' | 'image'>
141
+ reasoningEfforts?: string[]
142
+ }
143
+
144
+ interface ModelRow {
145
+ rowId: number
146
+ id: string
147
+ name: string
148
+ contextWindow: string
149
+ maxTokens: string
150
+ /** '' = 自动(按模型 ID 推断); 'text' = 仅文本; 'text-image' = 文本 + 图片 */
151
+ input: string
152
+ /** '' = 自动(未设置,按路由默认); 'on' = 支持(档位见 effortLevels); 'off' = 不支持 */
153
+ reasoning: string
154
+ /** 该模型实际支持的推理档位(reasoning === 'on' 时保存到配置) */
155
+ effortLevels: string
156
+ inputEdited?: boolean
157
+ reasoningEdited?: boolean
158
+ }
159
+
160
+ const DEFAULT_REASONING_LEVELS = ['low', 'medium', 'high']
161
+
162
+ interface ProviderState {
163
+ key: string
164
+ keyConfigured: boolean
165
+ models: ModelRow[]
166
+ }
167
+
168
+ interface ImageToolModelRef {
169
+ provider: string
170
+ model: string
171
+ }
172
+
173
+ interface ImageToolsState {
174
+ generate: ImageToolModelRef
175
+ }
176
+
177
+ interface ConfigState {
178
+ baseURL: string
179
+ catalogFormat?: 'structured-v1'
180
+ providers: Record<string, { keyConfigured: boolean; models: Array<CatalogModel | string> }>
181
+ tools?: {
182
+ generate?: ImageToolModelRef
183
+ }
184
+ }
185
+
186
+ interface ModelsDevModel {
187
+ id?: string
188
+ name?: string
189
+ /** Whether the model accepts image input (models.dev `attachment`). */
190
+ attachment?: boolean
191
+ /** Explicit modality list when the entry carries one. */
192
+ modalities?: { input?: string[] }
193
+ reasoning?: boolean
194
+ /** models.dev JSON key is `reasoning_options` (snake_case). */
195
+ reasoning_options?: Array<{ type?: string; values?: string[] }>
196
+ limit?: { context?: number; output?: number }
197
+ }
198
+
199
+ interface ModelsDevProvider {
200
+ models?: Record<string, ModelsDevModel>
201
+ }
202
+
203
+ type ModelsDevCatalog = Record<string, ModelsDevProvider>
204
+
205
+ let nextRowId = 1
206
+ let modelsDevRequest: Promise<ModelsDevCatalog> | undefined
207
+
208
+ function modelRow(model: CatalogModel | string = { id: '' }): ModelRow {
209
+ if (typeof model === 'string') {
210
+ const [id = '', name = '', contextWindow = ''] = model.split('|')
211
+ return { rowId: nextRowId++, id: id.trim(), name: name.trim(), contextWindow: contextWindow.trim(), maxTokens: '', input: '', reasoning: '', effortLevels: '' }
212
+ }
213
+ const reasoningEfforts = model.reasoningEfforts
214
+ return {
215
+ rowId: nextRowId++,
216
+ id: model.id,
217
+ name: model.name ?? '',
218
+ contextWindow: model.contextWindow !== undefined ? String(model.contextWindow) : '',
219
+ maxTokens: model.maxTokens !== undefined ? String(model.maxTokens) : '',
220
+ input: model.input === undefined || model.input.length === 0 ? '' : model.input.includes('image') ? 'text-image' : 'text',
221
+ reasoning: reasoningEfforts === undefined ? '' : reasoningEfforts.length === 0 ? 'off' : 'on',
222
+ effortLevels: reasoningEfforts !== undefined && reasoningEfforts.length > 0 ? reasoningEfforts.join(', ') : '',
223
+ }
224
+ }
225
+
226
+ function loadModelsDev(): Promise<ModelsDevCatalog> {
227
+ modelsDevRequest ??= fetch(MODELS_DEV_API)
228
+ .then(async (response) => {
229
+ if (!response.ok) throw new Error(`models.dev HTTP ${response.status}`)
230
+ return await response.json() as ModelsDevCatalog
231
+ })
232
+ .catch((error) => {
233
+ modelsDevRequest = undefined
234
+ throw error
235
+ })
236
+ return modelsDevRequest
237
+ }
238
+
239
+ function canonicalModelsDevProvider(id: string): string | undefined {
240
+ const normalized = id.toLowerCase()
241
+ if (/^(gpt|o[134]|codex)/.test(normalized)) return 'openai'
242
+ if (normalized.startsWith('claude')) return 'anthropic'
243
+ if (normalized.startsWith('grok')) return 'xai'
244
+ if (normalized.startsWith('gemini')) return 'google'
245
+ if (normalized.startsWith('deepseek')) return 'deepseek'
246
+ return undefined
247
+ }
248
+
249
+ function officialModel(catalog: ModelsDevCatalog, def: ProviderDefinition, id: string): ModelsDevModel | undefined {
250
+ const preferredProviders = [canonicalModelsDevProvider(id), def.modelsDevProvider].filter((provider, index, all): provider is string => (
251
+ provider !== undefined && all.indexOf(provider) === index
252
+ ))
253
+ for (const provider of preferredProviders) {
254
+ const models = catalog[provider]?.models
255
+ const match = models?.[id] ?? (models !== undefined ? Object.values(models).find((model) => model.id === id) : undefined)
256
+ if (match !== undefined) return match
257
+ }
258
+ for (const provider of Object.values(catalog)) {
259
+ const models = provider.models
260
+ const match = models?.[id] ?? (models !== undefined ? Object.values(models).find((model) => model.id === id) : undefined)
261
+ if (match !== undefined) return match
262
+ }
263
+ return undefined
264
+ }
265
+
266
+ /** The concrete reasoning-effort levels a model advertises, or undefined. */
267
+ function officialEffortValues(official: ModelsDevModel): string[] | undefined {
268
+ const effort = official.reasoning_options?.find((option) => option.type === 'effort')
269
+ const values = effort?.values
270
+ if (values === undefined || values.length === 0) return undefined
271
+ return values.filter((value): value is string => typeof value === 'string' && value.length > 0)
272
+ }
273
+
274
+ /**
275
+ * Image-input modality derived from models.dev: an explicit modality list
276
+ * wins, then the `attachment` flag. Absent means the entry says nothing, and
277
+ * the row keeps "auto" (the adapter infers from the model id).
278
+ */
279
+ function officialInput(official: ModelsDevModel): 'text' | 'text-image' | undefined {
280
+ const input = official.modalities?.input
281
+ if (Array.isArray(input) && input.includes('image')) return 'text-image'
282
+ if (typeof official.attachment === 'boolean') return official.attachment ? 'text-image' : 'text'
283
+ return undefined
284
+ }
285
+
286
+ function applyOfficialDefaults(rows: ModelRow[], def: ProviderDefinition, catalog: ModelsDevCatalog): { rows: ModelRow[]; filled: number } {
287
+ let filled = 0
288
+ const next = rows.map((row) => {
289
+ const official = officialModel(catalog, def, row.id.trim())
290
+ if (official === undefined) return row
291
+ const name = row.name.trim().length === 0 && typeof official.name === 'string' ? official.name : row.name
292
+ const officialContext = official.limit?.context
293
+ const contextWindow = row.contextWindow.length === 0 && Number.isSafeInteger(officialContext) && (officialContext ?? 0) > 0
294
+ ? String(officialContext)
295
+ : row.contextWindow
296
+ const officialOutput = official.limit?.output
297
+ const maxTokens = row.maxTokens.length === 0 && Number.isSafeInteger(officialOutput) && (officialOutput ?? 0) > 0
298
+ ? String(officialOutput)
299
+ : row.maxTokens
300
+ // 图片输入:models.dev 有数据就自动定,用户不用选
301
+ const input = !row.inputEdited && row.input.length === 0 && officialInput(official) !== undefined ? officialInput(official)! : row.input
302
+ const officialEfforts = officialEffortValues(official)
303
+ let reasoning = row.reasoning
304
+ let effortLevels = row.effortLevels
305
+ if (!row.reasoningEdited && reasoning.length === 0) {
306
+ if (officialEfforts !== undefined && officialEfforts.length > 0) {
307
+ reasoning = 'on'
308
+ effortLevels = officialEfforts.join(', ')
309
+ } else if (typeof official.reasoning === 'boolean') {
310
+ reasoning = official.reasoning ? 'on' : 'off'
311
+ effortLevels = official.reasoning ? DEFAULT_REASONING_LEVELS.join(', ') : ''
312
+ }
313
+ }
314
+ if (
315
+ name !== row.name || contextWindow !== row.contextWindow || maxTokens !== row.maxTokens
316
+ || input !== row.input
317
+ || reasoning !== row.reasoning || effortLevels !== row.effortLevels
318
+ ) filled++
319
+ return { ...row, name, contextWindow, maxTokens, input, reasoning, effortLevels }
320
+ })
321
+ return { rows: next, filled }
322
+ }
323
+
324
+ async function api<T>(path: string, init?: RequestInit): Promise<T> {
325
+ const response = await fetch(path, {
326
+ ...init,
327
+ headers: { 'content-type': 'application/json', ...init?.headers },
328
+ })
329
+ const payload = await response.json().catch(() => ({})) as Record<string, unknown>
330
+ if (!response.ok) {
331
+ const message = typeof payload.error === 'string' ? payload.error : `HTTP ${response.status}`
332
+ throw new Error(message)
333
+ }
334
+ return payload as T
335
+ }
336
+
337
+ function emptyProvider(): ProviderState {
338
+ return { key: '', keyConfigured: false, models: [] }
339
+ }
340
+
341
+ function emptyToolRef(): ImageToolModelRef {
342
+ return { provider: '', model: '' }
343
+ }
344
+
345
+ function emptyTools(): ImageToolsState {
346
+ return { generate: emptyToolRef() }
347
+ }
348
+
349
+ function toolRefFromConfig(value: ImageToolModelRef | undefined): ImageToolModelRef {
350
+ return {
351
+ provider: PROVIDERS.some(def => def.key === value?.provider) ? value!.provider : '',
352
+ model: PROVIDERS.some(def => def.key === value?.provider) ? value!.model : '',
353
+ }
354
+ }
355
+
356
+ function toolOptions(providers: Record<string, ProviderState>): Array<{ value: string; label: string; provider: string; model: string }> {
357
+ const options: Array<{ value: string; label: string; provider: string; model: string }> = []
358
+ for (const def of PROVIDERS) {
359
+ const provider = providers[def.key]
360
+ if (provider === undefined) continue
361
+ for (const row of provider.models) {
362
+ const id = row.id.trim()
363
+ if (id.length === 0) continue
364
+ const name = row.name.trim()
365
+ options.push({
366
+ value: `${def.key}:${id}`,
367
+ label: `${def.label} / ${name.length > 0 ? `${name} (${id})` : id}`,
368
+ provider: def.key,
369
+ model: id,
370
+ })
371
+ }
372
+ }
373
+ return options
374
+ }
375
+
376
+ function serializeToolRef(ref: ImageToolModelRef): ImageToolModelRef | undefined {
377
+ const provider = ref.provider.trim()
378
+ const model = ref.model.trim()
379
+ if (provider.length === 0 || model.length === 0) return undefined
380
+ return { provider, model }
381
+ }
382
+
383
+ export function Sub2ApiSettings() {
384
+ const sectionRef = useRef<HTMLDivElement>(null)
385
+ useEffect(() => {
386
+ const section = sectionRef.current
387
+ if (!section) return
388
+ // DSH's settings scroller has bottom padding; extend the opaque footer
389
+ // through that inset so scrolling rows cannot peek out underneath it.
390
+ let scroller = section.parentElement
391
+ while (scroller && !/auto|scroll/.test(getComputedStyle(scroller).overflowY)) scroller = scroller.parentElement
392
+ if (!scroller) return
393
+ const update = () => section.style.setProperty('--s2a-footer-inset', getComputedStyle(scroller).paddingBottom)
394
+ update()
395
+ const observer = new ResizeObserver(update)
396
+ observer.observe(scroller)
397
+ return () => observer.disconnect()
398
+ }, [])
399
+ const [baseURL, setBaseURL] = useState('')
400
+ const [providers, setProviders] = useState<Record<string, ProviderState>>({})
401
+ const [tools, setTools] = useState<ImageToolsState>(emptyTools())
402
+ const [structuredConfig, setStructuredConfig] = useState(false)
403
+ const [expandedModels, setExpandedModels] = useState<Set<number>>(() => new Set())
404
+ const [busy, setBusy] = useState('')
405
+ const [message, setMessage] = useState('')
406
+ const [error, setError] = useState('')
407
+
408
+ useEffect(() => {
409
+ if (!message && !error) return
410
+ const timer = window.setTimeout(() => { setMessage(''); setError('') }, error ? 10000 : 6000)
411
+ return () => window.clearTimeout(timer)
412
+ }, [message, error])
413
+
414
+ useEffect(() => {
415
+ ensureCss()
416
+ api<ConfigState>(`${BASE}/config`)
417
+ .then(async (cfg) => {
418
+ setBaseURL(cfg.baseURL ?? '')
419
+ setStructuredConfig(cfg.catalogFormat === 'structured-v1')
420
+ const map: Record<string, ProviderState> = {}
421
+ for (const def of PROVIDERS) {
422
+ const provider = cfg.providers?.[def.key]
423
+ map[def.key] = {
424
+ key: '',
425
+ keyConfigured: provider?.keyConfigured ?? false,
426
+ models: (provider?.models ?? []).map(modelRow),
427
+ }
428
+ }
429
+ setProviders(map)
430
+ setTools({
431
+ generate: toolRefFromConfig(cfg.tools?.generate),
432
+ })
433
+ try {
434
+ const catalog = await loadModelsDev()
435
+ setProviders((current) => {
436
+ const enriched = { ...current }
437
+ for (const def of PROVIDERS) {
438
+ const provider = current[def.key]
439
+ if (provider !== undefined) enriched[def.key] = { ...provider, models: applyOfficialDefaults(provider.models, def, catalog).rows }
440
+ }
441
+ return enriched
442
+ })
443
+ } catch {
444
+ // The form remains fully editable when the optional public catalog is unavailable.
445
+ }
446
+ })
447
+ .catch((e) => setError(String(e instanceof Error ? e.message : e)))
448
+ }, [])
449
+
450
+ const updateProvider = useCallback((key: string, patch: Partial<ProviderState>) => {
451
+ setProviders((previous) => ({ ...previous, [key]: { ...(previous[key] ?? emptyProvider()), ...patch } }))
452
+ }, [])
453
+
454
+ const updateModel = (providerKey: string, rowId: number, patch: Partial<ModelRow>) => {
455
+ setProviders(previous => {
456
+ const provider = previous[providerKey] ?? emptyProvider()
457
+ return { ...previous, [providerKey]: { ...provider, models: provider.models.map(row => row.rowId === rowId ? { ...row, ...patch } : row) } }
458
+ })
459
+ }
460
+
461
+ const toggleModel = (rowId: number) => {
462
+ setExpandedModels((current) => {
463
+ const next = new Set(current)
464
+ if (next.has(rowId)) next.delete(rowId)
465
+ else next.add(rowId)
466
+ return next
467
+ })
468
+ }
469
+
470
+ const fillModel = async (def: ProviderDefinition, rowId: number) => {
471
+ try {
472
+ const catalog = await loadModelsDev()
473
+ setProviders((previous) => {
474
+ const provider = previous[def.key]
475
+ if (provider === undefined) return previous
476
+ const models = provider.models.map((row) => row.rowId === rowId ? applyOfficialDefaults([row], def, catalog).rows[0] ?? row : row)
477
+ return { ...previous, [def.key]: { ...provider, models } }
478
+ })
479
+ } catch {
480
+ // Manual values remain available if models.dev cannot be reached.
481
+ }
482
+ }
483
+
484
+ const fillProvider = async (def: ProviderDefinition) => {
485
+ setBusy(`metadata-${def.key}`); setError(''); setMessage('')
486
+ try {
487
+ const catalog = await loadModelsDev()
488
+ setProviders(previous => {
489
+ const provider = previous[def.key] ?? emptyProvider()
490
+ return { ...previous, [def.key]: { ...provider, models: applyOfficialDefaults(provider.models, def, catalog).rows } }
491
+ })
492
+ setMessage(`${def.label} 已补全空白字段,保留手动设置`)
493
+ } catch (e) {
494
+ setError(`无法读取 models.dev:${String(e instanceof Error ? e.message : e)}`)
495
+ } finally {
496
+ setBusy('')
497
+ }
498
+ }
499
+
500
+ const save = async () => {
501
+ setBusy('save'); setError(''); setMessage('')
502
+ try {
503
+ if (!structuredConfig) throw new Error('服务端仍在运行旧版插件,请重启 DSH Web 后再保存结构化模型配置')
504
+ const payload = {
505
+ baseURL,
506
+ providers: {} as Record<string, { apiKey: string; models: CatalogModel[] }>,
507
+ tools: {} as { generate?: ImageToolModelRef },
508
+ }
509
+ for (const def of PROVIDERS) {
510
+ const provider = providers[def.key] ?? emptyProvider()
511
+ const nonEmptyRows = provider.models.filter((row) =>
512
+ row.id.trim().length > 0 || row.name.trim().length > 0 || row.contextWindow.length > 0 || row.maxTokens.length > 0 || row.reasoning.length > 0)
513
+ const seen = new Set<string>()
514
+ const models = nonEmptyRows.map((row) => {
515
+ const id = row.id.trim()
516
+ if (id.length === 0) throw new Error(`${def.label} 存在未填写 ID 的模型`)
517
+ if (seen.has(id)) throw new Error(`${def.label} 模型 ID 重复:${id}`)
518
+ seen.add(id)
519
+ const name = row.name.trim()
520
+ const contextWindow = row.contextWindow.length > 0 ? Number(row.contextWindow) : undefined
521
+ if (contextWindow !== undefined && (!Number.isSafeInteger(contextWindow) || contextWindow < 1)) {
522
+ throw new Error(`${def.label} ${id} 的 Context Window 必须是正整数`)
523
+ }
524
+ const maxTokens = row.maxTokens.trim().length > 0 ? Number(row.maxTokens) : undefined
525
+ if (maxTokens !== undefined && (!Number.isSafeInteger(maxTokens) || maxTokens < 1)) {
526
+ throw new Error(`${def.label} ${id} 的 Max Tokens 必须是正整数`)
527
+ }
528
+ const reasoningEfforts = row.reasoning === 'off' ? [] : row.reasoning === 'on'
529
+ ? [...new Set(row.effortLevels.split(/[,,/\s]+/).filter(Boolean))]
530
+ : undefined
531
+ if (row.reasoning === 'on' && !reasoningEfforts?.length) throw new Error(`${def.label} ${id} 请填写至少一个思考强度`)
532
+ if (reasoningEfforts?.some(level => !['none', 'off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'].includes(level))) throw new Error(`${def.label} ${id} 思考强度支持 none、off、minimal、low、medium、high、xhigh、max`)
533
+ const input: Array<'text' | 'image'> | undefined = row.input === 'text-image'
534
+ ? ['text', 'image']
535
+ : row.input === 'text'
536
+ ? ['text']
537
+ : undefined
538
+ return {
539
+ id,
540
+ ...(name.length > 0 ? { name } : {}),
541
+ ...(contextWindow !== undefined ? { contextWindow } : {}),
542
+ ...(maxTokens !== undefined ? { maxTokens } : {}),
543
+ ...(input !== undefined ? { input } : {}),
544
+ ...(reasoningEfforts !== undefined ? { reasoningEfforts } : {}),
545
+ }
546
+ })
547
+ payload.providers[def.key] = { apiKey: provider.key, models }
548
+ }
549
+ const generate = serializeToolRef(tools.generate)
550
+ if (generate !== undefined) payload.tools.generate = generate
551
+ const res = await api<{ ok: boolean; routes?: string[] }>(`${BASE}/config`, { method: 'POST', body: JSON.stringify(payload) })
552
+ const routes = res.routes !== undefined && res.routes.length > 0 ? res.routes.join(', ') : '无(未填 key)'
553
+ setMessage(`已保存。激活路由: ${routes}`)
554
+ } catch (e) {
555
+ setError(String(e instanceof Error ? e.message : e))
556
+ } finally {
557
+ setBusy('')
558
+ }
559
+ }
560
+
561
+ const discover = async (def: ProviderDefinition) => {
562
+ const key = providers[def.key]?.key ?? ''
563
+ setBusy(`discover-${def.key}`); setError(''); setMessage('')
564
+ // 先清空现有模型列表,再重新拉取:失败的拉取不会残留旧列表。
565
+ updateProvider(def.key, { models: [] })
566
+ try {
567
+ const res = await api<{ ok: boolean; models: CatalogModel[] }>(`${BASE}/discover`, {
568
+ method: 'POST',
569
+ body: JSON.stringify({ baseURL, apiKey: key, provider: def.key }),
570
+ })
571
+ let rows = (res.models ?? []).map(modelRow)
572
+ try {
573
+ rows = applyOfficialDefaults(rows, def, await loadModelsDev()).rows
574
+ } catch {
575
+ // Discovery results are still useful without public metadata.
576
+ }
577
+ updateProvider(def.key, { models: rows })
578
+ setMessage(`${def.label} 发现 ${rows.length} 个模型`)
579
+ } catch (e) {
580
+ setError(String(e instanceof Error ? e.message : e))
581
+ } finally {
582
+ setBusy('')
583
+ }
584
+ }
585
+
586
+ const checkUsage = async (def: ProviderDefinition) => {
587
+ const key = providers[def.key]?.key ?? ''
588
+ setBusy(`usage-${def.key}`); setError(''); setMessage('')
589
+ try {
590
+ const res = await api<{ ok: boolean; summary?: string }>(`${BASE}/usage`, {
591
+ method: 'POST',
592
+ body: JSON.stringify({ baseURL, apiKey: key, provider: def.key }),
593
+ })
594
+ setMessage(`${def.label} 用量: ${res.summary ?? ''}`)
595
+ } catch (e) {
596
+ setError(String(e instanceof Error ? e.message : e))
597
+ } finally {
598
+ setBusy('')
599
+ }
600
+ }
601
+
602
+ const checkStatus = async () => {
603
+ setBusy('status'); setError(''); setMessage('')
604
+ try {
605
+ const res = await api<{ routes: string[]; models: Record<string, string[]> }>(`${BASE}/status`)
606
+ setMessage(`已注册路由: ${res.routes.length > 0 ? res.routes.join(', ') : '无'};模型: ${JSON.stringify(res.models)}`)
607
+ } catch (e) {
608
+ setError(String(e instanceof Error ? e.message : e))
609
+ } finally {
610
+ setBusy('')
611
+ }
612
+ }
613
+
614
+ return (
615
+ <div className="s2a_section" ref={sectionRef}>
616
+ <h2 className="s2a_title">Sub2API 模型接入</h2>
617
+ <p className="s2a_intro">
618
+ 统一端点 + 多 key:所有供应商共享一个 baseURL,每个 key 在 sub2api 后台绑定一个分组,分组决定平台(OpenAI / Claude / Grok)与可用模型。
619
+ </p>
620
+ <p className="s2a_notice">
621
+ 提示:先在 sub2api 后台创建各平台的分组并生成 API key,再填入下方。
622
+ </p>
623
+ <div className="s2a_field" style={{ marginTop: 2 }}>
624
+ <label className="s2a_fieldLabel">Sub2API Base URL</label>
625
+ <input className="s2a_input" value={baseURL} placeholder="http://localhost:8080" onChange={(event) => setBaseURL(event.target.value)} />
626
+ </div>
627
+ <div className="s2a_rowCard">
628
+ <div className="s2a_rowHead">
629
+ <div className="s2a_rowIdentity">
630
+ <span className="s2a_rowName">图片生成工具</span>
631
+ <span className="s2a_rowTag">generate_image</span>
632
+ </div>
633
+ </div>
634
+ <div className="s2a_editor">
635
+ <p className="s2a_intro">
636
+ 指定生成图片使用的模型,生成结果会保存到工作区。
637
+ </p>
638
+ {(['generate'] as const).map((kind) => {
639
+ const label = '生图模型'
640
+ const ref = tools[kind]
641
+ const options = toolOptions(providers)
642
+ const selected = ref.provider.length > 0 && ref.model.length > 0 ? `${ref.provider}:${ref.model}` : ''
643
+ const known = options.some((option) => option.value === selected)
644
+ return (
645
+ <div key={kind} className="s2a_field">
646
+ <label className="s2a_fieldLabel">{label}</label>
647
+ <select
648
+ className="s2a_input"
649
+ value={selected}
650
+ aria-label={label}
651
+ onChange={(event) => {
652
+ const next = options.find((option) => option.value === event.target.value)
653
+ setTools((current) => ({
654
+ ...current,
655
+ [kind]: next === undefined ? emptyToolRef() : { provider: next.provider, model: next.model },
656
+ }))
657
+ }}
658
+ >
659
+ <option value="">未指定</option>
660
+ {!known && selected.length > 0 && (
661
+ <option value={selected}>{`${ref.provider} / ${ref.model}(不在当前目录)`}</option>
662
+ )}
663
+ {options.map((option) => (
664
+ <option key={option.value} value={option.value}>{option.label}</option>
665
+ ))}
666
+ </select>
667
+ </div>
668
+ )
669
+ })}
670
+ </div>
671
+ </div>
672
+ <ul className="s2a_rows">
673
+ {PROVIDERS.map((def) => {
674
+ const provider = providers[def.key] ?? emptyProvider()
675
+ return (
676
+ <li key={def.key} className="s2a_rowCard">
677
+ <div className="s2a_rowHead">
678
+ <div className="s2a_rowIdentity">
679
+ <ProviderIcon name={def.icon} size={18} />
680
+ <span className="s2a_rowName">{def.label}</span>
681
+ <span className="s2a_rowTag">sub2api-{def.key}</span>
682
+ </div>
683
+ <div className="s2a_rowActions">
684
+ <button className="s2a_btn" disabled={busy.length > 0} onClick={() => discover(def)}>
685
+ {busy === `discover-${def.key}` ? '…' : '获取模型'}
686
+ </button>
687
+ <button className="s2a_btn" disabled={busy.length > 0} onClick={() => checkUsage(def)}>
688
+ {busy === `usage-${def.key}` ? '…' : '查看用量'}
689
+ </button>
690
+ </div>
691
+ </div>
692
+ <div className="s2a_editor">
693
+ <div className="s2a_field">
694
+ <label className="s2a_fieldLabel">
695
+ {def.label} API Key{provider.keyConfigured || provider.key.length > 0 ? ' ✓' : ''}
696
+ </label>
697
+ <input
698
+ className="s2a_input"
699
+ type="password"
700
+ value={provider.key}
701
+ placeholder={provider.keyConfigured ? `${def.placeholder}(已配置,留空保持不变)` : def.placeholder}
702
+ onChange={(event) => updateProvider(def.key, { key: event.target.value })}
703
+ />
704
+ </div>
705
+ <div className="s2a_field">
706
+ <label className="s2a_fieldLabel">模型列表</label>
707
+ <div className="s2a_models">
708
+ {provider.models.length === 0
709
+ ? <p className="s2a_modelEmpty">暂无模型</p>
710
+ : provider.models.map((row) => {
711
+ const expanded = expandedModels.has(row.rowId)
712
+ const detailsId = `s2a-model-${row.rowId}-details`
713
+ return (
714
+ <div key={row.rowId} className="s2a_modelItem">
715
+ <div className="s2a_modelSummary">
716
+ <div>
717
+ <input
718
+ className="s2a_input"
719
+ value={row.id}
720
+ placeholder="模型 ID"
721
+ aria-label={`${def.label} 模型 ID`}
722
+ onChange={(event) => updateModel(def.key, row.rowId, { id: event.target.value })}
723
+ onBlur={() => fillModel(def, row.rowId)}
724
+ />
725
+ </div>
726
+ <div>
727
+ <input
728
+ className="s2a_input"
729
+ value={row.name}
730
+ placeholder="名称"
731
+ aria-label={`${def.label} 模型名称`}
732
+ onChange={(event) => updateModel(def.key, row.rowId, { name: event.target.value })}
733
+ />
734
+ </div>
735
+ <button
736
+ type="button"
737
+ className="s2a_iconBtn s2a_expandBtn"
738
+ title={expanded ? '收起模型详情' : '展开模型详情'}
739
+ aria-label={expanded ? `收起 ${row.id || '模型'} 详情` : `展开 ${row.id || '模型'} 详情`}
740
+ aria-expanded={expanded}
741
+ aria-controls={detailsId}
742
+ onClick={() => toggleModel(row.rowId)}
743
+ >
744
+ <IconChevron expanded={expanded} />
745
+ </button>
746
+ <button
747
+ type="button"
748
+ className="s2a_iconBtn s2a_trash"
749
+ title="删除模型"
750
+ aria-label={`删除 ${row.id || '模型'}`}
751
+ onClick={() => updateProvider(def.key, { models: provider.models.filter((item) => item.rowId !== row.rowId) })}
752
+ >
753
+ <IconTrash />
754
+ </button>
755
+ </div>
756
+ {expanded && (
757
+ <div id={detailsId} className="s2a_modelDetails">
758
+ <div className="s2a_field">
759
+ <label className="s2a_fieldLabel">上下文窗口</label>
760
+ <input
761
+ className="s2a_input"
762
+ type="number"
763
+ min="1"
764
+ step="1"
765
+ value={row.contextWindow}
766
+ placeholder="自动填充"
767
+ aria-label={`${def.label} 上下文窗口`}
768
+ onChange={(event) => updateModel(def.key, row.rowId, { contextWindow: event.target.value })}
769
+ />
770
+ </div>
771
+ <div className="s2a_field">
772
+ <label className="s2a_fieldLabel">最大输出 token</label>
773
+ <input
774
+ className="s2a_input"
775
+ type="number"
776
+ min="1"
777
+ step="1"
778
+ value={row.maxTokens}
779
+ placeholder="自动填充"
780
+ aria-label={`${def.label} 最大输出 token`}
781
+ onChange={(event) => updateModel(def.key, row.rowId, { maxTokens: event.target.value })}
782
+ />
783
+ </div>
784
+ <div className="s2a_field">
785
+ <label className="s2a_fieldLabel">图片输入</label>
786
+ <select className="s2a_input" aria-label={`${def.label} ${row.id} 图片输入`} value={row.input}
787
+ onChange={event => updateModel(def.key, row.rowId, { input: event.target.value, inputEdited: true })}>
788
+ <option value="">自动(按模型推断)</option>
789
+ <option value="text">仅文本</option>
790
+ <option value="text-image">文本 + 图片</option>
791
+ </select>
792
+ </div>
793
+ <div className="s2a_field s2a_reasoningField">
794
+ <label className="s2a_fieldLabel">思考强度</label>
795
+ <select className="s2a_input" aria-label={`${def.label} ${row.id} 思考模式`} value={row.reasoning}
796
+ onChange={event => updateModel(def.key, row.rowId, { reasoning: event.target.value, reasoningEdited: true })}>
797
+ <option value="">自动</option>
798
+ <option value="off">不支持</option>
799
+ <option value="on">手动输入档位</option>
800
+ </select>
801
+ {row.reasoning === 'on' && <input className="s2a_input" value={row.effortLevels}
802
+ aria-label={`${def.label} ${row.id} 思考强度档位`} placeholder="例如 none, low, high, max"
803
+ onChange={event => updateModel(def.key, row.rowId, { effortLevels: event.target.value, reasoningEdited: true })} />}
804
+ <span className="s2a_modelSource">多个档位用逗号分隔。手动设置不会被补全数据覆盖。</span>
805
+ </div>
806
+ </div>
807
+ )}
808
+ </div>
809
+ )
810
+ })}
811
+ </div>
812
+ <div className="s2a_modelFooter">
813
+ <span className="s2a_modelSource">
814
+ 默认值来自 <a href="https://models.dev/" target="_blank" rel="noreferrer">models.dev</a>,未匹配时可手动填写
815
+ </span>
816
+ <div className="s2a_modelActions">
817
+ <button className="s2a_btn" disabled={busy.length > 0 || provider.models.length === 0} onClick={() => fillProvider(def)}>
818
+ {busy === `metadata-${def.key}` ? '…' : '补全数据'}
819
+ </button>
820
+ <button className="s2a_btn" disabled={busy.length > 0} onClick={() => updateProvider(def.key, { models: [...provider.models, modelRow()] })}>
821
+ 添加模型
822
+ </button>
823
+ </div>
824
+ </div>
825
+ </div>
826
+ </div>
827
+ </li>
828
+ )
829
+ })}
830
+ </ul>
831
+ <div className="s2a_actions">
832
+ <button className="s2a_primary" disabled={busy.length > 0} onClick={save}>
833
+ {busy === 'save' ? '保存中…' : '保存配置'}
834
+ </button>
835
+ <button className="s2a_btn" disabled={busy.length > 0} onClick={checkStatus}>
836
+ {busy === 'status' ? '…' : '查看状态'}
837
+ </button>
838
+ </div>
839
+ {(message || error) && <div className="s2a_toast" role="status" aria-live="polite">
840
+ <p className={`s2a_status ${error ? 's2a_statusErr' : 's2a_statusOk'}`}>{error || message}</p>
841
+ <button className="s2a_iconBtn" aria-label="关闭提示" onClick={() => { setMessage(''); setError('') }}>×</button>
842
+ </div>}
843
+ </div>
844
+ )
845
+ }
846
+
847
+ export default Sub2ApiSettings