@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.
- package/LICENSE +21 -0
- package/README.md +158 -0
- package/README.zh.md +160 -0
- package/assets/icons/claude.svg +1 -0
- package/assets/icons/grok.svg +1 -0
- package/assets/icons/openai.svg +1 -0
- package/cordis.patch.yml +11 -0
- package/lib/client.cjs +1098 -0
- package/lib/client.js +1107 -0
- package/lib/image-tools.d.ts +18 -0
- package/lib/index.d.ts +148 -0
- package/lib/index.js +1220 -0
- package/lib/invariant.d.ts +13 -0
- package/lib/invariant.js +9 -0
- package/lib/pi-ai-patch.d.ts +45 -0
- package/lib/pi-ai.d.ts +50 -0
- package/lib/routes.d.ts +43 -0
- package/package.json +160 -0
- package/src/client/icons.tsx +38 -0
- package/src/client/index.tsx +31 -0
- package/src/client/settings.tsx +847 -0
- package/src/client/toolview.tsx +76 -0
- package/src/image-tools.ts +590 -0
- package/src/index.ts +339 -0
- package/src/invariant.ts +17 -0
- package/src/pi-ai-patch.ts +94 -0
- package/src/pi-ai.ts +185 -0
- package/src/routes.ts +442 -0
|
@@ -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
|