dsh-auto-sight 0.4.7 → 0.4.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +81 -81
- package/README.zh-CN.md +81 -81
- package/client.js +349 -349
- package/cordis.patch.yml +6 -6
- package/icon.svg +6 -6
- package/index.js +672 -627
- package/locale/en.json +6 -6
- package/locale/zh.json +6 -6
- package/package.json +60 -60
- package/test/auto-vision.test.mjs +550 -480
- package/test/client-render.mjs +158 -158
package/client.js
CHANGED
|
@@ -1,349 +1,349 @@
|
|
|
1
|
-
window.__ModuleLoader__.load({
|
|
2
|
-
id: 'dsh-auto-sight',
|
|
3
|
-
factory(require) {
|
|
4
|
-
const React = require('react')
|
|
5
|
-
const h = React.createElement
|
|
6
|
-
|
|
7
|
-
const NS = 'auto-vision'
|
|
8
|
-
const zh = {
|
|
9
|
-
nav: '自动视觉',
|
|
10
|
-
title: '自动视觉',
|
|
11
|
-
intro: '主模型不能识别图片时,自动改用同一 API 下可识别图片的模型读取图片,再把描述反馈给主模型。',
|
|
12
|
-
target: '视觉模型',
|
|
13
|
-
auto: '自动(使用该 API 中第一个支持图片的模型)',
|
|
14
|
-
hint: '保证组只显示 DSH 配置里声明了 input: [text, image] 的模型。官方 /models 接口不返回模态信息;勾选后可查看仅按名称猜出的官方候选,不保证能调用。调用失败会先试同一 API 的其他视觉模型,再试官方清单候选,并在最终答复中说明。',
|
|
15
|
-
refresh: '刷新模型列表',
|
|
16
|
-
loading: '正在读取模型…',
|
|
17
|
-
saving: '正在保存…',
|
|
18
|
-
saved: '已保存,下一次图片请求生效。',
|
|
19
|
-
loadFailed: '读取设置失败',
|
|
20
|
-
saveFailed: '保存失败',
|
|
21
|
-
noModels: '没有找到已声明图片输入的模型。下方列出了已配置但按名称匹配的视觉模型;也可勾选官方候选。',
|
|
22
|
-
provider: 'API',
|
|
23
|
-
showCandidates: '显示官方清单中未声明的候选(不保证)',
|
|
24
|
-
configured: '已配置但未声明图像输入',
|
|
25
|
-
undeclared: '未声明',
|
|
26
|
-
}
|
|
27
|
-
const en = {
|
|
28
|
-
nav: 'Auto Vision',
|
|
29
|
-
title: 'Auto Vision',
|
|
30
|
-
intro: 'When the main model cannot see images, a vision model on the same API reads them first and its description is fed back.',
|
|
31
|
-
target: 'Vision model',
|
|
32
|
-
auto: 'Automatic (first image-capable model on this API)',
|
|
33
|
-
hint: 'The guaranteed group only shows models whose DSH config declares input: [text, image]. The official /models endpoint exposes no modality metadata; tick below to see name-matched official candidates, which are not guaranteed to work. On failure the plugin tries other mounted vision models, then official candidates, and reports the switch in the final answer.',
|
|
34
|
-
refresh: 'Refresh models',
|
|
35
|
-
loading: 'Reading models…',
|
|
36
|
-
saving: 'Saving…',
|
|
37
|
-
saved: 'Saved. Applies to the next image request.',
|
|
38
|
-
loadFailed: 'Could not read settings',
|
|
39
|
-
saveFailed: 'Could not save',
|
|
40
|
-
noModels: 'No model declares an image input. Configured models matched by name are listed below; official candidates can be shown too.',
|
|
41
|
-
provider: 'API',
|
|
42
|
-
showCandidates: 'Show undeclared official candidates (not guaranteed)',
|
|
43
|
-
configured: 'Configured without image input',
|
|
44
|
-
undeclared: 'Undeclared',
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
const box = {
|
|
48
|
-
display: 'grid',
|
|
49
|
-
gap: 16,
|
|
50
|
-
maxWidth: 720,
|
|
51
|
-
color: 'var(--dsw-alias-label-primary)',
|
|
52
|
-
}
|
|
53
|
-
const title = { margin: 0, fontSize: 18, fontWeight: 600 }
|
|
54
|
-
const intro = { margin: 0, color: 'var(--dsw-alias-label-secondary)', lineHeight: 1.6 }
|
|
55
|
-
const card = {
|
|
56
|
-
display: 'grid',
|
|
57
|
-
gap: 12,
|
|
58
|
-
padding: 16,
|
|
59
|
-
borderRadius: 12,
|
|
60
|
-
background: 'var(--dsw-alias-settings-card-fill)',
|
|
61
|
-
border: '1px solid var(--dsw-alias-settings-card-stroke)',
|
|
62
|
-
}
|
|
63
|
-
const label = { display: 'grid', gap: 6, fontSize: 13, fontWeight: 500 }
|
|
64
|
-
const select = {
|
|
65
|
-
width: '100%',
|
|
66
|
-
padding: '8px 10px',
|
|
67
|
-
borderRadius: 8,
|
|
68
|
-
border: '1px solid var(--dsw-alias-border-l2)',
|
|
69
|
-
background: 'var(--dsw-alias-bg-layer-1)',
|
|
70
|
-
color: 'var(--dsw-alias-label-primary)',
|
|
71
|
-
fontSize: 14,
|
|
72
|
-
}
|
|
73
|
-
const hint = { margin: 0, color: 'var(--dsw-alias-label-tertiary)', fontSize: 12, lineHeight: 1.5 }
|
|
74
|
-
const row = { display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }
|
|
75
|
-
const button = {
|
|
76
|
-
padding: '6px 12px',
|
|
77
|
-
borderRadius: 8,
|
|
78
|
-
border: '1px solid var(--dsw-alias-border-l2)',
|
|
79
|
-
background: 'transparent',
|
|
80
|
-
color: 'var(--dsw-alias-label-secondary)',
|
|
81
|
-
cursor: 'pointer',
|
|
82
|
-
fontSize: 12,
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function groupModels(providers) {
|
|
86
|
-
return providers.filter(
|
|
87
|
-
(provider) => provider.models.length > 0 || provider.configured.length > 0 || provider.candidates.length > 0,
|
|
88
|
-
)
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
function routeKey(provider, model) {
|
|
92
|
-
return `${provider}|${model}`
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function splitRoute(value) {
|
|
96
|
-
if (!value) return { provider: '', model: '' }
|
|
97
|
-
const at = value.indexOf('|')
|
|
98
|
-
if (at < 0) return { provider: '', model: value }
|
|
99
|
-
return { provider: value.slice(0, at), model: value.slice(at + 1) }
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
function atPath(root, path) {
|
|
103
|
-
let node = root
|
|
104
|
-
for (const key of path || []) {
|
|
105
|
-
if (node == null) return undefined
|
|
106
|
-
node = node[key]
|
|
107
|
-
}
|
|
108
|
-
return node
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
function configuredModels(profile) {
|
|
112
|
-
if (!Array.isArray(profile?.models)) return []
|
|
113
|
-
return profile.models
|
|
114
|
-
.filter((model) => model?.id)
|
|
115
|
-
.map((model) => ({ id: model.id, name: model.name || model.id }))
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
function imageModels(profile) {
|
|
119
|
-
if (!Array.isArray(profile?.models)) return []
|
|
120
|
-
return profile.models
|
|
121
|
-
.filter((model) => Array.isArray(model?.input) && model.input.includes('image'))
|
|
122
|
-
.map((model) => ({ id: model.id, name: model.name || model.id }))
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
function isVisionName(id) {
|
|
126
|
-
return /vision|multimodal|omni|ocr|(?:^|[-_])vl(?:$|[-_])|4(?:\.\d+)?v/i.test(id)
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
function AutoVisionSection() {
|
|
130
|
-
const t = ctx.locale.bind(NS)
|
|
131
|
-
const [state, setState] = React.useState({ phase: 'loading' })
|
|
132
|
-
const [saving, setSaving] = React.useState(false)
|
|
133
|
-
const [notice, setNotice] = React.useState(null)
|
|
134
|
-
const [showCandidates, setShowCandidates] = React.useState(false)
|
|
135
|
-
|
|
136
|
-
const load = React.useCallback(async () => {
|
|
137
|
-
setState({ phase: 'loading' })
|
|
138
|
-
const [settings, configured, registered] = await Promise.all([
|
|
139
|
-
ctx.remote.settings.describe(),
|
|
140
|
-
ctx.remote.llm.listConfigurableProviders(),
|
|
141
|
-
ctx.remote.llm.listProviders(),
|
|
142
|
-
])
|
|
143
|
-
if (!settings.ok) throw new Error(settings.error.message)
|
|
144
|
-
if (!configured.ok) throw new Error(configured.error.message)
|
|
145
|
-
if (!registered.ok) throw new Error(registered.error.message)
|
|
146
|
-
|
|
147
|
-
const view = settings.value.namespaces.find((entry) => entry.ns === NS)
|
|
148
|
-
if (!view) throw new Error(`No settings namespace "${NS}"`)
|
|
149
|
-
const active = new Set(registered.value.map((provider) => provider.id))
|
|
150
|
-
const routes = configured.value.filter((provider) => active.has(provider.provider))
|
|
151
|
-
|
|
152
|
-
const providers = await Promise.all(routes.map(async (provider) => {
|
|
153
|
-
const view = settings.value.namespaces.find((entry) => entry.ns === provider.settingsNs)
|
|
154
|
-
const profile = atPath(view?.value, provider.settingsPath)
|
|
155
|
-
const declared = new Map(imageModels(profile).map((model) => [model.id, model]))
|
|
156
|
-
let official = []
|
|
157
|
-
try {
|
|
158
|
-
// The Host plugin owns the official `/models` query and its API key,
|
|
159
|
-
// so the browser never receives a credential.
|
|
160
|
-
const answer = await ctx.remote.llm.discoverModels(NS, { provider: provider.provider })
|
|
161
|
-
if (answer.ok) {
|
|
162
|
-
official = answer.value
|
|
163
|
-
.filter((model) => model?.id)
|
|
164
|
-
.map((model) => ({ id: model.id, name: model.name || model.id }))
|
|
165
|
-
}
|
|
166
|
-
} catch {
|
|
167
|
-
// The declared DSH catalog is still usable when discovery fails.
|
|
168
|
-
}
|
|
169
|
-
const unique = new Map()
|
|
170
|
-
for (const model of official) unique.set(model.id, model)
|
|
171
|
-
return {
|
|
172
|
-
id: provider.provider,
|
|
173
|
-
name: provider.displayName || provider.provider,
|
|
174
|
-
// The declared catalog is authoritative: the official endpoint may
|
|
175
|
-
// omit aliases or vision models entirely, so never hide a model
|
|
176
|
-
// just because /models did not return it.
|
|
177
|
-
models: [...declared.values()],
|
|
178
|
-
configured: configuredModels(profile).filter(
|
|
179
|
-
(model) => !declared.has(model.id) && isVisionName(model.id),
|
|
180
|
-
),
|
|
181
|
-
candidates: [...unique.values()].filter(
|
|
182
|
-
(model) => !declared.has(model.id) && isVisionName(model.id),
|
|
183
|
-
),
|
|
184
|
-
}
|
|
185
|
-
}))
|
|
186
|
-
|
|
187
|
-
const model = typeof view.value?.visionModel === 'string' ? view.value.visionModel : ''
|
|
188
|
-
const configuredProvider = typeof view.value?.visionProvider === 'string' ? view.value.visionProvider : ''
|
|
189
|
-
const owner = configuredProvider
|
|
190
|
-
|| providers.find((provider) => provider.models.some((entry) => entry.id === model))?.id
|
|
191
|
-
|| ''
|
|
192
|
-
|
|
193
|
-
setState({
|
|
194
|
-
phase: 'ready',
|
|
195
|
-
ns: view.ns,
|
|
196
|
-
revision: view.revision,
|
|
197
|
-
current: model ? routeKey(owner, model) : '',
|
|
198
|
-
providers: groupModels(providers),
|
|
199
|
-
})
|
|
200
|
-
}, [])
|
|
201
|
-
|
|
202
|
-
React.useEffect(() => {
|
|
203
|
-
load().catch((error) => setState({ phase: 'error', message: error.message }))
|
|
204
|
-
}, [load])
|
|
205
|
-
|
|
206
|
-
async function change(event) {
|
|
207
|
-
const { provider, model } = splitRoute(event.target.value)
|
|
208
|
-
setSaving(true)
|
|
209
|
-
setNotice(null)
|
|
210
|
-
try {
|
|
211
|
-
const answer = await ctx.remote.settings.mutate(
|
|
212
|
-
state.ns,
|
|
213
|
-
[
|
|
214
|
-
{ op: 'set', path: ['visionProvider'], value: provider },
|
|
215
|
-
{ op: 'set', path: ['visionModel'], value: model },
|
|
216
|
-
],
|
|
217
|
-
state.revision,
|
|
218
|
-
)
|
|
219
|
-
if (!answer.ok) throw new Error(answer.error.message)
|
|
220
|
-
const savedProvider = typeof answer.value.value?.visionProvider === 'string' ? answer.value.value.visionProvider : provider
|
|
221
|
-
const savedModel = typeof answer.value.value?.visionModel === 'string' ? answer.value.value.visionModel : model
|
|
222
|
-
setState((previous) => ({
|
|
223
|
-
...previous,
|
|
224
|
-
revision: answer.value.revision,
|
|
225
|
-
current: savedModel ? routeKey(savedProvider, savedModel) : '',
|
|
226
|
-
}))
|
|
227
|
-
setNotice({ kind: 'ok', text: t('saved') })
|
|
228
|
-
} catch (error) {
|
|
229
|
-
setNotice({ kind: 'error', text: `${t('saveFailed')}: ${error.message}` })
|
|
230
|
-
} finally {
|
|
231
|
-
setSaving(false)
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
if (state.phase === 'loading') {
|
|
236
|
-
return h('section', { style: box }, h('h2', { style: title }, t('title')), h('p', { style: intro }, t('loading')))
|
|
237
|
-
}
|
|
238
|
-
if (state.phase === 'error') {
|
|
239
|
-
return h('section', { style: box },
|
|
240
|
-
h('h2', { style: title }, t('title')),
|
|
241
|
-
h('p', { style: { ...intro, color: 'var(--dsw-alias-state-error-primary)' } }, `${t('loadFailed')}: ${state.message}`),
|
|
242
|
-
)
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
const options = []
|
|
246
|
-
options.push(h('option', { key: '', value: '' }, t('auto')))
|
|
247
|
-
const known = new Set()
|
|
248
|
-
for (const provider of state.providers) {
|
|
249
|
-
if (provider.models.length === 0) continue
|
|
250
|
-
const children = []
|
|
251
|
-
for (const model of provider.models) {
|
|
252
|
-
const route = routeKey(provider.id, model.id)
|
|
253
|
-
known.add(route)
|
|
254
|
-
children.push(h('option', { key: route, value: route }, `${model.name} (${provider.name})`))
|
|
255
|
-
}
|
|
256
|
-
options.push(h('optgroup', { key: provider.id, label: `${t('provider')}: ${provider.name}` }, children))
|
|
257
|
-
}
|
|
258
|
-
for (const provider of state.providers) {
|
|
259
|
-
if (provider.configured.length === 0) continue
|
|
260
|
-
const children = []
|
|
261
|
-
for (const model of provider.configured) {
|
|
262
|
-
const route = routeKey(provider.id, model.id)
|
|
263
|
-
known.add(route)
|
|
264
|
-
children.push(h('option', { key: route, value: route }, `${model.name} (${provider.name} · ${t('configured')})`))
|
|
265
|
-
}
|
|
266
|
-
options.push(h('optgroup', {
|
|
267
|
-
key: `configured:${provider.id}`,
|
|
268
|
-
label: `${t('provider')}: ${provider.name} · ${t('configured')}`,
|
|
269
|
-
}, children))
|
|
270
|
-
}
|
|
271
|
-
if (showCandidates) {
|
|
272
|
-
for (const provider of state.providers) {
|
|
273
|
-
if (provider.candidates.length === 0) continue
|
|
274
|
-
const children = []
|
|
275
|
-
for (const model of provider.candidates) {
|
|
276
|
-
const route = routeKey(provider.id, model.id)
|
|
277
|
-
known.add(route)
|
|
278
|
-
children.push(h('option', { key: route, value: route }, `${model.name} (${provider.name} · ${t('undeclared')})`))
|
|
279
|
-
}
|
|
280
|
-
options.push(h('optgroup', {
|
|
281
|
-
key: `candidates:${provider.id}`,
|
|
282
|
-
label: `${t('provider')}: ${provider.name} · ${t('undeclared')}`,
|
|
283
|
-
}, children))
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
if (state.current && !known.has(state.current)) {
|
|
287
|
-
options.push(h('option', { key: 'custom', value: state.current }, `${state.current} (${t('undeclared')})`))
|
|
288
|
-
}
|
|
289
|
-
const hasGuaranteed = state.providers.some((provider) => provider.models.length > 0)
|
|
290
|
-
const hasCandidates = state.providers.some((provider) => provider.candidates.length > 0)
|
|
291
|
-
|
|
292
|
-
return h('section', { style: box },
|
|
293
|
-
h('h2', { style: title }, t('title')),
|
|
294
|
-
h('p', { style: intro }, t('intro')),
|
|
295
|
-
h('div', { style: card },
|
|
296
|
-
h('label', { style: label }, t('target'),
|
|
297
|
-
h('select', {
|
|
298
|
-
style: select,
|
|
299
|
-
value: state.current,
|
|
300
|
-
disabled: saving,
|
|
301
|
-
onChange: change,
|
|
302
|
-
}, options),
|
|
303
|
-
),
|
|
304
|
-
h('p', { style: hint }, t('hint')),
|
|
305
|
-
hasCandidates ? h('label', {
|
|
306
|
-
style: { ...hint, display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' },
|
|
307
|
-
},
|
|
308
|
-
h('input', {
|
|
309
|
-
type: 'checkbox',
|
|
310
|
-
checked: showCandidates,
|
|
311
|
-
disabled: saving,
|
|
312
|
-
onChange: (event) => setShowCandidates(event.target.checked),
|
|
313
|
-
}),
|
|
314
|
-
t('showCandidates'),
|
|
315
|
-
) : null,
|
|
316
|
-
h('div', { style: row },
|
|
317
|
-
h('button', {
|
|
318
|
-
type: 'button',
|
|
319
|
-
style: button,
|
|
320
|
-
disabled: saving,
|
|
321
|
-
onClick: () => load().catch((error) => setState({ phase: 'error', message: error.message })),
|
|
322
|
-
}, t('refresh')),
|
|
323
|
-
saving ? h('span', { style: hint }, t('saving')) : null,
|
|
324
|
-
notice ? h('span', {
|
|
325
|
-
style: { ...hint, color: notice.kind === 'error' ? 'var(--dsw-alias-state-error-primary)' : 'var(--dsw-alias-state-success-primary)' },
|
|
326
|
-
}, notice.text) : null,
|
|
327
|
-
),
|
|
328
|
-
!hasGuaranteed ? h('p', { style: hint }, t('noModels')) : null,
|
|
329
|
-
),
|
|
330
|
-
)
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
let ctx
|
|
334
|
-
return {
|
|
335
|
-
inject: ['slots', 'locale', 'remote', 'remote.llm', 'remote.settings'],
|
|
336
|
-
apply(root) {
|
|
337
|
-
ctx = root
|
|
338
|
-
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-auto-vision: locale')
|
|
339
|
-
const t = ctx.locale.bind(NS)
|
|
340
|
-
ctx.slots.inject('settings.section', () => ctx.slots.register({
|
|
341
|
-
name: 'settings.section',
|
|
342
|
-
id: NS,
|
|
343
|
-
order: 30,
|
|
344
|
-
label: () => t('nav'),
|
|
345
|
-
}, AutoVisionSection))
|
|
346
|
-
},
|
|
347
|
-
}
|
|
348
|
-
},
|
|
349
|
-
})
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: 'dsh-auto-sight',
|
|
3
|
+
factory(require) {
|
|
4
|
+
const React = require('react')
|
|
5
|
+
const h = React.createElement
|
|
6
|
+
|
|
7
|
+
const NS = 'auto-vision'
|
|
8
|
+
const zh = {
|
|
9
|
+
nav: '自动视觉',
|
|
10
|
+
title: '自动视觉',
|
|
11
|
+
intro: '主模型不能识别图片时,自动改用同一 API 下可识别图片的模型读取图片,再把描述反馈给主模型。',
|
|
12
|
+
target: '视觉模型',
|
|
13
|
+
auto: '自动(使用该 API 中第一个支持图片的模型)',
|
|
14
|
+
hint: '保证组只显示 DSH 配置里声明了 input: [text, image] 的模型。官方 /models 接口不返回模态信息;勾选后可查看仅按名称猜出的官方候选,不保证能调用。调用失败会先试同一 API 的其他视觉模型,再试官方清单候选,并在最终答复中说明。',
|
|
15
|
+
refresh: '刷新模型列表',
|
|
16
|
+
loading: '正在读取模型…',
|
|
17
|
+
saving: '正在保存…',
|
|
18
|
+
saved: '已保存,下一次图片请求生效。',
|
|
19
|
+
loadFailed: '读取设置失败',
|
|
20
|
+
saveFailed: '保存失败',
|
|
21
|
+
noModels: '没有找到已声明图片输入的模型。下方列出了已配置但按名称匹配的视觉模型;也可勾选官方候选。',
|
|
22
|
+
provider: 'API',
|
|
23
|
+
showCandidates: '显示官方清单中未声明的候选(不保证)',
|
|
24
|
+
configured: '已配置但未声明图像输入',
|
|
25
|
+
undeclared: '未声明',
|
|
26
|
+
}
|
|
27
|
+
const en = {
|
|
28
|
+
nav: 'Auto Vision',
|
|
29
|
+
title: 'Auto Vision',
|
|
30
|
+
intro: 'When the main model cannot see images, a vision model on the same API reads them first and its description is fed back.',
|
|
31
|
+
target: 'Vision model',
|
|
32
|
+
auto: 'Automatic (first image-capable model on this API)',
|
|
33
|
+
hint: 'The guaranteed group only shows models whose DSH config declares input: [text, image]. The official /models endpoint exposes no modality metadata; tick below to see name-matched official candidates, which are not guaranteed to work. On failure the plugin tries other mounted vision models, then official candidates, and reports the switch in the final answer.',
|
|
34
|
+
refresh: 'Refresh models',
|
|
35
|
+
loading: 'Reading models…',
|
|
36
|
+
saving: 'Saving…',
|
|
37
|
+
saved: 'Saved. Applies to the next image request.',
|
|
38
|
+
loadFailed: 'Could not read settings',
|
|
39
|
+
saveFailed: 'Could not save',
|
|
40
|
+
noModels: 'No model declares an image input. Configured models matched by name are listed below; official candidates can be shown too.',
|
|
41
|
+
provider: 'API',
|
|
42
|
+
showCandidates: 'Show undeclared official candidates (not guaranteed)',
|
|
43
|
+
configured: 'Configured without image input',
|
|
44
|
+
undeclared: 'Undeclared',
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const box = {
|
|
48
|
+
display: 'grid',
|
|
49
|
+
gap: 16,
|
|
50
|
+
maxWidth: 720,
|
|
51
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
52
|
+
}
|
|
53
|
+
const title = { margin: 0, fontSize: 18, fontWeight: 600 }
|
|
54
|
+
const intro = { margin: 0, color: 'var(--dsw-alias-label-secondary)', lineHeight: 1.6 }
|
|
55
|
+
const card = {
|
|
56
|
+
display: 'grid',
|
|
57
|
+
gap: 12,
|
|
58
|
+
padding: 16,
|
|
59
|
+
borderRadius: 12,
|
|
60
|
+
background: 'var(--dsw-alias-settings-card-fill)',
|
|
61
|
+
border: '1px solid var(--dsw-alias-settings-card-stroke)',
|
|
62
|
+
}
|
|
63
|
+
const label = { display: 'grid', gap: 6, fontSize: 13, fontWeight: 500 }
|
|
64
|
+
const select = {
|
|
65
|
+
width: '100%',
|
|
66
|
+
padding: '8px 10px',
|
|
67
|
+
borderRadius: 8,
|
|
68
|
+
border: '1px solid var(--dsw-alias-border-l2)',
|
|
69
|
+
background: 'var(--dsw-alias-bg-layer-1)',
|
|
70
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
71
|
+
fontSize: 14,
|
|
72
|
+
}
|
|
73
|
+
const hint = { margin: 0, color: 'var(--dsw-alias-label-tertiary)', fontSize: 12, lineHeight: 1.5 }
|
|
74
|
+
const row = { display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }
|
|
75
|
+
const button = {
|
|
76
|
+
padding: '6px 12px',
|
|
77
|
+
borderRadius: 8,
|
|
78
|
+
border: '1px solid var(--dsw-alias-border-l2)',
|
|
79
|
+
background: 'transparent',
|
|
80
|
+
color: 'var(--dsw-alias-label-secondary)',
|
|
81
|
+
cursor: 'pointer',
|
|
82
|
+
fontSize: 12,
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function groupModels(providers) {
|
|
86
|
+
return providers.filter(
|
|
87
|
+
(provider) => provider.models.length > 0 || provider.configured.length > 0 || provider.candidates.length > 0,
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function routeKey(provider, model) {
|
|
92
|
+
return `${provider}|${model}`
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function splitRoute(value) {
|
|
96
|
+
if (!value) return { provider: '', model: '' }
|
|
97
|
+
const at = value.indexOf('|')
|
|
98
|
+
if (at < 0) return { provider: '', model: value }
|
|
99
|
+
return { provider: value.slice(0, at), model: value.slice(at + 1) }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function atPath(root, path) {
|
|
103
|
+
let node = root
|
|
104
|
+
for (const key of path || []) {
|
|
105
|
+
if (node == null) return undefined
|
|
106
|
+
node = node[key]
|
|
107
|
+
}
|
|
108
|
+
return node
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function configuredModels(profile) {
|
|
112
|
+
if (!Array.isArray(profile?.models)) return []
|
|
113
|
+
return profile.models
|
|
114
|
+
.filter((model) => model?.id)
|
|
115
|
+
.map((model) => ({ id: model.id, name: model.name || model.id }))
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function imageModels(profile) {
|
|
119
|
+
if (!Array.isArray(profile?.models)) return []
|
|
120
|
+
return profile.models
|
|
121
|
+
.filter((model) => Array.isArray(model?.input) && model.input.includes('image'))
|
|
122
|
+
.map((model) => ({ id: model.id, name: model.name || model.id }))
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function isVisionName(id) {
|
|
126
|
+
return /vision|multimodal|omni|ocr|(?:^|[-_])vl(?:$|[-_])|4(?:\.\d+)?v/i.test(id)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function AutoVisionSection() {
|
|
130
|
+
const t = ctx.locale.bind(NS)
|
|
131
|
+
const [state, setState] = React.useState({ phase: 'loading' })
|
|
132
|
+
const [saving, setSaving] = React.useState(false)
|
|
133
|
+
const [notice, setNotice] = React.useState(null)
|
|
134
|
+
const [showCandidates, setShowCandidates] = React.useState(false)
|
|
135
|
+
|
|
136
|
+
const load = React.useCallback(async () => {
|
|
137
|
+
setState({ phase: 'loading' })
|
|
138
|
+
const [settings, configured, registered] = await Promise.all([
|
|
139
|
+
ctx.remote.settings.describe(),
|
|
140
|
+
ctx.remote.llm.listConfigurableProviders(),
|
|
141
|
+
ctx.remote.llm.listProviders(),
|
|
142
|
+
])
|
|
143
|
+
if (!settings.ok) throw new Error(settings.error.message)
|
|
144
|
+
if (!configured.ok) throw new Error(configured.error.message)
|
|
145
|
+
if (!registered.ok) throw new Error(registered.error.message)
|
|
146
|
+
|
|
147
|
+
const view = settings.value.namespaces.find((entry) => entry.ns === NS)
|
|
148
|
+
if (!view) throw new Error(`No settings namespace "${NS}"`)
|
|
149
|
+
const active = new Set(registered.value.map((provider) => provider.id))
|
|
150
|
+
const routes = configured.value.filter((provider) => active.has(provider.provider))
|
|
151
|
+
|
|
152
|
+
const providers = await Promise.all(routes.map(async (provider) => {
|
|
153
|
+
const view = settings.value.namespaces.find((entry) => entry.ns === provider.settingsNs)
|
|
154
|
+
const profile = atPath(view?.value, provider.settingsPath)
|
|
155
|
+
const declared = new Map(imageModels(profile).map((model) => [model.id, model]))
|
|
156
|
+
let official = []
|
|
157
|
+
try {
|
|
158
|
+
// The Host plugin owns the official `/models` query and its API key,
|
|
159
|
+
// so the browser never receives a credential.
|
|
160
|
+
const answer = await ctx.remote.llm.discoverModels(NS, { provider: provider.provider })
|
|
161
|
+
if (answer.ok) {
|
|
162
|
+
official = answer.value
|
|
163
|
+
.filter((model) => model?.id)
|
|
164
|
+
.map((model) => ({ id: model.id, name: model.name || model.id }))
|
|
165
|
+
}
|
|
166
|
+
} catch {
|
|
167
|
+
// The declared DSH catalog is still usable when discovery fails.
|
|
168
|
+
}
|
|
169
|
+
const unique = new Map()
|
|
170
|
+
for (const model of official) unique.set(model.id, model)
|
|
171
|
+
return {
|
|
172
|
+
id: provider.provider,
|
|
173
|
+
name: provider.displayName || provider.provider,
|
|
174
|
+
// The declared catalog is authoritative: the official endpoint may
|
|
175
|
+
// omit aliases or vision models entirely, so never hide a model
|
|
176
|
+
// just because /models did not return it.
|
|
177
|
+
models: [...declared.values()],
|
|
178
|
+
configured: configuredModels(profile).filter(
|
|
179
|
+
(model) => !declared.has(model.id) && isVisionName(model.id),
|
|
180
|
+
),
|
|
181
|
+
candidates: [...unique.values()].filter(
|
|
182
|
+
(model) => !declared.has(model.id) && isVisionName(model.id),
|
|
183
|
+
),
|
|
184
|
+
}
|
|
185
|
+
}))
|
|
186
|
+
|
|
187
|
+
const model = typeof view.value?.visionModel === 'string' ? view.value.visionModel : ''
|
|
188
|
+
const configuredProvider = typeof view.value?.visionProvider === 'string' ? view.value.visionProvider : ''
|
|
189
|
+
const owner = configuredProvider
|
|
190
|
+
|| providers.find((provider) => provider.models.some((entry) => entry.id === model))?.id
|
|
191
|
+
|| ''
|
|
192
|
+
|
|
193
|
+
setState({
|
|
194
|
+
phase: 'ready',
|
|
195
|
+
ns: view.ns,
|
|
196
|
+
revision: view.revision,
|
|
197
|
+
current: model ? routeKey(owner, model) : '',
|
|
198
|
+
providers: groupModels(providers),
|
|
199
|
+
})
|
|
200
|
+
}, [])
|
|
201
|
+
|
|
202
|
+
React.useEffect(() => {
|
|
203
|
+
load().catch((error) => setState({ phase: 'error', message: error.message }))
|
|
204
|
+
}, [load])
|
|
205
|
+
|
|
206
|
+
async function change(event) {
|
|
207
|
+
const { provider, model } = splitRoute(event.target.value)
|
|
208
|
+
setSaving(true)
|
|
209
|
+
setNotice(null)
|
|
210
|
+
try {
|
|
211
|
+
const answer = await ctx.remote.settings.mutate(
|
|
212
|
+
state.ns,
|
|
213
|
+
[
|
|
214
|
+
{ op: 'set', path: ['visionProvider'], value: provider },
|
|
215
|
+
{ op: 'set', path: ['visionModel'], value: model },
|
|
216
|
+
],
|
|
217
|
+
state.revision,
|
|
218
|
+
)
|
|
219
|
+
if (!answer.ok) throw new Error(answer.error.message)
|
|
220
|
+
const savedProvider = typeof answer.value.value?.visionProvider === 'string' ? answer.value.value.visionProvider : provider
|
|
221
|
+
const savedModel = typeof answer.value.value?.visionModel === 'string' ? answer.value.value.visionModel : model
|
|
222
|
+
setState((previous) => ({
|
|
223
|
+
...previous,
|
|
224
|
+
revision: answer.value.revision,
|
|
225
|
+
current: savedModel ? routeKey(savedProvider, savedModel) : '',
|
|
226
|
+
}))
|
|
227
|
+
setNotice({ kind: 'ok', text: t('saved') })
|
|
228
|
+
} catch (error) {
|
|
229
|
+
setNotice({ kind: 'error', text: `${t('saveFailed')}: ${error.message}` })
|
|
230
|
+
} finally {
|
|
231
|
+
setSaving(false)
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (state.phase === 'loading') {
|
|
236
|
+
return h('section', { style: box }, h('h2', { style: title }, t('title')), h('p', { style: intro }, t('loading')))
|
|
237
|
+
}
|
|
238
|
+
if (state.phase === 'error') {
|
|
239
|
+
return h('section', { style: box },
|
|
240
|
+
h('h2', { style: title }, t('title')),
|
|
241
|
+
h('p', { style: { ...intro, color: 'var(--dsw-alias-state-error-primary)' } }, `${t('loadFailed')}: ${state.message}`),
|
|
242
|
+
)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const options = []
|
|
246
|
+
options.push(h('option', { key: '', value: '' }, t('auto')))
|
|
247
|
+
const known = new Set()
|
|
248
|
+
for (const provider of state.providers) {
|
|
249
|
+
if (provider.models.length === 0) continue
|
|
250
|
+
const children = []
|
|
251
|
+
for (const model of provider.models) {
|
|
252
|
+
const route = routeKey(provider.id, model.id)
|
|
253
|
+
known.add(route)
|
|
254
|
+
children.push(h('option', { key: route, value: route }, `${model.name} (${provider.name})`))
|
|
255
|
+
}
|
|
256
|
+
options.push(h('optgroup', { key: provider.id, label: `${t('provider')}: ${provider.name}` }, children))
|
|
257
|
+
}
|
|
258
|
+
for (const provider of state.providers) {
|
|
259
|
+
if (provider.configured.length === 0) continue
|
|
260
|
+
const children = []
|
|
261
|
+
for (const model of provider.configured) {
|
|
262
|
+
const route = routeKey(provider.id, model.id)
|
|
263
|
+
known.add(route)
|
|
264
|
+
children.push(h('option', { key: route, value: route }, `${model.name} (${provider.name} · ${t('configured')})`))
|
|
265
|
+
}
|
|
266
|
+
options.push(h('optgroup', {
|
|
267
|
+
key: `configured:${provider.id}`,
|
|
268
|
+
label: `${t('provider')}: ${provider.name} · ${t('configured')}`,
|
|
269
|
+
}, children))
|
|
270
|
+
}
|
|
271
|
+
if (showCandidates) {
|
|
272
|
+
for (const provider of state.providers) {
|
|
273
|
+
if (provider.candidates.length === 0) continue
|
|
274
|
+
const children = []
|
|
275
|
+
for (const model of provider.candidates) {
|
|
276
|
+
const route = routeKey(provider.id, model.id)
|
|
277
|
+
known.add(route)
|
|
278
|
+
children.push(h('option', { key: route, value: route }, `${model.name} (${provider.name} · ${t('undeclared')})`))
|
|
279
|
+
}
|
|
280
|
+
options.push(h('optgroup', {
|
|
281
|
+
key: `candidates:${provider.id}`,
|
|
282
|
+
label: `${t('provider')}: ${provider.name} · ${t('undeclared')}`,
|
|
283
|
+
}, children))
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
if (state.current && !known.has(state.current)) {
|
|
287
|
+
options.push(h('option', { key: 'custom', value: state.current }, `${state.current} (${t('undeclared')})`))
|
|
288
|
+
}
|
|
289
|
+
const hasGuaranteed = state.providers.some((provider) => provider.models.length > 0)
|
|
290
|
+
const hasCandidates = state.providers.some((provider) => provider.candidates.length > 0)
|
|
291
|
+
|
|
292
|
+
return h('section', { style: box },
|
|
293
|
+
h('h2', { style: title }, t('title')),
|
|
294
|
+
h('p', { style: intro }, t('intro')),
|
|
295
|
+
h('div', { style: card },
|
|
296
|
+
h('label', { style: label }, t('target'),
|
|
297
|
+
h('select', {
|
|
298
|
+
style: select,
|
|
299
|
+
value: state.current,
|
|
300
|
+
disabled: saving,
|
|
301
|
+
onChange: change,
|
|
302
|
+
}, options),
|
|
303
|
+
),
|
|
304
|
+
h('p', { style: hint }, t('hint')),
|
|
305
|
+
hasCandidates ? h('label', {
|
|
306
|
+
style: { ...hint, display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' },
|
|
307
|
+
},
|
|
308
|
+
h('input', {
|
|
309
|
+
type: 'checkbox',
|
|
310
|
+
checked: showCandidates,
|
|
311
|
+
disabled: saving,
|
|
312
|
+
onChange: (event) => setShowCandidates(event.target.checked),
|
|
313
|
+
}),
|
|
314
|
+
t('showCandidates'),
|
|
315
|
+
) : null,
|
|
316
|
+
h('div', { style: row },
|
|
317
|
+
h('button', {
|
|
318
|
+
type: 'button',
|
|
319
|
+
style: button,
|
|
320
|
+
disabled: saving,
|
|
321
|
+
onClick: () => load().catch((error) => setState({ phase: 'error', message: error.message })),
|
|
322
|
+
}, t('refresh')),
|
|
323
|
+
saving ? h('span', { style: hint }, t('saving')) : null,
|
|
324
|
+
notice ? h('span', {
|
|
325
|
+
style: { ...hint, color: notice.kind === 'error' ? 'var(--dsw-alias-state-error-primary)' : 'var(--dsw-alias-state-success-primary)' },
|
|
326
|
+
}, notice.text) : null,
|
|
327
|
+
),
|
|
328
|
+
!hasGuaranteed ? h('p', { style: hint }, t('noModels')) : null,
|
|
329
|
+
),
|
|
330
|
+
)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
let ctx
|
|
334
|
+
return {
|
|
335
|
+
inject: ['slots', 'locale', 'remote', 'remote.llm', 'remote.settings'],
|
|
336
|
+
apply(root) {
|
|
337
|
+
ctx = root
|
|
338
|
+
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-auto-vision: locale')
|
|
339
|
+
const t = ctx.locale.bind(NS)
|
|
340
|
+
ctx.slots.inject('settings.section', () => ctx.slots.register({
|
|
341
|
+
name: 'settings.section',
|
|
342
|
+
id: NS,
|
|
343
|
+
order: 30,
|
|
344
|
+
label: () => t('nav'),
|
|
345
|
+
}, AutoVisionSection))
|
|
346
|
+
},
|
|
347
|
+
}
|
|
348
|
+
},
|
|
349
|
+
})
|