dsh-kylin-media 0.1.0 → 0.2.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/README.md CHANGED
@@ -50,13 +50,22 @@ npm publish # prepack 会自动跑 smoke
50
50
 
51
51
  | 项 | 说明 |
52
52
  |---|---|
53
- | 网关地址 | 默认 `http://127.0.0.1:8288`;局域网用 `http://<主机IP>:8288`,frp / 反代填对外地址 |
53
+ | 网关地址 | 默认 `http://127.0.0.1:8288`;局域网用 `http://<主机IP>:8288`,frp / 反代填对外地址。编辑的是「当前线路」的地址 |
54
+ | 连接线路 | 可同时保存多条地址(本机 / 局域网 / frp 公网),点圆点切换当前线路,「测试连接」与所有 `media_*` 都用它;「全部测速」并发探测每条线路并标出最快可用的那条 |
54
55
  | 访问 Key | 网关 `API_KEY_MODE=always` 时必填;保存后不再回显明文,留空表示沿用已保存的值 |
55
56
  | 单次最多等待 | 工具调用内等待任务的秒数(10-600);超时不影响网关侧任务,可继续用 `media_job` |
56
57
  | 产物目录 | 留空 = `<会话目录>/media-output/<任务 id>/`;也可填绝对路径 |
57
58
  | 自动下载 | 关闭后只返回远端 URL,用 `media_job action=download` 取回 |
59
+ | 默认模型 | 「可用能力」卡片里逐类型列出网关实时目录中的模型,点选即设为该类型默认模型并立即保存;`media_*` 未传 `model` 时用它,没选则用该类型第一个可用模型 |
58
60
 
59
- 页面上的「测试连接」会读取 `/api/health` 与 `/api/models`,显示网关版本、鉴权模式、ComfyUI 与双卡队列、各类型可用模型数。连接失败时会给出排查提示。
61
+ 页面上的「测试连接」会读取 `/api/health` 与 `/api/models`,显示网关版本、鉴权模式、ComfyUI 与双卡队列,并按能力类型列出具体模型(含可用性与权重缺失),可直接点选默认模型。连接失败时会给出排查提示。
62
+
63
+ > 常见坑:网关默认监听明文 HTTP(`API_PORT=8288`)。frp / 反代把 8288 映射出去后仍然是 HTTP,地址要写 `http://<对外地址>:<端口>`;写成 `https://` 会在 TLS 握手阶段被断开,错误信息里会提示「这个端口没有完成 TLS 握手…把地址改成 http:// 再试」。反过来,如果前面套了 nginx / Caddy 的 HTTPS,则必须写 `https://`(自签证书还需要让运行宿主信任该 CA)。
64
+
65
+ > 为什么「本机 / 局域网」地址连不上:
66
+ > 1. `127.0.0.1` / `localhost` 只有**网关和插件跑在同一台机器**时才通。网关在远端(例如 Windows 主机)而插件在 Mac 上时,本机地址必然被拒绝。
67
+ > 2. 局域网地址要求两者**在同一网段**,且网关监听 `HOST=0.0.0.0`(默认)、系统防火墙放行 8288(仓库里有 `add-firewall-rule.bat`)。可先用 `curl http://<主机IP>:8288/api/health` 验证。
68
+ > 3. 上面两条都不满足时只能用 frp / 公网地址。把三条地址都加进「连接线路」,点「全部测速」即可看出哪条通、哪条最快;失败原因直接写在行内(拒绝连接 / 不可达 / 域名解析失败 / 协议写反)。
60
69
 
61
70
  ## 工具
62
71
 
@@ -105,10 +114,12 @@ media_audio action=speech text="你好,这是本地语音合成。" format=mp3
105
114
 
106
115
  ```bash
107
116
  cd dsh-kylin-media
108
- npm test # smoke(包契约 + 客户端自注册 + locale/icon/技能) + selftest(mock ctx + mock 网关)
117
+ npm test # smoke(包契约 + 客户端自注册 + locale/icon/技能)+ selftest(mock ctx + mock 网关)+ client-test(渲染自测)
109
118
  ```
110
119
 
111
- `selftest` 会用临时 HOME 与 `127.0.0.1` 上的 mock 网关跑完整链路:设置持久化、403 / 400 / 404 / 405 围栏、模型选择、提交轮询、产物下载、鉴权头、注册与卸载。
120
+ `selftest` 会用临时 HOME 与 `127.0.0.1` 上的 mock 网关跑完整链路:设置持久化、403 / 400 / 404 / 405 围栏、默认模型选择与回退、提交轮询、产物下载、鉴权头、注册与卸载。
121
+
122
+ `client-test` 用最小 React 替身渲染设置页的「可用能力」卡片:类型卡片列出具体模型、默认模型高亮、点选后 POST `settings.update`、缺权重模型不可选,且渲染树不含明文 Key。
112
123
 
113
124
  ## 目录
114
125
 
@@ -126,7 +137,7 @@ dsh-kylin-media/
126
137
  │ └── routes.js # fenced JSON API
