dsh-vision-router 1.0.0
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 +268 -0
- package/README.zh.md +268 -0
- package/assets/dsh-conversation-image-qa-result.png +0 -0
- package/assets/dsh-conversation-image-qa.png +0 -0
- package/assets/hero-zh.svg +41 -0
- package/assets/hero.svg +122 -0
- package/assets/how-it-works-zh.svg +1 -0
- package/assets/how-it-works.svg +1 -0
- package/assets/pixel-loop-zh.png +0 -0
- package/assets/pixel-loop.png +0 -0
- package/assets/vision-demo.gif +0 -0
- package/assets/vision-settings.png +0 -0
- package/assets/vision-tools-zh.svg +4 -0
- package/assets/vision-tools.svg +4 -0
- package/cordis.patch.yml +25 -0
- package/docs/free-models.zh-CN.md +166 -0
- package/index.js +3201 -0
- package/lib/client.js +866 -0
- package/package.json +75 -0
- package/presets/README.md +33 -0
- package/presets/dashscope.yaml +18 -0
- package/presets/openrouter.yaml +17 -0
- package/presets/ovh.yaml +14 -0
- package/presets/siliconflow.yaml +18 -0
- package/presets/zhipu.yaml +18 -0
package/lib/client.js
ADDED
|
@@ -0,0 +1,866 @@
|
|
|
1
|
+
// dsh-vision-router browser half: the 设置 > 插件 > 插件配置 card that edits
|
|
2
|
+
// the `vision-router` settings section owned by the host half. Self-contained
|
|
3
|
+
// by hand (no bundler in this repo): the client module system wraps it in a
|
|
4
|
+
// CJS factory and the kernel adopts { apply, inject } as a client plugin.
|
|
5
|
+
window.__ModuleLoader__.load({
|
|
6
|
+
id: 'dsh-vision-router',
|
|
7
|
+
factory: (require) => {
|
|
8
|
+
const module = { exports: {} }
|
|
9
|
+
const exports = module.exports
|
|
10
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
|
|
11
|
+
const React = require('react')
|
|
12
|
+
const { useState, useMemo } = React
|
|
13
|
+
|
|
14
|
+
// ── locale dictionaries (follow the app's language setting) ─────────────
|
|
15
|
+
const NS = 'vision-router'
|
|
16
|
+
const zh = {
|
|
17
|
+
nav: '视觉路由(自动识图)',
|
|
18
|
+
desc: '默认即用内置免费视觉模型;已配置的模型可直接选择,细节收在高级设置里 · 面板 v5',
|
|
19
|
+
pending: '未保存',
|
|
20
|
+
readOnly: '当前设置提供方只读。',
|
|
21
|
+
overridden: '已覆盖',
|
|
22
|
+
reset: '恢复默认',
|
|
23
|
+
invalidProviders: '每行需为「provider/model」,例如 openrouter/qwen3-vl-235b',
|
|
24
|
+
invalidTextProvider: '格式为「provider/model」,例如 deepseek-official/deepseek-v4-pro',
|
|
25
|
+
invalidTimeout: '需为 ≥1000 的整数(毫秒)',
|
|
26
|
+
invalidGeneric: '输入无效',
|
|
27
|
+
selectProvider: '选择供应商…',
|
|
28
|
+
selectModel: '选择模型…',
|
|
29
|
+
pickProviderFirst: '先选供应商',
|
|
30
|
+
freeTag: '(免费)',
|
|
31
|
+
builtinFreeTag: '(内置免费模型)',
|
|
32
|
+
chainLabel: '视觉模型链(按顺序失败回退)',
|
|
33
|
+
chainHint: '第一行是主视觉模型,后面的行在其失败时依次回退;清空 = 仅用内置免费模型兜底。',
|
|
34
|
+
addFallback: '+ 添加备用模型',
|
|
35
|
+
remove: '移除',
|
|
36
|
+
removeTitle: '移除这一行',
|
|
37
|
+
textModelLabel: '文本模型(文字轮走它)',
|
|
38
|
+
textModelHint: '通常无需设置;见上方说明,留空即恢复默认。',
|
|
39
|
+
defaultChainNote:
|
|
40
|
+
'默认用内置免费视觉模型(免注册、免 Key);「设置 > 模型」里配过的供应商会出现在下面的下拉里,' +
|
|
41
|
+
'选了才覆盖默认,不选就是默认。',
|
|
42
|
+
catalogLoading: '正在加载模型目录(与「设置 > 模型」同源)…',
|
|
43
|
+
catalogUnavailable: '连接服务不可用(拿不到模型目录),退回手动输入。',
|
|
44
|
+
catalogTimeout: '目录请求超时(15 秒)',
|
|
45
|
+
catalogEmpty: '模型目录为空:',
|
|
46
|
+
catalogErrorEnvelope: '模型目录接口返回失败',
|
|
47
|
+
catalogError: '模型目录不可用(',
|
|
48
|
+
catalogFallback: '),模型字段已退回手动输入。',
|
|
49
|
+
retryCatalog: '重试加载目录',
|
|
50
|
+
advanced: '高级设置',
|
|
51
|
+
groupTextModel: '文本模型(仅特殊场景)',
|
|
52
|
+
textModelGroupHint:
|
|
53
|
+
'平时文字轮直接使用右下角选择的会话模型,无需设置;此字段仅在开启' +
|
|
54
|
+
'「图片轮整轮自动路由」且会话入口为视觉路由时,作为文字轮的回退模型。',
|
|
55
|
+
groupBehavior: '行为开关',
|
|
56
|
+
groupParams: '参数',
|
|
57
|
+
groupRoutes: '路由名',
|
|
58
|
+
groupProxy: '代理',
|
|
59
|
+
proxyLabel: '代理地址',
|
|
60
|
+
proxyHint: '如 http://127.0.0.1:10808 或 socks5h://127.0.0.1:10808;留空关闭。修改即时生效。',
|
|
61
|
+
proxyHostsLabel: '走代理的域名(每行一个)',
|
|
62
|
+
proxyHostsHint: '仅这些域名经代理,其余直连;留空清除覆盖。修改即时生效。',
|
|
63
|
+
saveFailed: '保存失败:宿主拒绝了本次写入,请重试。',
|
|
64
|
+
discard: '放弃修改',
|
|
65
|
+
save: '保存',
|
|
66
|
+
saving: '保存中…',
|
|
67
|
+
renderFailed: '设置卡渲染失败:',
|
|
68
|
+
toggleRouting: '图片轮整轮自动路由',
|
|
69
|
+
toggleReverseRouting: '文字轮反向路由',
|
|
70
|
+
toggleTool: '识图工具',
|
|
71
|
+
toggleRewriteImages: '图片块改写',
|
|
72
|
+
toggleDownscale: '图片自动压缩',
|
|
73
|
+
toggleCache: '识图答案缓存',
|
|
74
|
+
toggleFreeFallback: '免费兜底',
|
|
75
|
+
toggleStealth: '隐身模式',
|
|
76
|
+
hintRouting:
|
|
77
|
+
'默认关闭:图片轮不整轮切到视觉模型,而是像普通文本轮一样由会话模型调用 ' +
|
|
78
|
+
'vision_describe 等工具看图,可连续多步操作(定位→裁剪→对比…)。' +
|
|
79
|
+
'开启后恢复旧的整轮一次性自动识图行为;注意:开启时降级链只包含 ' +
|
|
80
|
+
'「视觉模型链」里的 provider+fallbacks,httpProviders(含免费兜底端点)不参与。',
|
|
81
|
+
hintReverseRouting: '开启图片轮整轮路由时,把纯文字轮反向路由回文本模型;默认开启。',
|
|
82
|
+
hintTool: 'vision_describe / vision_ground 等像素级视觉工具;关闭后这些工具不可用。',
|
|
83
|
+
hintRewriteImages:
|
|
84
|
+
'把消息里的图片块替换为文字:已有视觉记录就给出记录,否则给出附件标记,' +
|
|
85
|
+
'文本模型始终不会收到它看不懂的图片内容。',
|
|
86
|
+
hintDownscale: '超过像素预算的图片先缩放再送视觉模型,降低延迟与成本;默认开启。',
|
|
87
|
+
hintCache: '缓存识图答案(按图片内容 + 问题);默认开启。',
|
|
88
|
+
hintFreeFallback: '未显式配置 httpProviders 时启用内置免 Key 免费端点兜底;默认开启。',
|
|
89
|
+
hintStealth:
|
|
90
|
+
'接管官方 DeepSeek 路由,模型选择器保持原样。需在 profile 补丁层 ' +
|
|
91
|
+
'(cordis.patch.yml)禁用官方 llm-deepseek 行后才真正接管;官方行还在时 ' +
|
|
92
|
+
'插件自动回退为选择器里的「自动识图」包装路由。改动后需重启 dsh 生效。',
|
|
93
|
+
numTimeoutMs: '视觉请求超时(毫秒)',
|
|
94
|
+
numDownscaleMaxPixels: '图片像素预算',
|
|
95
|
+
numCacheTtlSeconds: '缓存有效期(秒)',
|
|
96
|
+
numCacheMaxEntries: '缓存条目上限',
|
|
97
|
+
numHintTimeoutMs: '单个视觉请求超时;默认 120000。',
|
|
98
|
+
numHintDownscaleMaxPixels: '约 8MP = 8000000;超过的图先缩放再送视觉模型。',
|
|
99
|
+
numHintCacheTtlSeconds: '视觉答案缓存有效期;0 = 永久;默认 3600。',
|
|
100
|
+
numHintCacheMaxEntries: '缓存 LRU 容量;默认 200。',
|
|
101
|
+
textWrapperRoute: '包装路由名',
|
|
102
|
+
textChainRoute: '视觉链路由名',
|
|
103
|
+
textHintWrapperRoute: '模型选择器里显示的「自动识图」入口路由名。',
|
|
104
|
+
textHintChainRoute: '视觉链的挂载路由名(识图工具经由真实 provider 直接调用,不经过它)。',
|
|
105
|
+
textProviders: '视觉模型链',
|
|
106
|
+
textProvidersHint: '每行一个「provider/model」,从上到下失败回退;留空清除用户覆盖。',
|
|
107
|
+
textTextProvider: '文本模型',
|
|
108
|
+
textTextProviderHint: '格式「provider/model」。',
|
|
109
|
+
}
|
|
110
|
+
const en = {
|
|
111
|
+
nav: 'Vision Router (auto image understanding)',
|
|
112
|
+
desc: 'Built-in free vision model by default; configured models become selectable, details under Advanced · panel v5',
|
|
113
|
+
pending: 'Unsaved',
|
|
114
|
+
readOnly: 'The active settings provider is read-only.',
|
|
115
|
+
overridden: 'Overridden',
|
|
116
|
+
reset: 'Reset',
|
|
117
|
+
invalidProviders: 'Each line must be "provider/model", e.g. openrouter/qwen3-vl-235b',
|
|
118
|
+
invalidTextProvider: 'Format "provider/model", e.g. deepseek-official/deepseek-v4-pro',
|
|
119
|
+
invalidTimeout: 'Must be an integer ≥ 1000 (milliseconds)',
|
|
120
|
+
invalidGeneric: 'Invalid input',
|
|
121
|
+
selectProvider: 'Select provider…',
|
|
122
|
+
selectModel: 'Select model…',
|
|
123
|
+
pickProviderFirst: 'Pick a provider first',
|
|
124
|
+
freeTag: ' (free)',
|
|
125
|
+
builtinFreeTag: ' (built-in free model)',
|
|
126
|
+
chainLabel: 'Vision chain (top-down failover)',
|
|
127
|
+
chainHint: 'The first row is the primary vision model; later rows are tried in order on failure. Empty = the built-in free fallback only.',
|
|
128
|
+
addFallback: '+ Add fallback model',
|
|
129
|
+
remove: 'Remove',
|
|
130
|
+
removeTitle: 'Remove this row',
|
|
131
|
+
textModelLabel: 'Text model (text turns use it)',
|
|
132
|
+
textModelHint: 'Usually unneeded; leave empty to restore the default.',
|
|
133
|
+
defaultChainNote:
|
|
134
|
+
'The built-in free vision model (no signup, no key) is the default; providers configured in ' +
|
|
135
|
+
'Settings → Models appear in the dropdowns below. Picking one overrides the default; not picking keeps it.',
|
|
136
|
+
catalogLoading: 'Loading the model catalog (same source as Settings → Models)…',
|
|
137
|
+
catalogUnavailable: 'Connection service unavailable (no model catalog); falling back to free-text input.',
|
|
138
|
+
catalogTimeout: 'Catalog request timed out (15s)',
|
|
139
|
+
catalogEmpty: 'Model catalog is empty: ',
|
|
140
|
+
catalogErrorEnvelope: 'The model catalog endpoint failed',
|
|
141
|
+
catalogError: 'Model catalog unavailable (',
|
|
142
|
+
catalogFallback: '); model fields fell back to free-text input.',
|
|
143
|
+
retryCatalog: 'Retry catalog',
|
|
144
|
+
advanced: 'Advanced settings',
|
|
145
|
+
groupTextModel: 'Text model (special cases only)',
|
|
146
|
+
textModelGroupHint:
|
|
147
|
+
'Text turns normally use the session model picked in the composer; this field only acts as the ' +
|
|
148
|
+
'text-turn fallback when whole-turn routing is on and the session entry is a vision route.',
|
|
149
|
+
groupBehavior: 'Behavior',
|
|
150
|
+
groupParams: 'Parameters',
|
|
151
|
+
groupRoutes: 'Route names',
|
|
152
|
+
groupProxy: 'Proxy',
|
|
153
|
+
proxyLabel: 'Proxy URL',
|
|
154
|
+
proxyHint: 'e.g. http://127.0.0.1:10808 or socks5h://127.0.0.1:10808; empty disables. Applies immediately.',
|
|
155
|
+
proxyHostsLabel: 'Proxied hosts (one per line)',
|
|
156
|
+
proxyHostsHint: 'Only these hosts go through the proxy; everything else stays direct. Empty clears the override. Applies immediately.',
|
|
157
|
+
saveFailed: 'Save failed: the host rejected this write, please retry.',
|
|
158
|
+
discard: 'Discard',
|
|
159
|
+
save: 'Save',
|
|
160
|
+
saving: 'Saving…',
|
|
161
|
+
renderFailed: 'Settings card failed to render: ',
|
|
162
|
+
toggleRouting: 'Whole-turn vision routing',
|
|
163
|
+
toggleReverseRouting: 'Reverse routing for text turns',
|
|
164
|
+
toggleTool: 'Vision tools',
|
|
165
|
+
toggleRewriteImages: 'Image-block rewriting',
|
|
166
|
+
toggleDownscale: 'Auto downscale',
|
|
167
|
+
toggleCache: 'Vision answer cache',
|
|
168
|
+
toggleFreeFallback: 'Free fallback',
|
|
169
|
+
toggleStealth: 'Stealth mode',
|
|
170
|
+
hintRouting:
|
|
171
|
+
'Off by default: image turns are not switched to the vision model as a whole; instead the ' +
|
|
172
|
+
'session model looks at images through vision_describe and friends like any tool call, enabling ' +
|
|
173
|
+
'continuous multi-step work (ground → crop → diff → …). Turning it on restores the legacy one-shot ' +
|
|
174
|
+
'whole-turn behavior. Note: with routing on, the fallback chain only contains provider+fallbacks ' +
|
|
175
|
+
'from the vision chain; httpProviders (including the free fallback) do not participate.',
|
|
176
|
+
hintReverseRouting: 'With whole-turn routing on, route plain text turns back to the text model; on by default.',
|
|
177
|
+
hintTool: 'Pixel-level vision tools such as vision_describe / vision_ground; turning this off disables them.',
|
|
178
|
+
hintRewriteImages:
|
|
179
|
+
'Replaces image blocks in the model input with text: a recorded vision description when one exists, ' +
|
|
180
|
+
'otherwise an attachment marker — a text-only model never receives image content it cannot handle.',
|
|
181
|
+
hintDownscale: 'Images beyond the pixel budget are resized before the vision call, cutting latency and cost; on by default.',
|
|
182
|
+
hintCache: 'Caches vision answers (keyed by image content + question); on by default.',
|
|
183
|
+
hintFreeFallback: 'Enables the built-in keyless free endpoint fallback when httpProviders are not explicitly configured; on by default.',
|
|
184
|
+
hintStealth:
|
|
185
|
+
'Takes over the official DeepSeek route while the model picker looks exactly like stock. Requires the ' +
|
|
186
|
+
'official llm-deepseek row to be disabled in your profile patch layer (cordis.patch.yml); while the ' +
|
|
187
|
+
'stock row is present the plugin falls back to the visible "auto image understanding" wrapper entry. ' +
|
|
188
|
+
'Restart dsh after changing this.',
|
|
189
|
+
numTimeoutMs: 'Vision request timeout (ms)',
|
|
190
|
+
numDownscaleMaxPixels: 'Image pixel budget',
|
|
191
|
+
numCacheTtlSeconds: 'Cache TTL (seconds)',
|
|
192
|
+
numCacheMaxEntries: 'Cache entry limit',
|
|
193
|
+
numHintTimeoutMs: 'Per vision-call deadline; default 120000.',
|
|
194
|
+
numHintDownscaleMaxPixels: 'About 8MP = 8000000; larger images are resized before the vision call.',
|
|
195
|
+
numHintCacheTtlSeconds: 'Vision answer cache lifetime; 0 = forever; default 3600.',
|
|
196
|
+
numHintCacheMaxEntries: 'Cache LRU capacity; default 200.',
|
|
197
|
+
textWrapperRoute: 'Wrapper route name',
|
|
198
|
+
textChainRoute: 'Chain route name',
|
|
199
|
+
textHintWrapperRoute: 'The "auto image understanding" entry route shown in the model picker.',
|
|
200
|
+
textHintChainRoute: 'The fallback chain mount route (vision tools call real providers directly, not through it).',
|
|
201
|
+
textProviders: 'Vision chain',
|
|
202
|
+
textProvidersHint: 'One "provider/model" per line, top-down failover; empty clears the override.',
|
|
203
|
+
textTextProvider: 'Text model',
|
|
204
|
+
textTextProviderHint: 'Format "provider/model".',
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Unwrap the client RPC envelope the `connection` api returns for unary
|
|
209
|
+
* calls: { rpcId, result: { ok, value } }. The catalog fields live in
|
|
210
|
+
* `result.value`, NOT on the envelope itself.
|
|
211
|
+
*/
|
|
212
|
+
function unwrapModelsResult(body, t) {
|
|
213
|
+
if (body && typeof body === 'object' && body.result && typeof body.result === 'object') {
|
|
214
|
+
if (body.result.ok !== true) {
|
|
215
|
+
const message =
|
|
216
|
+
body.result.error && body.result.error.message
|
|
217
|
+
? body.result.error.message
|
|
218
|
+
: t('catalogErrorEnvelope')
|
|
219
|
+
throw new Error(message)
|
|
220
|
+
}
|
|
221
|
+
return body.result.value
|
|
222
|
+
}
|
|
223
|
+
return body
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ── field specs ──────────────────────────────────────────────────────────
|
|
227
|
+
const TOGGLE_KEYS = ['routing', 'tool', 'stealth']
|
|
228
|
+
const ADVANCED_TOGGLE_KEYS = ['reverseRouting', 'rewriteImages', 'downscale', 'cache', 'freeFallback']
|
|
229
|
+
const ALL_TOGGLE_KEYS = [...TOGGLE_KEYS, ...ADVANCED_TOGGLE_KEYS]
|
|
230
|
+
const NUMBER_KEYS = ['timeoutMs', 'downscaleMaxPixels', 'cacheTtlSeconds', 'cacheMaxEntries']
|
|
231
|
+
const NUMBER_META = {
|
|
232
|
+
timeoutMs: { min: 1000 },
|
|
233
|
+
downscaleMaxPixels: { min: 1000 },
|
|
234
|
+
cacheTtlSeconds: { min: 0 },
|
|
235
|
+
cacheMaxEntries: { min: 1 },
|
|
236
|
+
}
|
|
237
|
+
const TEXT_KEYS = ['wrapperRoute', 'chainRoute']
|
|
238
|
+
|
|
239
|
+
function readValue(snapshot, key) {
|
|
240
|
+
const value = snapshot && snapshot.value
|
|
241
|
+
return value && typeof value === 'object' ? value[key] : undefined
|
|
242
|
+
}
|
|
243
|
+
function userHas(snapshot, key) {
|
|
244
|
+
const user = snapshot && snapshot.user
|
|
245
|
+
return user && typeof user === 'object' && key in user
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function providersToText(value) {
|
|
249
|
+
if (!Array.isArray(value)) return ''
|
|
250
|
+
return value
|
|
251
|
+
.map((pair) => (pair && pair.provider ? `${pair.provider}/${pair.model ?? ''}` : ''))
|
|
252
|
+
.join('\n')
|
|
253
|
+
}
|
|
254
|
+
function parseProviders(text) {
|
|
255
|
+
const list = []
|
|
256
|
+
for (const raw of String(text ?? '').split('\n')) {
|
|
257
|
+
const line = raw.trim()
|
|
258
|
+
if (line === '') continue
|
|
259
|
+
const idx = line.indexOf('/')
|
|
260
|
+
if (idx <= 0) return undefined
|
|
261
|
+
const provider = line.slice(0, idx).trim()
|
|
262
|
+
const model = line.slice(idx + 1).trim()
|
|
263
|
+
if (provider === '' || model === '') return undefined
|
|
264
|
+
list.push({ provider, model })
|
|
265
|
+
}
|
|
266
|
+
return list
|
|
267
|
+
}
|
|
268
|
+
function textProviderToText(value) {
|
|
269
|
+
if (!value || typeof value !== 'object') return ''
|
|
270
|
+
return `${value.provider ?? ''}/${value.model ?? ''}`
|
|
271
|
+
}
|
|
272
|
+
function parseTextProvider(text) {
|
|
273
|
+
const idx = String(text ?? '').indexOf('/')
|
|
274
|
+
if (idx <= 0) return undefined
|
|
275
|
+
const provider = String(text).slice(0, idx).trim()
|
|
276
|
+
const model = String(text).slice(idx + 1).trim()
|
|
277
|
+
if (provider === '' || model === '') return undefined
|
|
278
|
+
return { provider, model }
|
|
279
|
+
}
|
|
280
|
+
function parseNumber(text, min) {
|
|
281
|
+
const trimmed = String(text ?? '').trim()
|
|
282
|
+
if (trimmed === '') return { clear: true }
|
|
283
|
+
const parsed = Number(trimmed)
|
|
284
|
+
return Number.isInteger(parsed) && parsed >= min ? { value: parsed } : undefined
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ── styles: mirrors the built-in plugin cards (same design tokens) ──────
|
|
288
|
+
const CSS =
|
|
289
|
+
'.vr-card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;list-style:none;transition:border-color .16s,background .16s}' +
|
|
290
|
+
'.vr-card:hover{border-color:var(--dsw-alias-label-dimmed)}' +
|
|
291
|
+
'.vr-card-open{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)}' +
|
|
292
|
+
'.vr-header{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px;align-items:center;gap:12px;padding:14px 16px;display:flex}' +
|
|
293
|
+
'.vr-header:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}' +
|
|
294
|
+
'.vr-headText{flex-direction:column;flex:1;gap:4px;min-width:0;display:flex}' +
|
|
295
|
+
'.vr-name{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}' +
|
|
296
|
+
'.vr-desc{color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.5}' +
|
|
297
|
+
'.vr-pending{white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);border-radius:999px;flex:none;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px}' +
|
|
298
|
+
'.vr-chevron{color:var(--dsw-alias-label-tertiary);flex:none;transition:transform .16s;font-size:12px;line-height:1}' +
|
|
299
|
+
'.vr-chevron-open{transform:rotate(180deg)}' +
|
|
300
|
+
'.vr-body{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:8px}' +
|
|
301
|
+
'.vr-readOnly{color:var(--dsw-alias-label-tertiary);margin:12px 0 0;font-size:12px;line-height:1.5}' +
|
|
302
|
+
'.vr-field{flex-direction:column;gap:6px;padding:12px 0;display:flex}' +
|
|
303
|
+
'.vr-field + .vr-field{border-top:1px solid var(--dsw-alias-border-l2)}' +
|
|
304
|
+
'.vr-field-head{align-items:center;gap:8px;display:flex}' +
|
|
305
|
+
'.vr-label{min-width:0;color:var(--dsw-alias-label-primary);flex:1;font-size:13px;font-weight:500;line-height:1.5}' +
|
|
306
|
+
'.vr-badges{align-items:center;gap:8px;display:inline-flex}' +
|
|
307
|
+
'.vr-badge{white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);border-radius:999px;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px}' +
|
|
308
|
+
'.vr-reset{font:inherit;color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:none;padding:0;font-size:12px;line-height:1.5}' +
|
|
309
|
+
'.vr-reset:hover:not(:disabled){color:var(--dsw-alias-label-primary)}' +
|
|
310
|
+
'.vr-reset:disabled{cursor:default}' +
|
|
311
|
+
'.vr-input{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);min-height:34px;font:inherit;color:var(--dsw-alias-label-primary);border-radius:8px;padding:6px 12px;font-size:13px;line-height:1.5;width:100%;box-sizing:border-box}' +
|
|
312
|
+
'.vr-input:focus-visible{border-color:var(--dsw-alias-brand-primary);outline:none}' +
|
|
313
|
+
'.vr-input:disabled{color:var(--dsw-alias-label-tertiary);cursor:default}' +
|
|
314
|
+
'.vr-input-invalid{border-color:var(--dsw-alias-label-error)}' +
|
|
315
|
+
'.vr-area{resize:vertical;min-height:84px;font-family:monospace}' +
|
|
316
|
+
'.vr-check{width:16px;height:16px;accent-color:var(--dsw-alias-brand-primary);cursor:pointer;margin:0}' +
|
|
317
|
+
'.vr-chain-row{display:flex;align-items:center;gap:8px;margin:6px 0}' +
|
|
318
|
+
'.vr-catalog-error{display:flex;align-items:center;gap:10px;flex-wrap:wrap}' +
|
|
319
|
+
'.vr-subheader{display:flex;align-items:center;justify-content:space-between;gap:8px;width:100%;padding:8px 0;border:none;background:none;cursor:pointer;font:inherit;color:var(--dsw-alias-label-primary);text-align:left}' +
|
|
320
|
+
'.vr-group{border-top:1px solid var(--dsw-alias-border-l2);padding:10px 0 2px;display:flex;flex-direction:column;gap:8px}' +
|
|
321
|
+
'.vr-group-title{font-size:12px;font-weight:600;color:var(--dsw-alias-label-tertiary);margin:0}' +
|
|
322
|
+
'.vr-select{flex:1;min-width:0;font:inherit}' +
|
|
323
|
+
'.vr-invalid{color:var(--dsw-alias-label-error);margin:0;font-size:12px;line-height:1.5}' +
|
|
324
|
+
'.vr-hint{color:var(--dsw-alias-label-tertiary);margin:0;font-size:12px;line-height:1.5}' +
|
|
325
|
+
'.vr-toggle{display:flex;align-items:center;gap:10px;justify-content:space-between;width:100%}' +
|
|
326
|
+
'.vr-footer{border-top:1px solid var(--dsw-alias-border-l2);justify-content:flex-end;align-items:center;gap:8px;padding:12px 0 4px;display:flex}' +
|
|
327
|
+
'.vr-failed{min-width:0;color:var(--dsw-alias-label-error);flex:1;margin:0;font-size:12px;line-height:1.5}' +
|
|
328
|
+
'.vr-btn{appearance:none;font:inherit;cursor:pointer;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:5px 14px;font-size:13px;line-height:1.5;color:var(--dsw-alias-label-secondary);background:0 0}' +
|
|
329
|
+
'.vr-btn:hover:not(:disabled){color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-dimmed)}' +
|
|
330
|
+
'.vr-btn-save{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3);border-color:#0000}' +
|
|
331
|
+
'.vr-btn:disabled{opacity:.4;cursor:default}' +
|
|
332
|
+
'.vr-btn:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}'
|
|
333
|
+
|
|
334
|
+
let stylesInstalled = false
|
|
335
|
+
function installStyles() {
|
|
336
|
+
if (stylesInstalled || typeof document === 'undefined') return
|
|
337
|
+
stylesInstalled = true
|
|
338
|
+
const tag = document.createElement('style')
|
|
339
|
+
tag.dataset.plugin = 'dsh-vision-router'
|
|
340
|
+
tag.dataset.pluginCss = 'dsh-vision-router/plugin-card'
|
|
341
|
+
tag.textContent = CSS
|
|
342
|
+
document.head.appendChild(tag)
|
|
343
|
+
return () => {
|
|
344
|
+
tag.remove()
|
|
345
|
+
stylesInstalled = false
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const LABEL_KEY = {
|
|
350
|
+
routing: 'toggleRouting',
|
|
351
|
+
reverseRouting: 'toggleReverseRouting',
|
|
352
|
+
tool: 'toggleTool',
|
|
353
|
+
rewriteImages: 'toggleRewriteImages',
|
|
354
|
+
downscale: 'toggleDownscale',
|
|
355
|
+
cache: 'toggleCache',
|
|
356
|
+
freeFallback: 'toggleFreeFallback',
|
|
357
|
+
stealth: 'toggleStealth',
|
|
358
|
+
timeoutMs: 'numTimeoutMs',
|
|
359
|
+
downscaleMaxPixels: 'numDownscaleMaxPixels',
|
|
360
|
+
cacheTtlSeconds: 'numCacheTtlSeconds',
|
|
361
|
+
cacheMaxEntries: 'numCacheMaxEntries',
|
|
362
|
+
wrapperRoute: 'textWrapperRoute',
|
|
363
|
+
chainRoute: 'textChainRoute',
|
|
364
|
+
}
|
|
365
|
+
const HINT_KEY = {
|
|
366
|
+
routing: 'hintRouting',
|
|
367
|
+
reverseRouting: 'hintReverseRouting',
|
|
368
|
+
tool: 'hintTool',
|
|
369
|
+
rewriteImages: 'hintRewriteImages',
|
|
370
|
+
downscale: 'hintDownscale',
|
|
371
|
+
cache: 'hintCache',
|
|
372
|
+
freeFallback: 'hintFreeFallback',
|
|
373
|
+
stealth: 'hintStealth',
|
|
374
|
+
timeoutMs: 'numHintTimeoutMs',
|
|
375
|
+
downscaleMaxPixels: 'numHintDownscaleMaxPixels',
|
|
376
|
+
cacheTtlSeconds: 'numHintCacheTtlSeconds',
|
|
377
|
+
cacheMaxEntries: 'numHintCacheMaxEntries',
|
|
378
|
+
wrapperRoute: 'textHintWrapperRoute',
|
|
379
|
+
chainRoute: 'textHintChainRoute',
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function VisionRouterCard(props) {
|
|
383
|
+
const scope = props.scope
|
|
384
|
+
const t = props.t
|
|
385
|
+
const locale = props.locale
|
|
386
|
+
// Re-render on language switches: t() re-reads the active dictionary.
|
|
387
|
+
// The locale face's subscribe/getSnapshot are prototype methods using
|
|
388
|
+
// `this` exactly like the settings binder — bind them before handing
|
|
389
|
+
// them to useSyncExternalStore, or the card crashes with "Cannot read
|
|
390
|
+
// properties of undefined" and gets abdicated.
|
|
391
|
+
const localeSubscribe = useMemo(
|
|
392
|
+
() => (locale && typeof locale.subscribe === 'function' ? locale.subscribe.bind(locale) : () => () => {}),
|
|
393
|
+
[locale],
|
|
394
|
+
)
|
|
395
|
+
const localeGetSnapshot = useMemo(
|
|
396
|
+
() => (locale && typeof locale.getSnapshot === 'function' ? locale.getSnapshot.bind(locale) : () => undefined),
|
|
397
|
+
[locale],
|
|
398
|
+
)
|
|
399
|
+
React.useSyncExternalStore(localeSubscribe, localeGetSnapshot)
|
|
400
|
+
// The binder's getSnapshot/subscribe are prototype methods using `this`:
|
|
401
|
+
// passing them bare to useSyncExternalStore detaches the receiver and
|
|
402
|
+
// crashes with "Cannot read properties of undefined (reading 'store')".
|
|
403
|
+
// Bind them so a crash can never abdicate the card again.
|
|
404
|
+
const subscribe = useMemo(() => scope.subscribe.bind(scope), [scope])
|
|
405
|
+
const getSnapshot = useMemo(() => scope.getSnapshot.bind(scope), [scope])
|
|
406
|
+
const [drafts, setDrafts] = useState({})
|
|
407
|
+
const [saving, setSaving] = useState(false)
|
|
408
|
+
const [failed, setFailed] = useState(false)
|
|
409
|
+
const [open, setOpen] = useState(false)
|
|
410
|
+
const [showAdvanced, setShowAdvanced] = useState(false)
|
|
411
|
+
const [catalog, setCatalog] = useState({ status: 'idle', groups: [], error: undefined })
|
|
412
|
+
const catalogReady = catalog.status === 'ready' && catalog.groups.length > 0
|
|
413
|
+
const loadCatalog = () => {
|
|
414
|
+
if (catalog.status === 'loading' || catalog.status === 'ready') return
|
|
415
|
+
setCatalog({ status: 'loading', groups: [], error: undefined })
|
|
416
|
+
try {
|
|
417
|
+
const connection = props.getConnection ? props.getConnection() : undefined
|
|
418
|
+
const api = connection && connection.api
|
|
419
|
+
const modelsFn = api && api.llm && typeof api.llm.models === 'function' ? api.llm.models : undefined
|
|
420
|
+
if (modelsFn === undefined) {
|
|
421
|
+
setCatalog({ status: 'error', groups: [], error: t('catalogUnavailable') })
|
|
422
|
+
return
|
|
423
|
+
}
|
|
424
|
+
const timeout = new Promise((_resolve, reject) => {
|
|
425
|
+
setTimeout(() => reject(new Error(t('catalogTimeout'))), 15000)
|
|
426
|
+
})
|
|
427
|
+
Promise.race([
|
|
428
|
+
modelsFn({}).catch((error) => {
|
|
429
|
+
throw error && error.message ? error : new Error(String(error))
|
|
430
|
+
}),
|
|
431
|
+
timeout,
|
|
432
|
+
]).then((body) => unwrapModelsResult(body, t)).then(
|
|
433
|
+
(value) => {
|
|
434
|
+
const groups = (value && value.groups) || []
|
|
435
|
+
const failures = (value && value.failures) || []
|
|
436
|
+
if (groups.length === 0 && failures.length > 0) {
|
|
437
|
+
const detail = failures
|
|
438
|
+
.map((f) => (f && f.name ? f.name + ':' : '') + ((f && f.message) || ''))
|
|
439
|
+
.join(';').slice(0, 300)
|
|
440
|
+
setCatalog({ status: 'error', groups: [], error: t('catalogEmpty') + detail })
|
|
441
|
+
return
|
|
442
|
+
}
|
|
443
|
+
setCatalog({ status: 'ready', groups, error: undefined })
|
|
444
|
+
},
|
|
445
|
+
(error) => {
|
|
446
|
+
setCatalog({
|
|
447
|
+
status: 'error',
|
|
448
|
+
groups: [],
|
|
449
|
+
error: error && error.message ? error.message : String(error),
|
|
450
|
+
})
|
|
451
|
+
},
|
|
452
|
+
)
|
|
453
|
+
} catch (error) {
|
|
454
|
+
setCatalog({
|
|
455
|
+
status: 'error',
|
|
456
|
+
groups: [],
|
|
457
|
+
error: error && error.message ? error.message : String(error),
|
|
458
|
+
})
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
let snapshot
|
|
462
|
+
let renderError
|
|
463
|
+
try {
|
|
464
|
+
snapshot = React.useSyncExternalStore(subscribe, getSnapshot)
|
|
465
|
+
} catch (error) {
|
|
466
|
+
renderError = error
|
|
467
|
+
}
|
|
468
|
+
if (renderError !== undefined) {
|
|
469
|
+
return React.createElement(
|
|
470
|
+
'li',
|
|
471
|
+
{ className: 'vr-card' },
|
|
472
|
+
React.createElement(
|
|
473
|
+
'div',
|
|
474
|
+
{ style: { padding: '14px 16px', fontSize: 12, color: 'var(--dsw-alias-label-error)' } },
|
|
475
|
+
t('renderFailed') + (renderError && renderError.message ? renderError.message : String(renderError)),
|
|
476
|
+
),
|
|
477
|
+
)
|
|
478
|
+
}
|
|
479
|
+
if (!snapshot || snapshot.status !== 'ready') {
|
|
480
|
+
// Match the built-in cards: render nothing while the namespace is
|
|
481
|
+
// unavailable rather than a disabled card the user cannot act on.
|
|
482
|
+
return null
|
|
483
|
+
}
|
|
484
|
+
const writable = snapshot.writable
|
|
485
|
+
|
|
486
|
+
const format = (key) => {
|
|
487
|
+
if (key in drafts) return drafts[key]
|
|
488
|
+
const value = readValue(snapshot, key)
|
|
489
|
+
if (key === 'providers') {
|
|
490
|
+
if (catalogReady) return Array.isArray(value) ? value : []
|
|
491
|
+
return providersToText(value)
|
|
492
|
+
}
|
|
493
|
+
if (key === 'textProvider') {
|
|
494
|
+
if (catalogReady) {
|
|
495
|
+
return value && typeof value === 'object' ? value : { provider: '', model: '' }
|
|
496
|
+
}
|
|
497
|
+
return textProviderToText(value)
|
|
498
|
+
}
|
|
499
|
+
if (key === 'proxyHosts') return Array.isArray(value) ? value.join('\n') : ''
|
|
500
|
+
if (NUMBER_KEYS.includes(key)) return typeof value === 'number' ? String(value) : ''
|
|
501
|
+
if (ALL_TOGGLE_KEYS.includes(key)) return value === true
|
|
502
|
+
return typeof value === 'string' ? value : ''
|
|
503
|
+
}
|
|
504
|
+
const parse = (key, text) => {
|
|
505
|
+
if (ALL_TOGGLE_KEYS.includes(key)) return { value: text === true }
|
|
506
|
+
if (key === 'providers') {
|
|
507
|
+
if (catalogReady) {
|
|
508
|
+
const rows = Array.isArray(text) ? text : []
|
|
509
|
+
const half = rows.some((row) => row && (row.provider ? !row.model : !!row.model))
|
|
510
|
+
if (half) return undefined
|
|
511
|
+
const filled = rows.filter((row) => row && row.provider && row.model)
|
|
512
|
+
return filled.length > 0 ? { value: filled } : { clear: true }
|
|
513
|
+
}
|
|
514
|
+
const value = parseProviders(text)
|
|
515
|
+
return value === undefined ? undefined : { value }
|
|
516
|
+
}
|
|
517
|
+
if (key === 'textProvider') {
|
|
518
|
+
if (catalogReady) {
|
|
519
|
+
const pair = text && typeof text === 'object' ? text : { provider: '', model: '' }
|
|
520
|
+
if (pair.provider && pair.model) return { value: { provider: pair.provider, model: pair.model } }
|
|
521
|
+
if (!pair.provider && !pair.model) return { clear: true }
|
|
522
|
+
return undefined
|
|
523
|
+
}
|
|
524
|
+
const value = parseTextProvider(text)
|
|
525
|
+
return value === undefined ? undefined : { value }
|
|
526
|
+
}
|
|
527
|
+
if (NUMBER_KEYS.includes(key)) return parseNumber(text, NUMBER_META[key].min)
|
|
528
|
+
if (key === 'proxyHosts') {
|
|
529
|
+
const list = String(text ?? '')
|
|
530
|
+
.split('\n')
|
|
531
|
+
.map((host) => host.trim())
|
|
532
|
+
.filter((host) => host !== '')
|
|
533
|
+
return list.length > 0 ? { value: list } : { clear: true }
|
|
534
|
+
}
|
|
535
|
+
const trimmed = String(text ?? '').trim()
|
|
536
|
+
return trimmed === '' ? { clear: true } : { value: trimmed }
|
|
537
|
+
}
|
|
538
|
+
const plan = Object.keys(drafts)
|
|
539
|
+
.map((key) => ({ key, run: parse(key, drafts[key]) }))
|
|
540
|
+
.filter((item) => item.run !== undefined)
|
|
541
|
+
const dirty = Object.keys(drafts).length > 0
|
|
542
|
+
const invalid = plan.length !== Object.keys(drafts).length
|
|
543
|
+
const blocked = !dirty || invalid || saving || !writable
|
|
544
|
+
|
|
545
|
+
const setDraft = (key, text) => {
|
|
546
|
+
setFailed(false)
|
|
547
|
+
setDrafts((prev) => ({ ...prev, [key]: text }))
|
|
548
|
+
}
|
|
549
|
+
const clearDrafts = () => {
|
|
550
|
+
setDrafts({})
|
|
551
|
+
setFailed(false)
|
|
552
|
+
}
|
|
553
|
+
const save = async () => {
|
|
554
|
+
if (blocked) return
|
|
555
|
+
setSaving(true)
|
|
556
|
+
setFailed(false)
|
|
557
|
+
let landed = true
|
|
558
|
+
for (const item of plan) {
|
|
559
|
+
if (item.run.clear) {
|
|
560
|
+
const ok = await scope.unset(item.key).then(() => true, () => false)
|
|
561
|
+
landed = ok && landed
|
|
562
|
+
} else {
|
|
563
|
+
const ok = await scope.set(item.key, item.run.value).then(() => true, () => false)
|
|
564
|
+
landed = ok && landed
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
if (landed) setDrafts({})
|
|
568
|
+
setSaving(false)
|
|
569
|
+
setFailed(!landed)
|
|
570
|
+
}
|
|
571
|
+
const resetField = (key) => {
|
|
572
|
+
setFailed(false)
|
|
573
|
+
setDrafts((prev) => {
|
|
574
|
+
const next = { ...prev }
|
|
575
|
+
delete next[key]
|
|
576
|
+
return next
|
|
577
|
+
})
|
|
578
|
+
scope.unset(key)
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const h = React.createElement
|
|
582
|
+
const overriddenBadge = (key) =>
|
|
583
|
+
userHas(snapshot, key)
|
|
584
|
+
? h('span', { className: 'vr-badges' },
|
|
585
|
+
h('span', { className: 'vr-badge' }, t('overridden')),
|
|
586
|
+
h('button', {
|
|
587
|
+
type: 'button', className: 'vr-reset', disabled: !writable,
|
|
588
|
+
onClick: () => resetField(key),
|
|
589
|
+
}, t('reset')))
|
|
590
|
+
: null
|
|
591
|
+
const toggleField = (key) =>
|
|
592
|
+
h('div', { className: 'vr-field', key },
|
|
593
|
+
h('div', { className: 'vr-field-head' },
|
|
594
|
+
h('div', { className: 'vr-toggle' },
|
|
595
|
+
h('span', { className: 'vr-label' }, t(LABEL_KEY[key])),
|
|
596
|
+
h('input', {
|
|
597
|
+
type: 'checkbox', className: 'vr-check', checked: format(key),
|
|
598
|
+
disabled: !writable,
|
|
599
|
+
onChange: (event) => setDraft(key, event.target.checked),
|
|
600
|
+
}),
|
|
601
|
+
),
|
|
602
|
+
overriddenBadge(key),
|
|
603
|
+
),
|
|
604
|
+
HINT_KEY[key]
|
|
605
|
+
? h('p', { className: 'vr-hint' }, t(HINT_KEY[key]))
|
|
606
|
+
: null,
|
|
607
|
+
)
|
|
608
|
+
const textField = (key, label, hint, multi) => {
|
|
609
|
+
const invalidField = key in drafts && parse(key, drafts[key]) === undefined
|
|
610
|
+
return h('div', { className: 'vr-field', key },
|
|
611
|
+
h('div', { className: 'vr-field-head' },
|
|
612
|
+
h('label', { className: 'vr-label' }, label),
|
|
613
|
+
overriddenBadge(key),
|
|
614
|
+
),
|
|
615
|
+
h(multi ? 'textarea' : 'input', {
|
|
616
|
+
className: 'vr-input' + (multi ? ' vr-area' : '') + (invalidField ? ' vr-input-invalid' : ''),
|
|
617
|
+
value: format(key), disabled: !writable,
|
|
618
|
+
placeholder: '',
|
|
619
|
+
onChange: (event) => setDraft(key, event.target.value),
|
|
620
|
+
}),
|
|
621
|
+
invalidField
|
|
622
|
+
? h('p', { className: 'vr-invalid' },
|
|
623
|
+
key === 'providers'
|
|
624
|
+
? t('invalidProviders')
|
|
625
|
+
: key === 'textProvider'
|
|
626
|
+
? t('invalidTextProvider')
|
|
627
|
+
: key === 'timeoutMs'
|
|
628
|
+
? t('invalidTimeout')
|
|
629
|
+
: t('invalidGeneric'))
|
|
630
|
+
: hint
|
|
631
|
+
? h('p', { className: 'vr-hint' }, hint)
|
|
632
|
+
: null,
|
|
633
|
+
)
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
const groupOptions = catalog.groups.map((group) =>
|
|
637
|
+
h('option', { value: group.id, key: group.id },
|
|
638
|
+
(group.name && group.name !== group.id ? group.name + ' (' + group.id + ')' : group.id) +
|
|
639
|
+
(group.id === 'vision-http' ? t('builtinFreeTag') : '')),
|
|
640
|
+
)
|
|
641
|
+
const modelsOf = (providerId) => {
|
|
642
|
+
const group = catalog.groups.find((entry) => entry.id === providerId)
|
|
643
|
+
return group && Array.isArray(group.models) ? group.models : []
|
|
644
|
+
}
|
|
645
|
+
const chainEditor = () => {
|
|
646
|
+
const value = format('providers')
|
|
647
|
+
const rows = Array.isArray(value) && value.length > 0 ? value : [{ provider: '', model: '' }]
|
|
648
|
+
const updateChain = (index, next) => {
|
|
649
|
+
const list = rows.map((row) => ({ ...row }))
|
|
650
|
+
list[index] = next
|
|
651
|
+
setDraft('providers', list)
|
|
652
|
+
}
|
|
653
|
+
const removeChain = (index) => {
|
|
654
|
+
const list = rows.filter((_row, i) => i !== index)
|
|
655
|
+
setDraft('providers', list.length > 0 ? list : [{ provider: '', model: '' }])
|
|
656
|
+
}
|
|
657
|
+
return h('div', { className: 'vr-field' },
|
|
658
|
+
h('div', { className: 'vr-field-head' },
|
|
659
|
+
h('label', { className: 'vr-label' }, t('chainLabel')),
|
|
660
|
+
overriddenBadge('providers'),
|
|
661
|
+
),
|
|
662
|
+
rows.map((row, index) =>
|
|
663
|
+
h('div', { className: 'vr-chain-row', key: index },
|
|
664
|
+
h('select', {
|
|
665
|
+
className: 'vr-input vr-select', value: row.provider ?? '', disabled: !writable,
|
|
666
|
+
onChange: (event) => updateChain(index, { provider: event.target.value, model: '' }),
|
|
667
|
+
},
|
|
668
|
+
h('option', { value: '' }, t('selectProvider')),
|
|
669
|
+
groupOptions,
|
|
670
|
+
),
|
|
671
|
+
h('select', {
|
|
672
|
+
className: 'vr-input vr-select', value: row.model ?? '',
|
|
673
|
+
disabled: !writable || !row.provider,
|
|
674
|
+
onChange: (event) => updateChain(index, { provider: row.provider, model: event.target.value }),
|
|
675
|
+
},
|
|
676
|
+
h('option', { value: '' }, row.provider ? t('selectModel') : t('pickProviderFirst')),
|
|
677
|
+
modelsOf(row.provider).map((model) =>
|
|
678
|
+
h('option', { value: model.id, key: model.id },
|
|
679
|
+
(model.name && model.name !== model.id ? model.name + ' (' + model.id + ')' : model.id) +
|
|
680
|
+
(row.provider === 'vision-http' ? t('freeTag') : '')),
|
|
681
|
+
),
|
|
682
|
+
),
|
|
683
|
+
h('button', {
|
|
684
|
+
type: 'button', className: 'vr-reset', disabled: !writable, title: t('removeTitle'),
|
|
685
|
+
onClick: () => removeChain(index),
|
|
686
|
+
}, t('remove')),
|
|
687
|
+
),
|
|
688
|
+
),
|
|
689
|
+
h('button', {
|
|
690
|
+
type: 'button', className: 'vr-btn', disabled: !writable,
|
|
691
|
+
onClick: () => setDraft('providers', [...rows, { provider: '', model: '' }]),
|
|
692
|
+
}, t('addFallback')),
|
|
693
|
+
h('p', { className: 'vr-hint' }, t('chainHint')),
|
|
694
|
+
)
|
|
695
|
+
}
|
|
696
|
+
const textProviderEditor = () => {
|
|
697
|
+
const value = format('textProvider')
|
|
698
|
+
const pair = value && typeof value === 'object' ? value : { provider: '', model: '' }
|
|
699
|
+
const setPair = (next) => setDraft('textProvider', next)
|
|
700
|
+
return h('div', { className: 'vr-field' },
|
|
701
|
+
h('div', { className: 'vr-field-head' },
|
|
702
|
+
h('label', { className: 'vr-label' }, t('textModelLabel')),
|
|
703
|
+
overriddenBadge('textProvider'),
|
|
704
|
+
),
|
|
705
|
+
h('div', { className: 'vr-chain-row' },
|
|
706
|
+
h('select', {
|
|
707
|
+
className: 'vr-input vr-select', value: pair.provider ?? '', disabled: !writable,
|
|
708
|
+
onChange: (event) => setPair({ provider: event.target.value, model: '' }),
|
|
709
|
+
},
|
|
710
|
+
h('option', { value: '' }, t('selectProvider')),
|
|
711
|
+
groupOptions,
|
|
712
|
+
),
|
|
713
|
+
h('select', {
|
|
714
|
+
className: 'vr-input vr-select', value: pair.model ?? '',
|
|
715
|
+
disabled: !writable || !pair.provider,
|
|
716
|
+
onChange: (event) => setPair({ provider: pair.provider, model: event.target.value }),
|
|
717
|
+
},
|
|
718
|
+
h('option', { value: '' }, pair.provider ? t('selectModel') : t('pickProviderFirst')),
|
|
719
|
+
modelsOf(pair.provider).map((model) =>
|
|
720
|
+
h('option', { value: model.id, key: model.id },
|
|
721
|
+
(model.name && model.name !== model.id ? model.name + ' (' + model.id + ')' : model.id) +
|
|
722
|
+
(pair.provider === 'vision-http' ? t('freeTag') : '')),
|
|
723
|
+
),
|
|
724
|
+
),
|
|
725
|
+
),
|
|
726
|
+
h('p', { className: 'vr-hint' }, t('textModelHint')),
|
|
727
|
+
)
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// Render-phase kick: whenever the body is open and the catalog was never
|
|
731
|
+
// fetched (including after a remount that reset the state), start the
|
|
732
|
+
// fetch. React supports setState during render for the same component,
|
|
733
|
+
// and the status flips to 'loading' so this cannot loop.
|
|
734
|
+
if (open && catalog.status === 'idle') {
|
|
735
|
+
loadCatalog()
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
return h('li', { className: 'vr-card' + (open ? ' vr-card-open' : '') },
|
|
739
|
+
h('button', {
|
|
740
|
+
type: 'button', className: 'vr-header', 'aria-expanded': open,
|
|
741
|
+
onClick: () => {
|
|
742
|
+
if (!open) loadCatalog()
|
|
743
|
+
setOpen(!open)
|
|
744
|
+
},
|
|
745
|
+
},
|
|
746
|
+
h('span', { className: 'vr-headText' },
|
|
747
|
+
h('span', { className: 'vr-name' }, t('nav')),
|
|
748
|
+
h('span', { className: 'vr-desc' }, t('desc')),
|
|
749
|
+
),
|
|
750
|
+
dirty ? h('span', { className: 'vr-pending' }, t('pending')) : null,
|
|
751
|
+
h('span', { className: 'vr-chevron' + (open ? ' vr-chevron-open' : '') }, '▾'),
|
|
752
|
+
),
|
|
753
|
+
open
|
|
754
|
+
? h('div', { className: 'vr-body' },
|
|
755
|
+
!writable ? h('p', { className: 'vr-readOnly' }, t('readOnly')) : null,
|
|
756
|
+
TOGGLE_KEYS.map((key) => toggleField(key)),
|
|
757
|
+
h('p', { className: 'vr-hint' }, t('defaultChainNote')),
|
|
758
|
+
catalogReady
|
|
759
|
+
? chainEditor()
|
|
760
|
+
: textField('providers', t('textProviders'), t('textProvidersHint'), true),
|
|
761
|
+
catalog.status === 'loading'
|
|
762
|
+
? h('p', { className: 'vr-hint' }, t('catalogLoading'))
|
|
763
|
+
: catalog.status === 'error'
|
|
764
|
+
? h('div', { className: 'vr-catalog-error' },
|
|
765
|
+
h('p', { className: 'vr-hint' }, t('catalogError') + catalog.error + t('catalogFallback')),
|
|
766
|
+
h('button', {
|
|
767
|
+
type: 'button', className: 'vr-btn',
|
|
768
|
+
onClick: () => {
|
|
769
|
+
setCatalog({ status: 'idle', groups: [], error: undefined })
|
|
770
|
+
loadCatalog()
|
|
771
|
+
},
|
|
772
|
+
}, t('retryCatalog')),
|
|
773
|
+
)
|
|
774
|
+
: null,
|
|
775
|
+
h('button', {
|
|
776
|
+
type: 'button', className: 'vr-subheader', 'aria-expanded': showAdvanced,
|
|
777
|
+
onClick: () => setShowAdvanced(!showAdvanced),
|
|
778
|
+
},
|
|
779
|
+
h('span', { className: 'vr-label' }, t('advanced')),
|
|
780
|
+
h('span', { className: 'vr-chevron' + (showAdvanced ? ' vr-chevron-open' : '') }, '▾'),
|
|
781
|
+
),
|
|
782
|
+
showAdvanced
|
|
783
|
+
? h('div', { className: 'vr-advanced' },
|
|
784
|
+
h('div', { className: 'vr-group' },
|
|
785
|
+
h('p', { className: 'vr-group-title' }, t('groupTextModel')),
|
|
786
|
+
catalogReady
|
|
787
|
+
? textProviderEditor()
|
|
788
|
+
: textField('textProvider', t('textTextProvider'), t('textTextProviderHint'), false),
|
|
789
|
+
h('p', { className: 'vr-hint' }, t('textModelGroupHint')),
|
|
790
|
+
),
|
|
791
|
+
h('div', { className: 'vr-group' },
|
|
792
|
+
h('p', { className: 'vr-group-title' }, t('groupBehavior')),
|
|
793
|
+
ADVANCED_TOGGLE_KEYS.map((key) => toggleField(key)),
|
|
794
|
+
),
|
|
795
|
+
h('div', { className: 'vr-group' },
|
|
796
|
+
h('p', { className: 'vr-group-title' }, t('groupParams')),
|
|
797
|
+
NUMBER_KEYS.map((key) => textField(key, t(LABEL_KEY[key]), t(HINT_KEY[key]), false)),
|
|
798
|
+
),
|
|
799
|
+
h('div', { className: 'vr-group' },
|
|
800
|
+
h('p', { className: 'vr-group-title' }, t('groupRoutes')),
|
|
801
|
+
TEXT_KEYS.map((key) => textField(key, t(LABEL_KEY[key]), t(HINT_KEY[key]), false)),
|
|
802
|
+
),
|
|
803
|
+
h('div', { className: 'vr-group' },
|
|
804
|
+
h('p', { className: 'vr-group-title' }, t('groupProxy')),
|
|
805
|
+
textField('proxy', t('proxyLabel'), t('proxyHint'), false),
|
|
806
|
+
textField('proxyHosts', t('proxyHostsLabel'), t('proxyHostsHint'), true),
|
|
807
|
+
),
|
|
808
|
+
)
|
|
809
|
+
: null,
|
|
810
|
+
h('div', { className: 'vr-footer' },
|
|
811
|
+
failed ? h('p', { className: 'vr-failed' }, t('saveFailed')) : null,
|
|
812
|
+
h('button', {
|
|
813
|
+
type: 'button', className: 'vr-btn', disabled: !dirty || saving,
|
|
814
|
+
onClick: clearDrafts,
|
|
815
|
+
}, t('discard')),
|
|
816
|
+
h('button', {
|
|
817
|
+
type: 'button', className: 'vr-btn vr-btn-save', disabled: blocked,
|
|
818
|
+
onClick: save,
|
|
819
|
+
}, saving ? t('saving') : t('save')),
|
|
820
|
+
),
|
|
821
|
+
)
|
|
822
|
+
: null,
|
|
823
|
+
)
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
function apply(ctx) {
|
|
827
|
+
const scope = ctx.settingsScope.bind({ namespace: 'vision-router' })
|
|
828
|
+
// Follow the app language: register our dictionaries and re-read them
|
|
829
|
+
// whenever the user switches the locale in Settings → General.
|
|
830
|
+
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'vision-router: card locale')
|
|
831
|
+
const t = ctx.locale.bind(NS)
|
|
832
|
+
// Lazily reach the client `connection` service for the model catalog.
|
|
833
|
+
// Never a hard inject (a parked inject would keep the card from
|
|
834
|
+
// registering); absence degrades to the free-text inputs.
|
|
835
|
+
const getConnection = () => {
|
|
836
|
+
try {
|
|
837
|
+
return ctx.get('connection')
|
|
838
|
+
} catch {
|
|
839
|
+
return undefined
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
ctx.effect(installStyles, 'vision-router: card styles')
|
|
843
|
+
ctx.effect(
|
|
844
|
+
() =>
|
|
845
|
+
ctx.slots.inject('settings.plugin.item', function* () {
|
|
846
|
+
yield ctx.slots.register(
|
|
847
|
+
{
|
|
848
|
+
name: 'settings.plugin.item',
|
|
849
|
+
id: 'vision-router',
|
|
850
|
+
order: 30,
|
|
851
|
+
label: () => t('nav'),
|
|
852
|
+
inject: () => ({ scope, getConnection, t, locale: ctx.locale }),
|
|
853
|
+
},
|
|
854
|
+
VisionRouterCard,
|
|
855
|
+
)
|
|
856
|
+
}),
|
|
857
|
+
'vision-router: settings card',
|
|
858
|
+
)
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
exports.apply = apply
|
|
862
|
+
exports.inject = ['settingsScope', 'slots', 'locale']
|
|
863
|
+
exports.unwrapModelsResult = unwrapModelsResult
|
|
864
|
+
return module.exports
|
|
865
|
+
},
|
|
866
|
+
})
|