127
138
  ├── skills/kylin-media/ # 随包技能
128
139
  ├── locale/ # 插件管理器展示元数据
129
- └── scripts/ # smoke / selftest
140
+ └── scripts/ # smoke / selftest / client-test
130
141
  ```
131
142
 
132
143
  ## License
package/client.js CHANGED
@@ -38,6 +38,24 @@ window.__ModuleLoader__.load({
38
38
  testing: '测试中…',
39
39
  clearKey: '清除 Key',
40
40
  cleared: '已清除访问 Key',
41
+ lines: '连接线路',
42
+ linesHint: '点左侧圆点切换当前线路(测试与工具都用它);「测速」只查连通性。127.0.0.1 / localhost 只有当网关跑在同一台机器时才通,局域网地址要同网段且防火墙放行端口。',
43
+ lineAdd: '把当前地址加为线路',
44
+ lineTest: '测速',
45
+ linesUnavailable: '当前宿主版本还不支持线路管理:重启宿主(插件 0.2.0)后这里会显示可切换的线路。',
46
+ lineTestAll: '全部测速',
47
+ lineTesting: '测速中…',
48
+ lineFailed: '失败',
49
+ lineActive: '当前线路',
50
+ lineSwitch: '切换为当前线路',
51
+ lineRemove: '删除线路',
52
+ lineSwitched: '已切换到该线路',
53
+ lineAdded: '已添加线路',
54
+ lineRemoved: '已删除线路',
55
+ lineExists: '这个地址已经在列表里了',
56
+ lineKeepOne: '至少要保留一条线路',
57
+ noLineReachable: '所有线路都不通:检查网关是否运行、地址/端口是否正确、防火墙是否放行',
58
+ fastestLine: '最快线路:',
41
59
  saved: '已保存',
42
60
  failed: '操作失败:',
43
61
  loadFailed: '配置加载失败,请确认插件已激活后重试。',
@@ -60,7 +78,13 @@ window.__ModuleLoader__.load({
60
78
  noKey: '未配置访问 Key',
61
79
  keyConfigured: '访问 Key 已配置',
62
80
  capabilities: '可用能力',
63
- capabilitiesSub: '来自网关 /api/models 的实时目录。灰色表示模型文件缺失,需要服务方补齐权重。',
81
+ capabilitiesSub: '来自网关 /api/models 的实时目录。点选一个模型即设为该能力的默认模型,media_* 工具没传 model 时用它;灰色表示权重缺失。',
82
+ modelsAuto: '自动(第一个可用模型)',
83
+ modelsStale: '已选:',
84
+ modelsEmpty: '没有注册模型',
85
+ missingWeights: '缺权重',
86
+ defaultSaved: '默认模型已更新',
87
+ defaultFailed: '默认模型保存失败:',
64
88
  behavior: '任务与产物',
65
89
  behaviorSub: '这些默认值对所有 media_* 工具生效。',
66
90
  waitSeconds: '单次最多等待(秒)',
@@ -76,7 +100,7 @@ window.__ModuleLoader__.load({
76
100
  available: '可用',
77
101
  unprofiled: '未注册权重',
78
102
  noCatalog: '尚未读取到能力目录,点「测试连接」重试。',
79
- unreachableHint: '检查网关是否已启动、地址是否正确;若网关是 always 模式,还需填写正确的访问 Key。',
103
+ unreachableHint: '检查网关是否已启动、地址是否正确;若网关是 always 模式,还需填写正确的访问 Key。https:// 与 http:// 写反也会连不上(错误信息里会提示)。',
80
104
  testFailed: '连接测试没有通过。',
81
105
  keyRequired: '请先填写访问 Key(网关可能要求鉴权)。',
82
106
  type_text2image: '文生图',
@@ -109,6 +133,24 @@ window.__ModuleLoader__.load({
109
133
  testing: 'Testing…',
110
134
  clearKey: 'Clear key',
111
135
  cleared: 'Access key cleared',
136
+ lines: 'Connection lines',
137
+ linesHint: 'Click the dot to switch the active line (tests and tools use it). "Test" only checks reachability. 127.0.0.1 / localhost works only when the gateway runs on this machine; a LAN address needs the same subnet and an open firewall port.',
138
+ lineAdd: 'Add current address as a line',
139
+ lineTest: 'Test',
140
+ linesUnavailable: 'This host does not support connection lines yet: restart the host (plugin 0.2.0) to manage and switch lines here.',
141
+ lineTestAll: 'Test all',
142
+ lineTesting: 'Testing…',
143
+ lineFailed: 'failed',
144
+ lineActive: 'Active line',
145
+ lineSwitch: 'Switch to this line',
146
+ lineRemove: 'Remove line',
147
+ lineSwitched: 'Switched to this line',
148
+ lineAdded: 'Line added',
149
+ lineRemoved: 'Line removed',
150
+ lineExists: 'That address is already in the list',
151
+ lineKeepOne: 'Keep at least one line',
152
+ noLineReachable: 'No line is reachable: check the gateway process, address/port and firewall',
153
+ fastestLine: 'Fastest line: ',
112
154
  saved: 'Saved',
113
155
  failed: 'Operation failed: ',
114
156
  loadFailed: 'Failed to load settings. Make sure the plugin is active, then retry.',
@@ -131,7 +173,13 @@ window.__ModuleLoader__.load({
131
173
  noKey: 'No access key',
132
174
  keyConfigured: 'Access key configured',
133
175
  capabilities: 'Capabilities',
134
- capabilitiesSub: 'Live catalogue from the gateway /api/models. Grey means model files are missing and the operator must add the weights.',
176
+ capabilitiesSub: 'Live catalogue from the gateway /api/models. Pick a model to make it the default for that capability; media_* tools use it when model is omitted. Grey means the weights are missing.',
177
+ modelsAuto: 'Auto (first available model)',
178
+ modelsStale: 'Selected: ',
179
+ modelsEmpty: 'No registered models',
180
+ missingWeights: 'weights missing',
181
+ defaultSaved: 'Default model updated',
182
+ defaultFailed: 'Failed to save the default model: ',
135
183
  behavior: 'Jobs and artifacts',
136
184
  behaviorSub: 'These defaults apply to every media_* tool.',
137
185
  waitSeconds: 'Max wait per call (s)',
@@ -147,7 +195,7 @@ window.__ModuleLoader__.load({
147
195
  available: 'available',
148
196
  unprofiled: 'unprofiled weights',
149
197
  noCatalog: 'No catalogue yet — run "Test connection" again.',
150
- unreachableHint: 'Check that the gateway is running and the address is right; if it uses always mode, also check the access key.',
198
+ unreachableHint: 'Check that the gateway is running and the address is right; if it uses always mode, also check the access key. A mismatched https:// or http:// scheme also fails to connect (the error says which).',
151
199
  testFailed: 'Connection test did not pass.',
152
200
  keyRequired: 'Enter the access key first (the gateway may require auth).',
153
201
  type_text2image: 'Text to image',
@@ -201,6 +249,20 @@ window.__ModuleLoader__.load({
201
249
  '.km-hint{font-size:11.5px;line-height:1.55;color:var(--dsw-alias-label-tertiary,#8a919c)}',
202
250
  '.km-keyrow{display:flex;gap:8px;align-items:stretch}',
203
251
  '.km-keyrow input{flex:1;min-width:0}',
252
+ '.km-lines{display:flex;flex-direction:column;gap:6px;margin-top:8px}',
253
+ '.km-line{display:flex;align-items:center;gap:10px;border:1px solid var(--dsw-alias-border-l2,rgba(127,127,127,.22));border-radius:var(--dsw-radius-sm,8px);padding:7px 10px;background:var(--dsw-alias-bg-layer-1,transparent);min-width:0}',
254
+ '.km-line[data-active=true]{border-color:var(--dsw-alias-brand-primary,#4c7dff);background:color-mix(in srgb, var(--dsw-alias-brand-primary,#4c7dff) 9%, transparent)}',
255
+ '.km-line-radio{appearance:none;flex:none;width:14px;height:14px;padding:0;border-radius:50%;border:1px solid var(--dsw-alias-border-l3,rgba(127,127,127,.45));background:transparent;cursor:pointer;position:relative}',
256
+ '.km-line-radio[data-active=true]{border-color:var(--dsw-alias-brand-primary,#4c7dff)}',
257
+ '.km-line-radio[data-active=true]::after{content:"";position:absolute;inset:2px;border-radius:50%;background:var(--dsw-alias-brand-primary,#4c7dff)}',
258
+ '.km-line-main{flex:1;min-width:0;display:flex;flex-direction:column;gap:1px}',
259
+ '.km-line-name{font-size:12px;color:var(--dsw-alias-label-primary,#1f2329)}',
260
+ '.km-line-url{font-family:var(--dsw-font-mono,ui-monospace,SFMono-Regular,Menlo,Consolas,monospace);font-size:11px;color:var(--dsw-alias-label-tertiary,#8a919c);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
261
+ '.km-line-status{flex:none;font-size:11px;color:var(--dsw-alias-label-tertiary,#8a919c);white-space:nowrap}',
262
+ '.km-line-status[data-state=ok]{color:var(--dsw-alias-state-success-primary,#2f8f5b)}',
263
+ '.km-line-status[data-state=error]{color:var(--dsw-alias-state-error-primary,#d9534f)}',
264
+ '.km-line-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px}',
265
+ '.km-btn-mini{padding:4px 9px;font-size:11.5px;line-height:1.2}',
204
266
  '.km-actions{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:18px;padding-top:14px;border-top:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.12))}',
205
267
  '.km-btn{appearance:none;display:inline-flex;align-items:center;gap:6px;border:1px solid var(--dsw-alias-border-l2,rgba(127,127,127,.25));background:transparent;color:var(--dsw-alias-label-primary,#1f2329);border-radius:var(--dsw-radius-sm,8px);padding:7px 14px;font:inherit;font-size:13px;line-height:1.2;cursor:pointer;transition:background-color .16s ease,border-color .16s ease,color .16s ease}',
206
268
  '.km-btn:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.07));border-color:var(--dsw-alias-border-l3,rgba(127,127,127,.4))}',
@@ -216,11 +278,25 @@ window.__ModuleLoader__.load({
216
278
  '.km-state{margin-top:14px;font-size:12.5px;line-height:1.75;color:var(--dsw-alias-label-secondary,#5c6470);padding:10px 12px;border:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.12));border-left:3px solid var(--dsw-alias-border-l3,rgba(127,127,127,.3));border-radius:var(--dsw-radius-sm,8px);background:var(--dsw-alias-bg-layer-1,transparent)}',
217
279
  '.km-state[data-kind=ok]{border-left-color:var(--dsw-alias-state-success-primary,#2f8f5b)}',
218
280
  '.km-state[data-kind=error]{color:var(--dsw-alias-state-error-primary,#d9534f);border-left-color:var(--dsw-alias-state-error-primary,#d9534f)}',
219
- '.km-tiles{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:10px}',
281
+ '.km-tiles{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:10px}',
220
282
  '.km-tile{border:1px solid var(--dsw-alias-border-l2,rgba(127,127,127,.22));border-radius:var(--dsw-radius-sm,10px);padding:11px 12px;background:var(--dsw-alias-bg-layer-1,transparent);display:flex;flex-direction:column;gap:5px;min-width:0}',
221
283
  '.km-tile strong{font-size:12.5px;font-weight:600;color:var(--dsw-alias-label-primary,#1f2329)}',
222
- '.km-tile span{font-size:11.5px;color:var(--dsw-alias-label-tertiary,#8a919c)}',
284
+ '.km-tile>span,.km-tile-head span{font-size:11.5px;color:var(--dsw-alias-label-tertiary,#8a919c)}',
223
285
  '.km-tile[data-state=missing]{opacity:.55}',
286
+ '.km-tile-head{display:flex;align-items:baseline;justify-content:space-between;gap:8px}',
287
+ '.km-tile-models{gap:8px}',
288
+ '.km-models{display:flex;flex-direction:column;gap:1px}',
289
+ '.km-model{appearance:none;display:flex;align-items:center;gap:7px;width:100%;box-sizing:border-box;background:transparent;border:1px solid transparent;border-radius:var(--dsw-radius-sm,8px);padding:4px 6px;font:inherit;font-size:11.5px;line-height:1.45;color:var(--dsw-alias-label-secondary,#5c6470);text-align:left;cursor:pointer;transition:background-color .14s ease,border-color .14s ease,color .14s ease}',
290
+ '.km-model:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.07))}',
291
+ '.km-model:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#4c7dff);outline-offset:1px}',
292
+ '.km-model[data-selected=true]{border-color:var(--dsw-alias-brand-primary,#4c7dff);color:var(--dsw-alias-label-primary,#1f2329);background:color-mix(in srgb, var(--dsw-alias-brand-primary,#4c7dff) 12%, transparent)}',
293
+ '.km-model[data-disabled=true]{opacity:.5;cursor:not-allowed}',
294
+ '.km-model-radio{width:11px;height:11px;flex:none;border-radius:50%;border:1px solid var(--dsw-alias-border-l3,rgba(127,127,127,.45));position:relative}',
295
+ '.km-model[data-selected=true] .km-model-radio{border-color:var(--dsw-alias-brand-primary,#4c7dff)}',
296
+ '.km-model[data-selected=true] .km-model-radio::after{content:"";position:absolute;inset:2px;border-radius:50%;background:var(--dsw-alias-brand-primary,#4c7dff)}',
297
+ '.km-model-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:11.5px;color:inherit}',
298
+ '.km-model-badge{flex:none;font-size:10px;line-height:1;padding:2px 5px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2,rgba(127,127,127,.3));color:var(--dsw-alias-label-tertiary,#8a919c)}',
299
+ '.km-model-empty,.km-model-stale{font-size:11px;line-height:1.5;color:var(--dsw-alias-label-tertiary,#8a919c)}',
224
300
  '.km-check{display:flex;align-items:flex-start;gap:8px;font-size:12px;line-height:1.6;color:var(--dsw-alias-label-secondary,#5c6470);cursor:pointer}',
225
301
  '.km-check input{width:15px;height:15px;margin:1px 0 0;accent-color:var(--dsw-alias-brand-primary,#4c7dff);flex:none;cursor:pointer}',
226
302
  '.km-foot{margin:0;color:var(--dsw-alias-label-tertiary,#8a919c);font-size:11.5px;line-height:1.7}',
@@ -233,14 +309,24 @@ window.__ModuleLoader__.load({
233
309
  return function () { if (style.parentNode) style.parentNode.removeChild(style); };
234
310
  }
235
311
 
312
+ /* 写操作一律 POST:路由只放行 GET 的 state,其余动作 GET 会被 405 拒绝,
313
+ 所以这里绝不按「有没有 body」决定方法。 */
236
314
  function request(action, body) {
237
- var options = { headers: { accept: 'application/json' } };
238
- if (body !== undefined) {
239
- options.method = 'POST';
240
- options.headers['content-type'] = 'application/json';
241
- options.body = JSON.stringify(body);
242
- }
243
- return fetch(API + '/' + action, options).then(function (response) {
315
+ var payload = body === undefined ? {} : body;
316
+ return fetch(API + '/' + action, {
317
+ method: 'POST',
318
+ headers: { accept: 'application/json', 'content-type': 'application/json' },
319
+ body: JSON.stringify(payload)
320
+ }).then(function (response) {
321
+ return response.json().catch(function () {
322
+ return { ok: false, error: { code: 'http-' + response.status, message: 'HTTP ' + response.status } };
323
+ });
324
+ });
325
+ }
326
+
327
+ /* 只读查询(目前只有 state 允许 GET)。 */
328
+ function query(action) {
329
+ return fetch(API + '/' + action, { headers: { accept: 'application/json' } }).then(function (response) {
244
330
  return response.json().catch(function () {
245
331
  return { ok: false, error: { code: 'http-' + response.status, message: 'HTTP ' + response.status } };
246
332
  });
@@ -282,10 +368,188 @@ window.__ModuleLoader__.load({
282
368
  var busyHook = React.useState(false);
283
369
  var busy = busyHook[0];
284
370
  var setBusy = busyHook[1];
371
+ var defsHook = React.useState({});
372
+ var defaults = defsHook[0];
373
+ var setDefaults = defsHook[1];
374
+ var modelNoticeHook = React.useState(null);
375
+ var modelNotice = modelNoticeHook[0];
376
+ var setModelNotice = modelNoticeHook[1];
377
+ var linesHook = React.useState({});
378
+ var lineResults = linesHook[0];
379
+ var setLineResults = linesHook[1];
380
+ var linesBusyHook = React.useState(false);
381
+ var linesBusy = linesBusyHook[0];
382
+ var setLinesBusy = linesBusyHook[1];
383
+
384
+ /* 点选即保存:{ type: modelId },空串表示回到「自动」。 */
385
+ function selectModel(type, modelId) {
386
+ var previous = defaults;
387
+ var next = Object.assign({}, defaults);
388
+ if (modelId === '') delete next[type];
389
+ else next[type] = modelId;
390
+ setDefaults(next);
391
+ setModelNotice(null);
392
+ request('settings.update', { patch: { defaultModels: next } }).then(function (payload) {
393
+ if (!payload.ok) throw new Error(payloadError(payload));
394
+ setSnapshot(function (current) {
395
+ if (current === null || current.data === null || current.data === undefined) return current;
396
+ return Object.assign({}, current, {
397
+ data: Object.assign({}, current.data, { defaultModels: next })
398
+ });
399
+ });
400
+ setModelNotice({ kind: 'ok', text: t('defaultSaved') });
401
+ }).catch(function (error) {
402
+ setDefaults(previous);
403
+ setModelNotice({ kind: 'error', text: t('defaultFailed') + (error && error.message ? error.message : String(error)) });
404
+ });
405
+ }
406
+
407
+ function hostOf(url) {
408
+ try { return new URL(url).host || url; } catch (error) { return url; }
409
+ }
410
+
411
+ function setLineResult(id, value) {
412
+ setLineResults(function (previous) {
413
+ var next = Object.assign({}, previous);
414
+ next[id] = value;
415
+ return next;
416
+ });
417
+ }
418
+
419
+ /* 线路相关的接口都会返回最新 publicState:直接落到快照与地址输入框。 */
420
+ function applyState(state) {
421
+ setSnapshot({ status: 'ready', data: state, error: null });
422
+ setDefaults(state.defaultModels || {});
423
+ setDraft(function (previous) {
424
+ if (previous === null) {
425
+ return {
426
+ baseUrl: state.baseUrl,
427
+ waitSeconds: String(state.waitSeconds),
428
+ outputDir: state.outputDir,
429
+ autoDownload: state.autoDownload
430
+ };
431
+ }
432
+ return Object.assign({}, previous, { baseUrl: state.baseUrl });
433
+ });
434
+ }
435
+
436
+ function selectEndpoint(id) {
437
+ if (busy || linesBusy) return;
438
+ setNotice(null);
439
+ request('endpoints.select', { id: id }).then(function (payload) {
440
+ if (!payload.ok) throw new Error(payloadError(payload));
441
+ applyState(payload.value);
442
+ setNotice({ kind: 'ok', text: t('lineSwitched') });
443
+ runTest();
444
+ }).catch(function (error) {
445
+ setNotice({ kind: 'error', text: t('failed') + (error && error.message ? error.message : String(error)) });
446
+ });
447
+ }
448
+
449
+ function probeLine(id) {
450
+ setLineResult(id, { status: 'loading' });
451
+ request('endpoints.probe', { id: id }).then(function (payload) {
452
+ if (!payload.ok) throw new Error(payloadError(payload));
453
+ var value = payload.value;
454
+ setLineResult(id, value.reachable
455
+ ? { status: 'ok', latencyMs: value.latencyMs, message: '' }
456
+ : { status: 'error', latencyMs: value.latencyMs, message: value.error ? value.error.message : '' });
457
+ }).catch(function (error) {
458
+ setLineResult(id, { status: 'error', message: error && error.message ? error.message : String(error) });
459
+ });
460
+ }
461
+
462
+ function currentLines() {
463
+ return Array.isArray(data.endpoints) ? data.endpoints : [];
464
+ }
465
+
466
+ function probeAll() {
467
+ if (linesBusy) return;
468
+ var lines = currentLines();
469
+ if (lines.length === 0) return;
470
+ setLinesBusy(true);
471
+ setNotice(null);
472
+ var pending = {};
473
+ for (var index = 0; index < lines.length; index += 1) pending[lines[index].id] = { status: 'loading' };
474
+ setLineResults(pending);
475
+ request('endpoints.probeAll', {}).then(function (payload) {
476
+ if (!payload.ok) throw new Error(payloadError(payload));
477
+ var next = {};
478
+ var best = null;
479
+ for (var probe = 0; probe < payload.value.results.length; probe += 1) {
480
+ var result = payload.value.results[probe];
481
+ next[result.id] = result.reachable
482
+ ? { status: 'ok', latencyMs: result.latencyMs, message: '' }
483
+ : { status: 'error', latencyMs: result.latencyMs, message: result.error ? result.error.message : '' };
484
+ if (result.reachable && (best === null || result.latencyMs < best.latencyMs)) best = result;
485
+ }
486
+ setLineResults(next);
487
+ if (best === null) setNotice({ kind: 'error', text: t('noLineReachable') });
488
+ else setNotice({ kind: 'ok', text: t('fastestLine') + best.name + ' · ' + String(best.latencyMs) + 'ms' });
489
+ }).catch(function (error) {
490
+ setNotice({ kind: 'error', text: t('failed') + (error && error.message ? error.message : String(error)) });
491
+ }).then(function () {
492
+ setLinesBusy(false);
493
+ });
494
+ }
495
+
496
+ function addLine() {
497
+ if (linesBusy || draft === null) return;
498
+ var url = String(draft.baseUrl || '').trim().replace(/\/+$/, '');
499
+ if (url === '') return;
500
+ var lines = currentLines();
501
+ for (var index = 0; index < lines.length; index += 1) {
502
+ if (lines[index].baseUrl === url) {
503
+ setNotice({ kind: 'error', text: t('lineExists') });
504
+ return;
505
+ }
506
+ }
507
+ setLinesBusy(true);
508
+ setNotice(null);
509
+ request('settings.update', { patch: { endpoints: lines.concat([{ name: hostOf(url), baseUrl: url }]) } }).then(function (payload) {
510
+ if (!payload.ok) throw new Error(payloadError(payload));
511
+ applyState(payload.value);
512
+ setNotice({ kind: 'ok', text: t('lineAdded') });
513
+ }).catch(function (error) {
514
+ setNotice({ kind: 'error', text: t('failed') + (error && error.message ? error.message : String(error)) });
515
+ }).then(function () {
516
+ setLinesBusy(false);
517
+ });
518
+ }
519
+
520
+ function removeLine(id) {
521
+ if (linesBusy) return;
522
+ var lines = currentLines();
523
+ var kept = [];
524
+ var removedActive = false;
525
+ for (var index = 0; index < lines.length; index += 1) {
526
+ if (lines[index].id === id) {
527
+ removedActive = lines[index].id === data.activeEndpointId;
528
+ continue;
529
+ }
530
+ kept.push(lines[index]);
531
+ }
532
+ if (kept.length === 0) {
533
+ setNotice({ kind: 'error', text: t('lineKeepOne') });
534
+ return;
535
+ }
536
+ setLinesBusy(true);
537
+ setNotice(null);
538
+ request('settings.update', { patch: { endpoints: kept } }).then(function (payload) {
539
+ if (!payload.ok) throw new Error(payloadError(payload));
540
+ applyState(payload.value);
541
+ setNotice({ kind: 'ok', text: t('lineRemoved') });
542
+ if (removedActive) runTest();
543
+ }).catch(function (error) {
544
+ setNotice({ kind: 'error', text: t('failed') + (error && error.message ? error.message : String(error)) });
545
+ }).then(function () {
546
+ setLinesBusy(false);
547
+ });
548
+ }
285
549
 
286
550
  function runTest() {
287
551
  setTest({ status: 'loading', data: null, error: null });
288
- request('test').then(function (payload) {
552
+ request('test', {}).then(function (payload) {
289
553
  if (!payload.ok) throw new Error(payloadError(payload));
290
554
  setTest({ status: 'ready', data: payload.value, error: null });
291
555
  }).catch(function (error) {
@@ -295,9 +559,10 @@ window.__ModuleLoader__.load({
295
559
 
296
560
  function load() {
297
561
  setSnapshot({ status: 'loading', data: null, error: null });
298
- request('state').then(function (payload) {
562
+ query('state').then(function (payload) {
299
563
  if (!payload.ok) throw new Error(payloadError(payload));
300
564
  setSnapshot({ status: 'ready', data: payload.value, error: null });
565
+ setDefaults(payload.value.defaultModels || {});
301
566
  setDraft({
302
567
  baseUrl: payload.value.baseUrl,
303
568
  waitSeconds: String(payload.value.waitSeconds),
@@ -342,10 +607,11 @@ window.__ModuleLoader__.load({
342
607
  }).then(function (payload) {
343
608
  if (payload !== null && !payload.ok) throw new Error(payloadError(payload));
344
609
  setKeyInput('');
345
- return request('state');
610
+ return query('state');
346
611
  }).then(function (payload) {
347
612
  if (!payload.ok) throw new Error(payloadError(payload));
348
613
  setSnapshot({ status: 'ready', data: payload.value, error: null });
614
+ setDefaults(payload.value.defaultModels || {});
349
615
  setNotice({ kind: 'ok', text: t('saved') });
350
616
  runTest();
351
617
  }).catch(function (error) {
@@ -359,7 +625,7 @@ window.__ModuleLoader__.load({
359
625
  if (busy) return;
360
626
  setBusy(true);
361
627
  setNotice(null);
362
- request('key.clear').then(function (payload) {
628
+ request('key.clear', {}).then(function (payload) {
363
629
  if (!payload.ok) throw new Error(payloadError(payload));
364
630
  setKeyInput('');
365
631
  setSnapshot({ status: 'ready', data: payload.value, error: null });
@@ -394,6 +660,7 @@ window.__ModuleLoader__.load({
394
660
  }
395
661
  }
396
662
 
663
+ var endpoints = Array.isArray(data.endpoints) ? data.endpoints : null;
397
664
  var connectionCard = React.createElement('section', { className: 'km-card' },
398
665
  React.createElement('div', { className: 'km-card-head' },
399
666
  React.createElement('h3', null, t('connection')),
@@ -429,7 +696,61 @@ window.__ModuleLoader__.load({
429
696
  onClick: clearKey,
430
697
  disabled: busy
431
698
  }, t('clearKey')) : null),
432
- React.createElement('span', { className: 'km-hint' }, t('keyHint')))),
699
+ React.createElement('span', { className: 'km-hint' }, t('keyHint'))),
700
+ React.createElement('div', { className: 'km-field km-span2' },
701
+ React.createElement('label', null, t('lines')),
702
+ React.createElement('span', { className: 'km-hint' }, t('linesHint')),
703
+ endpoints === null ? React.createElement('span', { className: 'km-hint' }, t('linesUnavailable')) : null,
704
+ React.createElement('div', { className: 'km-lines' },
705
+ (endpoints || []).map(function (endpoint) {
706
+ var active = endpoint.id === data.activeEndpointId;
707
+ var result = lineResults[endpoint.id] || { status: 'idle' };
708
+ var statusText = result.status === 'loading' ? t('lineTesting')
709
+ : result.status === 'ok' ? String(result.latencyMs) + 'ms'
710
+ : result.status === 'error' ? t('lineFailed') : t('idle');
711
+ return React.createElement('div', {
712
+ key: endpoint.id,
713
+ className: 'km-line',
714
+ 'data-active': active ? 'true' : 'false'
715
+ },
716
+ React.createElement('button', {
717
+ type: 'button',
718
+ className: 'km-line-radio',
719
+ 'data-active': active ? 'true' : 'false',
720
+ title: active ? t('lineActive') : t('lineSwitch'),
721
+ onClick: function () { selectEndpoint(endpoint.id); }
722
+ }),
723
+ React.createElement('div', { className: 'km-line-main' },
724
+ React.createElement('div', { className: 'km-line-name' }, endpoint.name),
725
+ React.createElement('div', { className: 'km-line-url' }, endpoint.baseUrl)),
726
+ React.createElement('span', { className: 'km-line-status', 'data-state': result.status, title: result.message || '' }, statusText),
727
+ React.createElement('button', {
728
+ type: 'button',
729
+ className: 'km-btn km-btn-mini',
730
+ onClick: function () { probeLine(endpoint.id); },
731
+ disabled: result.status === 'loading'
732
+ }, t('lineTest')),
733
+ React.createElement('button', {
734
+ type: 'button',
735
+ className: 'km-btn km-btn-mini km-btn-danger',
736
+ onClick: function () { removeLine(endpoint.id); },
737
+ disabled: linesBusy || endpoints === null || endpoints.length <= 1,
738
+ title: t('lineRemove')
739
+ }, '×'));
740
+ })),
741
+ React.createElement('div', { className: 'km-line-actions' },
742
+ React.createElement('button', {
743
+ type: 'button',
744
+ className: 'km-btn km-btn-mini',
745
+ onClick: addLine,
746
+ disabled: linesBusy
747
+ }, t('lineAdd')),
748
+ React.createElement('button', {
749
+ type: 'button',
750
+ className: 'km-btn km-btn-mini',
751
+ onClick: probeAll,
752
+ disabled: linesBusy
753
+ }, linesBusy ? t('testing') : t('lineTestAll'))))),
433
754
 
434
755
  React.createElement('div', { className: 'km-actions' },
435
756
  React.createElement('button', {
@@ -471,17 +792,54 @@ window.__ModuleLoader__.load({
471
792
  var capabilitiesCard = React.createElement('section', { className: 'km-card' },
472
793
  React.createElement('h3', null, t('capabilities')),
473
794
  React.createElement('p', { className: 'km-card-sub' }, t('capabilitiesSub')),
795
+ modelNotice !== null ? React.createElement('p', { className: 'km-notice', 'data-kind': modelNotice.kind, style: { margin: '0 0 10px' } }, modelNotice.text) : null,
474
796
  React.createElement('div', { className: 'km-tiles' },
475
797
  TYPE_ORDER.map(function (type) {
476
798
  var entry = catalogByType[type];
799
+ var hasModelList = entry !== undefined && entry !== null && Array.isArray(entry.models);
800
+ var models = hasModelList ? entry.models : [];
801
+ var chosen = defaults[type] === undefined ? '' : defaults[type];
477
802
  var count = entry ? String(entry.available) + '/' + String(entry.count) + ' ' + t('available') : (catalog ? '—' : t('idle'));
803
+ var chosenKnown = false;
804
+ for (var probe = 0; probe < models.length; probe += 1) if (models[probe].id === chosen) chosenKnown = true;
478
805
  return React.createElement('div', {
479
806
  key: type,
480
- className: 'km-tile',
807
+ className: 'km-tile' + (models.length > 0 ? ' km-tile-models' : ''),
481
808
  'data-state': entry && entry.available === 0 ? 'missing' : 'ready'
482
809
  },
483
- React.createElement('strong', null, t('type_' + type)),
484
- React.createElement('span', null, count));
810
+ React.createElement('div', { className: 'km-tile-head' },
811
+ React.createElement('strong', null, t('type_' + type)),
812
+ React.createElement('span', null, count)),
813
+ models.length === 0
814
+ ? (hasModelList ? React.createElement('span', { className: 'km-model-empty' }, t('modelsEmpty')) : null)
815
+ : React.createElement('div', { className: 'km-models' },
816
+ React.createElement('button', {
817
+ type: 'button',
818
+ className: 'km-model',
819
+ 'data-selected': chosen === '' ? 'true' : 'false',
820
+ onClick: function () { selectModel(type, ''); }
821
+ },
822
+ React.createElement('span', { className: 'km-model-radio' }),
823
+ React.createElement('span', { className: 'km-model-name' }, t('modelsAuto'))),
824
+ chosen !== '' && !chosenKnown ? React.createElement('span', { className: 'km-model-stale' }, t('modelsStale') + chosen) : null,
825
+ models.map(function (model) {
826
+ var unavailable = model.available !== true;
827
+ return React.createElement('button', {
828
+ key: model.id,
829
+ type: 'button',
830
+ className: 'km-model',
831
+ 'data-selected': chosen === model.id ? 'true' : 'false',
832
+ 'data-disabled': unavailable ? 'true' : 'false',
833
+ disabled: unavailable,
834
+ title: model.id,
835
+ onClick: function () { selectModel(type, model.id); }
836
+ },
837
+ React.createElement('span', { className: 'km-model-radio' }),
838
+ React.createElement('span', { className: 'km-model-name' }, model.name),
839
+ unavailable ? React.createElement('span', { className: 'km-model-badge' }, t('missingWeights')) : null);
840
+ })
841
+ )
842
+ );
485
843
  })),
486
844
  catalog === null ? React.createElement('p', { className: 'km-hint', style: { margin: '12px 0 0' } }, t('noCatalog')) : null,
487
845
  catalog && catalog.unprofiled > 0 ? React.createElement('p', { className: 'km-hint', style: { margin: '8px 0 0' } }, String(catalog.unprofiled) + ' ' + t('unprofiled')) : null
package/entry.js CHANGED
@@ -17,11 +17,11 @@ import { createTools } from './lib/tools.js'
17
17
 
18
18
  export const name = 'dsh-kylin-media'
19
19
  export const inject = ['tools', 'webServer']
20
- export const VERSION = '0.1.0'
20
+ export const VERSION = '0.2.0'
21
21
 
22
22
  const ROOT = dirname(fileURLToPath(import.meta.url))
23
23
  const SKILLS_DIR = join(ROOT, 'skills')
24
- const CONFIG_FIELDS = ['baseUrl', 'waitSeconds', 'pollIntervalMs', 'requestTimeoutMs', 'autoDownload', 'outputDir']
24
+ const CONFIG_FIELDS = ['baseUrl', 'waitSeconds', 'pollIntervalMs', 'requestTimeoutMs', 'autoDownload', 'outputDir', 'defaultModels']
25
25
 
26
26
  function pickConfig(raw) {
27
27
  if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return {}