dsh-kylin-media 0.1.1 → 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 +7 -1
- package/client.js +255 -1
- package/entry.js +1 -1
- package/lib/gateway.js +23 -2
- package/lib/routes.js +88 -1
- package/lib/store.js +132 -5
- package/package.json +1 -1
- package/scripts/client-test.mjs +123 -3
- package/scripts/selftest.mjs +42 -0
- package/scripts/smoke.mjs +2 -0
- package/skills/kylin-media/SKILL.md +2 -1
package/README.md
CHANGED
|
@@ -50,7 +50,8 @@ 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>/`;也可填绝对路径 |
|
|
@@ -61,6 +62,11 @@ npm publish # prepack 会自动跑 smoke
|
|
|
61
62
|
|
|
62
63
|
> 常见坑:网关默认监听明文 HTTP(`API_PORT=8288`)。frp / 反代把 8288 映射出去后仍然是 HTTP,地址要写 `http://<对外地址>:<端口>`;写成 `https://` 会在 TLS 握手阶段被断开,错误信息里会提示「这个端口没有完成 TLS 握手…把地址改成 http:// 再试」。反过来,如果前面套了 nginx / Caddy 的 HTTPS,则必须写 `https://`(自签证书还需要让运行宿主信任该 CA)。
|
|
63
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 / 公网地址。把三条地址都加进「连接线路」,点「全部测速」即可看出哪条通、哪条最快;失败原因直接写在行内(拒绝连接 / 不可达 / 域名解析失败 / 协议写反)。
|
|
69
|
+
|
|
64
70
|
## 工具
|
|
65
71
|
|
|
66
72
|
| 工具 | 作用 |
|
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: '配置加载失败,请确认插件已激活后重试。',
|
|
@@ -115,6 +133,24 @@ window.__ModuleLoader__.load({
|
|
|
115
133
|
testing: 'Testing…',
|
|
116
134
|
clearKey: 'Clear key',
|
|
117
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: ',
|
|
118
154
|
saved: 'Saved',
|
|
119
155
|
failed: 'Operation failed: ',
|
|
120
156
|
loadFailed: 'Failed to load settings. Make sure the plugin is active, then retry.',
|
|
@@ -213,6 +249,20 @@ window.__ModuleLoader__.load({
|
|
|
213
249
|
'.km-hint{font-size:11.5px;line-height:1.55;color:var(--dsw-alias-label-tertiary,#8a919c)}',
|
|
214
250
|
'.km-keyrow{display:flex;gap:8px;align-items:stretch}',
|
|
215
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}',
|
|
216
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))}',
|
|
217
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}',
|
|
218
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))}',
|
|
@@ -324,6 +374,12 @@ window.__ModuleLoader__.load({
|
|
|
324
374
|
var modelNoticeHook = React.useState(null);
|
|
325
375
|
var modelNotice = modelNoticeHook[0];
|
|
326
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];
|
|
327
383
|
|
|
328
384
|
/* 点选即保存:{ type: modelId },空串表示回到「自动」。 */
|
|
329
385
|
function selectModel(type, modelId) {
|
|
@@ -348,6 +404,149 @@ window.__ModuleLoader__.load({
|
|
|
348
404
|
});
|
|
349
405
|
}
|
|
350
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
|
+
}
|
|
549
|
+
|
|
351
550
|
function runTest() {
|
|
352
551
|
setTest({ status: 'loading', data: null, error: null });
|
|
353
552
|
request('test', {}).then(function (payload) {
|
|
@@ -461,6 +660,7 @@ window.__ModuleLoader__.load({
|
|
|
461
660
|
}
|
|
462
661
|
}
|
|
463
662
|
|
|
663
|
+
var endpoints = Array.isArray(data.endpoints) ? data.endpoints : null;
|
|
464
664
|
var connectionCard = React.createElement('section', { className: 'km-card' },
|
|
465
665
|
React.createElement('div', { className: 'km-card-head' },
|
|
466
666
|
React.createElement('h3', null, t('connection')),
|
|
@@ -496,7 +696,61 @@ window.__ModuleLoader__.load({
|
|
|
496
696
|
onClick: clearKey,
|
|
497
697
|
disabled: busy
|
|
498
698
|
}, t('clearKey')) : null),
|
|
499
|
-
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'))))),
|
|
500
754
|
|
|
501
755
|
React.createElement('div', { className: 'km-actions' },
|
|
502
756
|
React.createElement('button', {
|
package/entry.js
CHANGED
|
@@ -17,7 +17,7 @@ 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.
|
|
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')
|
package/lib/gateway.js
CHANGED
|
@@ -95,6 +95,27 @@ function looksLikeSchemeMismatch(detail) {
|
|
|
95
95
|
return /EPROTO|ERR_SSL|wrong version number|unexpected eof|SSL routines|ssl3_|packet length too long|record layer|secure TLS connection|TLS connection/i.test(detail)
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
function isLoopbackBase(base) {
|
|
99
|
+
try {
|
|
100
|
+
const host = new URL(base).hostname.toLowerCase().replace(/^\[|\]$/g, '')
|
|
101
|
+
return host === '127.0.0.1' || host === 'localhost' || host === '::1'
|
|
102
|
+
} catch {
|
|
103
|
+
return false
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** 连不上时按原因给动作:协议写反 / 本机没服务 / 不可达 / 解析失败。 */
|
|
108
|
+
function reachabilityHint(base, detail) {
|
|
109
|
+
if (looksLikeSchemeMismatch(detail)) return schemeHint(base, detail)
|
|
110
|
+
if (/ECONNREFUSED/i.test(detail)) {
|
|
111
|
+
if (isLoopbackBase(base)) return '。本机这个端口没有服务在监听:网关跑在另一台机器上时,请填那台机器的局域网地址或 frp 公网地址'
|
|
112
|
+
return '。目标拒绝连接:确认网关进程在运行、端口正确,并已放行防火墙'
|
|
113
|
+
}
|
|
114
|
+
if (/ENOTFOUND|EAI_AGAIN|getaddrinfo/i.test(detail)) return '。域名解析失败:检查地址拼写或 DNS'
|
|
115
|
+
if (/EHOSTUNREACH|ENETUNREACH|ETIMEDOUT|timed out|timeout/i.test(detail)) return '。地址不可达:确认与网关在同一网络,Windows 防火墙已放行该端口'
|
|
116
|
+
return ''
|
|
117
|
+
}
|
|
118
|
+
|
|
98
119
|
/** 把 https/http 写反是最常见的「连不上」:TLS 握手失败时直接给出改法。 */
|
|
99
120
|
function schemeHint(base, detail) {
|
|
100
121
|
if (!looksLikeSchemeMismatch(detail)) return ''
|
|
@@ -150,10 +171,10 @@ export function createGateway(settings) {
|
|
|
150
171
|
if (error instanceof GatewayError) throw error
|
|
151
172
|
if (opts.signal !== undefined && opts.signal !== null && opts.signal.aborted) throw error
|
|
152
173
|
if (combined.timedOut()) {
|
|
153
|
-
throw new GatewayError('timeout', '请求网关超时(' + String(timeoutMs) + 'ms):' + base, 0, null)
|
|
174
|
+
throw new GatewayError('timeout', '请求网关超时(' + String(timeoutMs) + 'ms):' + base + reachabilityHint(base, 'ETIMEDOUT'), 0, null)
|
|
154
175
|
}
|
|
155
176
|
const detail = describeCause(error)
|
|
156
|
-
throw new GatewayError('gateway-unreachable', '无法连接网关 ' + base + ':' + detail +
|
|
177
|
+
throw new GatewayError('gateway-unreachable', '无法连接网关 ' + base + ':' + detail + reachabilityHint(base, detail), 0, null)
|
|
157
178
|
} finally {
|
|
158
179
|
combined.close()
|
|
159
180
|
}
|
package/lib/routes.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { createGateway } from './gateway.js'
|
|
8
8
|
import { summarizeModels } from './catalog.js'
|
|
9
|
+
import { hostLabel, isHttpBaseUrl } from './store.js'
|
|
9
10
|
|
|
10
11
|
export const PLUGIN_NAME = 'dsh-kylin-media'
|
|
11
12
|
export const API_PREFIX = '/dsh-kylin-media/api'
|
|
@@ -99,6 +100,63 @@ async function testConnection(store, signal) {
|
|
|
99
100
|
}
|
|
100
101
|
}
|
|
101
102
|
|
|
103
|
+
/** 只探连通性(health,8 秒超时),用于线路列表的逐条测速:快,不拉能力目录。 */
|
|
104
|
+
async function probeEndpoint(store, baseUrl, signal) {
|
|
105
|
+
const settings = store.settings()
|
|
106
|
+
const gateway = createGateway({ ...settings, baseUrl })
|
|
107
|
+
const started = Date.now()
|
|
108
|
+
try {
|
|
109
|
+
const health = await gateway.health()
|
|
110
|
+
const comfy = health !== null && typeof health === 'object' && health['comfy'] !== null && typeof health['comfy'] === 'object' ? health['comfy'] : null
|
|
111
|
+
const auth = health !== null && typeof health === 'object' && health['auth'] !== null && typeof health['auth'] === 'object' ? health['auth'] : null
|
|
112
|
+
return {
|
|
113
|
+
reachable: true,
|
|
114
|
+
latencyMs: Date.now() - started,
|
|
115
|
+
gateway: {
|
|
116
|
+
version: health !== null && typeof health === 'object' ? health['version'] ?? null : null,
|
|
117
|
+
authMode: auth === null ? null : auth['mode'] ?? null
|
|
118
|
+
},
|
|
119
|
+
comfy: {
|
|
120
|
+
reachable: comfy !== null && comfy['reachable'] === true,
|
|
121
|
+
upstreams: comfy !== null && Array.isArray(comfy['upstreams']) ? comfy['upstreams'].length : 0
|
|
122
|
+
},
|
|
123
|
+
error: null
|
|
124
|
+
}
|
|
125
|
+
} catch (error) {
|
|
126
|
+
return {
|
|
127
|
+
reachable: false,
|
|
128
|
+
latencyMs: Date.now() - started,
|
|
129
|
+
gateway: null,
|
|
130
|
+
comfy: null,
|
|
131
|
+
error: {
|
|
132
|
+
code: error !== null && typeof error === 'object' && typeof error.code === 'string' ? error.code : 'gateway-unreachable',
|
|
133
|
+
message: errorText(error)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function findEndpoint(state, id) {
|
|
140
|
+
return state.endpoints.find(function (endpoint) { return endpoint.id === id })
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** 解析探测目标:显式 baseUrl > 指定 id > 当前线路。 */
|
|
144
|
+
function resolveProbeTarget(store, payload) {
|
|
145
|
+
const state = store.publicState()
|
|
146
|
+
const rawUrl = typeof payload['baseUrl'] === 'string' ? payload['baseUrl'].trim().replace(/\/+$/, '') : ''
|
|
147
|
+
const id = typeof payload['id'] === 'string' ? payload['id'].trim() : ''
|
|
148
|
+
if (rawUrl !== '') {
|
|
149
|
+
if (!isHttpBaseUrl(rawUrl)) return { error: 'invalid-base-url' }
|
|
150
|
+
return { target: { id: id === '' ? 'probe' : id, name: hostLabel(rawUrl), baseUrl: rawUrl } }
|
|
151
|
+
}
|
|
152
|
+
if (id !== '') {
|
|
153
|
+
const endpoint = findEndpoint(state, id)
|
|
154
|
+
return endpoint === undefined ? { error: 'unknown-endpoint' } : { target: endpoint }
|
|
155
|
+
}
|
|
156
|
+
const active = findEndpoint(state, state.activeEndpointId)
|
|
157
|
+
return active === undefined ? { error: 'unknown-endpoint' } : { target: active }
|
|
158
|
+
}
|
|
159
|
+
|
|
102
160
|
/** 纯函数路由:不碰 req/res,便于单测直接调用。 */
|
|
103
161
|
export async function handleRequest(store, request, body) {
|
|
104
162
|
if (!isLoopbackHost(request.headers)) {
|
|
@@ -113,7 +171,7 @@ export async function handleRequest(store, request, body) {
|
|
|
113
171
|
if (!pathname.startsWith(API_PREFIX)) return undefined
|
|
114
172
|
const action = pathname.slice(API_PREFIX.length).replace(/^\/+/, '')
|
|
115
173
|
if (method === 'GET' && action === 'state') {
|
|
116
|
-
return { status: 200, payload: { ok: true, value: { ...store.publicState(), name: PLUGIN_NAME, version: '0.
|
|
174
|
+
return { status: 200, payload: { ok: true, value: { ...store.publicState(), name: PLUGIN_NAME, version: '0.2.0' } } }
|
|
117
175
|
}
|
|
118
176
|
if (method !== 'POST') {
|
|
119
177
|
return { status: 405, payload: errorPayload('method-not-allowed', '写操作必须使用 POST') }
|
|
@@ -136,6 +194,35 @@ export async function handleRequest(store, request, body) {
|
|
|
136
194
|
if (action === 'test') {
|
|
137
195
|
return { status: 200, payload: { ok: true, value: await testConnection(store, request.signal) } }
|
|
138
196
|
}
|
|
197
|
+
if (action === 'endpoints.select') {
|
|
198
|
+
const id = typeof payload['id'] === 'string' ? payload['id'].trim() : ''
|
|
199
|
+
const state = store.publicState()
|
|
200
|
+
if (id === '' || findEndpoint(state, id) === undefined) {
|
|
201
|
+
return { status: 404, payload: errorPayload('unknown-endpoint', '没有这条连接线路:' + id) }
|
|
202
|
+
}
|
|
203
|
+
const result = store.update({ activeEndpointId: id })
|
|
204
|
+
if (!result.ok) return { status: 400, payload: errorPayload('invalid-settings', result.errors.join('; ')) }
|
|
205
|
+
return { status: 200, payload: { ok: true, value: result.state } }
|
|
206
|
+
}
|
|
207
|
+
if (action === 'endpoints.probe') {
|
|
208
|
+
const resolved = resolveProbeTarget(store, payload)
|
|
209
|
+
if (resolved.error !== undefined) {
|
|
210
|
+
return resolved.error === 'unknown-endpoint'
|
|
211
|
+
? { status: 404, payload: errorPayload('unknown-endpoint', '没有这条连接线路') }
|
|
212
|
+
: { status: 400, payload: errorPayload('invalid-base-url', '探测地址必须是 http(s) 地址') }
|
|
213
|
+
}
|
|
214
|
+
const target = resolved.target
|
|
215
|
+
const probe = await probeEndpoint(store, target.baseUrl, request.signal)
|
|
216
|
+
return { status: 200, payload: { ok: true, value: { id: target.id, name: target.name, baseUrl: target.baseUrl, ...probe } } }
|
|
217
|
+
}
|
|
218
|
+
if (action === 'endpoints.probeAll') {
|
|
219
|
+
const state = store.publicState()
|
|
220
|
+
const results = await Promise.all(state.endpoints.map(async function (endpoint) {
|
|
221
|
+
const probe = await probeEndpoint(store, endpoint.baseUrl, request.signal)
|
|
222
|
+
return { id: endpoint.id, name: endpoint.name, baseUrl: endpoint.baseUrl, ...probe }
|
|
223
|
+
}))
|
|
224
|
+
return { status: 200, payload: { ok: true, value: { results } } }
|
|
225
|
+
}
|
|
139
226
|
return { status: 404, payload: errorPayload('not-found', '未知操作 ' + action) }
|
|
140
227
|
}
|
|
141
228
|
|
package/lib/store.js
CHANGED
|
@@ -22,9 +22,15 @@ export const DEFAULT_SETTINGS = {
|
|
|
22
22
|
requestTimeoutMs: 30000,
|
|
23
23
|
autoDownload: true,
|
|
24
24
|
outputDir: '',
|
|
25
|
-
defaultModels: {}
|
|
25
|
+
defaultModels: {},
|
|
26
|
+
endpoints: [],
|
|
27
|
+
activeEndpointId: ''
|
|
26
28
|
}
|
|
27
29
|
|
|
30
|
+
export const MAX_ENDPOINTS = 10
|
|
31
|
+
/** 线路 id:小写字母/数字/短横线,最长 32,便于在 URL、日志里稳定引用。 */
|
|
32
|
+
const ENDPOINT_ID = /^[a-z0-9][a-z0-9-]{0,31}$/
|
|
33
|
+
|
|
28
34
|
/** 能力类型键:与网关能力目录的 type 同形,避免把任意字段写进设置。 */
|
|
29
35
|
const TYPE_KEY = /^[a-z][a-z0-9_]{0,31}$/
|
|
30
36
|
const MAX_MODEL_ID_LENGTH = 200
|
|
@@ -81,6 +87,47 @@ export function normalizeDefaultModels(input) {
|
|
|
81
87
|
return map
|
|
82
88
|
}
|
|
83
89
|
|
|
90
|
+
/** 从地址取一个人类可读的线路名(host:port)。 */
|
|
91
|
+
export function hostLabel(url) {
|
|
92
|
+
try {
|
|
93
|
+
return new URL(url).host || String(url)
|
|
94
|
+
} catch {
|
|
95
|
+
return String(url)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** 只保留合法、去重后的线路;一条都没有时用 fallbackUrl 建一条。 */
|
|
100
|
+
function normalizeEndpoints(raw, fallbackUrl) {
|
|
101
|
+
const list = Array.isArray(raw) ? raw : []
|
|
102
|
+
const endpoints = []
|
|
103
|
+
const ids = new Set()
|
|
104
|
+
const urls = new Set()
|
|
105
|
+
for (const item of list) {
|
|
106
|
+
if (endpoints.length >= MAX_ENDPOINTS) break
|
|
107
|
+
if (!isRecord(item)) continue
|
|
108
|
+
const value = typeof item['baseUrl'] === 'string' ? item['baseUrl'].trim().replace(/\/+$/, '') : ''
|
|
109
|
+
if (!isHttpBaseUrl(value) || urls.has(value)) continue
|
|
110
|
+
let id = typeof item['id'] === 'string' ? item['id'].trim() : ''
|
|
111
|
+
if (!ENDPOINT_ID.test(id) || ids.has(id)) id = ''
|
|
112
|
+
if (id === '') {
|
|
113
|
+
let index = endpoints.length + 1
|
|
114
|
+
id = 'ep' + String(index)
|
|
115
|
+
while (ids.has(id)) {
|
|
116
|
+
index += 1
|
|
117
|
+
id = 'ep' + String(index)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const name = readString(item['name'], '', 40)
|
|
121
|
+
endpoints.push({ id, name: name === '' ? hostLabel(value) : name, baseUrl: value })
|
|
122
|
+
ids.add(id)
|
|
123
|
+
urls.add(value)
|
|
124
|
+
}
|
|
125
|
+
if (endpoints.length === 0) {
|
|
126
|
+
endpoints.push({ id: 'default', name: hostLabel(fallbackUrl), baseUrl: fallbackUrl })
|
|
127
|
+
}
|
|
128
|
+
return endpoints
|
|
129
|
+
}
|
|
130
|
+
|
|
84
131
|
function clampInt(value, min, max, fallback) {
|
|
85
132
|
const parsed = typeof value === 'number' ? value : Number(value)
|
|
86
133
|
if (!Number.isFinite(parsed)) return fallback
|
|
@@ -95,8 +142,15 @@ export function normalizeSettings(input) {
|
|
|
95
142
|
const key = typeof raw['apiKey'] === 'string'
|
|
96
143
|
? raw['apiKey'].replace(/[\r\n]/g, '').trim().slice(0, MAX_SECRET_LENGTH)
|
|
97
144
|
: ''
|
|
145
|
+
const endpoints = normalizeEndpoints(raw['endpoints'], baseUrl)
|
|
146
|
+
let activeEndpointId = typeof raw['activeEndpointId'] === 'string' ? raw['activeEndpointId'].trim() : ''
|
|
147
|
+
if (!endpoints.some(function (endpoint) { return endpoint.id === activeEndpointId })) activeEndpointId = endpoints[0].id
|
|
148
|
+
const activeEndpoint = endpoints.find(function (endpoint) { return endpoint.id === activeEndpointId })
|
|
98
149
|
return {
|
|
99
|
-
baseUrl
|
|
150
|
+
// baseUrl 始终等于当前线路:工具、路由、客户端只认这一个地址。
|
|
151
|
+
baseUrl: activeEndpoint === undefined ? baseUrl : activeEndpoint.baseUrl,
|
|
152
|
+
endpoints,
|
|
153
|
+
activeEndpointId,
|
|
100
154
|
apiKey: key,
|
|
101
155
|
waitSeconds: clampInt(raw['waitSeconds'], 10, 600, DEFAULT_SETTINGS.waitSeconds),
|
|
102
156
|
pollIntervalMs: clampInt(raw['pollIntervalMs'], 500, 10000, DEFAULT_SETTINGS.pollIntervalMs),
|
|
@@ -167,6 +221,65 @@ export function validatePatch(input) {
|
|
|
167
221
|
}
|
|
168
222
|
}
|
|
169
223
|
}
|
|
224
|
+
if (input['endpoints'] !== undefined) {
|
|
225
|
+
if (!Array.isArray(input['endpoints'])) {
|
|
226
|
+
errors.push('endpoints 必须是数组')
|
|
227
|
+
} else if (input['endpoints'].length === 0) {
|
|
228
|
+
errors.push('至少保留一条连接线路')
|
|
229
|
+
} else if (input['endpoints'].length > MAX_ENDPOINTS) {
|
|
230
|
+
errors.push('最多 ' + String(MAX_ENDPOINTS) + ' 条连接线路')
|
|
231
|
+
} else {
|
|
232
|
+
const list = []
|
|
233
|
+
const ids = new Set()
|
|
234
|
+
const urls = new Set()
|
|
235
|
+
for (const item of input['endpoints']) {
|
|
236
|
+
if (!isRecord(item)) {
|
|
237
|
+
errors.push('连接线路必须是对象')
|
|
238
|
+
continue
|
|
239
|
+
}
|
|
240
|
+
const url = typeof item['baseUrl'] === 'string' ? item['baseUrl'].trim().replace(/\/+$/, '') : ''
|
|
241
|
+
if (!isHttpBaseUrl(url)) {
|
|
242
|
+
errors.push('连接线路地址必须是 http(s):' + String(item['baseUrl']))
|
|
243
|
+
continue
|
|
244
|
+
}
|
|
245
|
+
if (urls.has(url)) {
|
|
246
|
+
errors.push('连接线路重复:' + url)
|
|
247
|
+
continue
|
|
248
|
+
}
|
|
249
|
+
let id = typeof item['id'] === 'string' ? item['id'].trim() : ''
|
|
250
|
+
if (id !== '' && !ENDPOINT_ID.test(id)) {
|
|
251
|
+
errors.push('连接线路 id 非法:' + id)
|
|
252
|
+
continue
|
|
253
|
+
}
|
|
254
|
+
if (id !== '' && ids.has(id)) {
|
|
255
|
+
errors.push('连接线路 id 重复:' + id)
|
|
256
|
+
continue
|
|
257
|
+
}
|
|
258
|
+
if (id === '') {
|
|
259
|
+
let index = list.length + 1
|
|
260
|
+
id = 'ep' + String(index)
|
|
261
|
+
while (ids.has(id)) {
|
|
262
|
+
index += 1
|
|
263
|
+
id = 'ep' + String(index)
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
const name = typeof item['name'] === 'string' ? item['name'].trim().slice(0, 40) : ''
|
|
267
|
+
list.push({ id, name: name === '' ? hostLabel(url) : name, baseUrl: url })
|
|
268
|
+
ids.add(id)
|
|
269
|
+
urls.add(url)
|
|
270
|
+
}
|
|
271
|
+
if (list.length > 0) patch.endpoints = list
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (input['activeEndpointId'] !== undefined) {
|
|
275
|
+
if (typeof input['activeEndpointId'] !== 'string') {
|
|
276
|
+
errors.push('activeEndpointId 必须是字符串')
|
|
277
|
+
} else {
|
|
278
|
+
const active = input['activeEndpointId'].trim()
|
|
279
|
+
if (active.length > 32) errors.push('activeEndpointId 过长')
|
|
280
|
+
else patch.activeEndpointId = active
|
|
281
|
+
}
|
|
282
|
+
}
|
|
170
283
|
if (input['outputDir'] !== undefined) {
|
|
171
284
|
if (typeof input['outputDir'] !== 'string') {
|
|
172
285
|
errors.push('outputDir 必须是字符串')
|
|
@@ -222,7 +335,11 @@ export class MediaStore {
|
|
|
222
335
|
|
|
223
336
|
/** 完整设置(含明文 Key):只允许宿主内部使用。 */
|
|
224
337
|
settings() {
|
|
225
|
-
return {
|
|
338
|
+
return {
|
|
339
|
+
...this.data,
|
|
340
|
+
defaultModels: { ...this.data.defaultModels },
|
|
341
|
+
endpoints: this.data.endpoints.map(function (endpoint) { return { ...endpoint } })
|
|
342
|
+
}
|
|
226
343
|
}
|
|
227
344
|
|
|
228
345
|
/** 供客户端 / 路由使用:永远不带明文 Key。 */
|
|
@@ -236,14 +353,24 @@ export class MediaStore {
|
|
|
236
353
|
requestTimeoutMs: this.data.requestTimeoutMs,
|
|
237
354
|
autoDownload: this.data.autoDownload,
|
|
238
355
|
outputDir: this.data.outputDir,
|
|
239
|
-
defaultModels: { ...this.data.defaultModels }
|
|
356
|
+
defaultModels: { ...this.data.defaultModels },
|
|
357
|
+
endpoints: this.data.endpoints.map(function (endpoint) { return { ...endpoint } }),
|
|
358
|
+
activeEndpointId: this.data.activeEndpointId
|
|
240
359
|
}
|
|
241
360
|
}
|
|
242
361
|
|
|
243
362
|
update(patch) {
|
|
244
363
|
const result = validatePatch(patch)
|
|
245
364
|
if (result.errors.length > 0) return { ok: false, errors: result.errors }
|
|
246
|
-
|
|
365
|
+
const merged = { ...this.data, ...result.patch }
|
|
366
|
+
// 只改 baseUrl 时改的是「当前线路」的地址,不新建线路;整表提交时以提交的为准。
|
|
367
|
+
if (result.patch.baseUrl !== undefined && result.patch.endpoints === undefined) {
|
|
368
|
+
const activeId = this.data.activeEndpointId
|
|
369
|
+
merged.endpoints = this.data.endpoints.map(function (endpoint) {
|
|
370
|
+
return endpoint.id === activeId ? { ...endpoint, baseUrl: result.patch.baseUrl } : endpoint
|
|
371
|
+
})
|
|
372
|
+
}
|
|
373
|
+
this.data = normalizeSettings(merged)
|
|
247
374
|
this.save()
|
|
248
375
|
return { ok: true, state: this.publicState() }
|
|
249
376
|
}
|
package/package.json
CHANGED
package/scripts/client-test.mjs
CHANGED
|
@@ -64,8 +64,14 @@ const STATE = {
|
|
|
64
64
|
autoDownload: true,
|
|
65
65
|
outputDir: '',
|
|
66
66
|
defaultModels: { text2image: 'qwen-image-2.1-lite' },
|
|
67
|
+
baseUrl: 'http://125.64.108.97:54157',
|
|
68
|
+
endpoints: [
|
|
69
|
+
{ id: 'local', name: '127.0.0.1:8288', baseUrl: 'http://127.0.0.1:8288' },
|
|
70
|
+
{ id: 'public', name: '125.64.108.97:54157', baseUrl: 'http://125.64.108.97:54157' }
|
|
71
|
+
],
|
|
72
|
+
activeEndpointId: 'public',
|
|
67
73
|
name: 'dsh-kylin-media',
|
|
68
|
-
version: '0.
|
|
74
|
+
version: '0.2.0'
|
|
69
75
|
}
|
|
70
76
|
const CATALOG = {
|
|
71
77
|
types: [
|
|
@@ -103,11 +109,67 @@ const TEST_PAYLOAD = {
|
|
|
103
109
|
}
|
|
104
110
|
|
|
105
111
|
function respond(action, options) {
|
|
106
|
-
|
|
112
|
+
const body = options === undefined || options.body === undefined ? null : JSON.parse(options.body)
|
|
113
|
+
calls.push({ action, method: options === undefined || options.method === undefined ? 'GET' : options.method, body: body })
|
|
107
114
|
let payload
|
|
108
115
|
if (action === 'state') payload = { ok: true, value: STATE }
|
|
109
116
|
else if (action === 'test') payload = { ok: true, value: TEST_PAYLOAD }
|
|
110
|
-
else if (action === 'settings.update')
|
|
117
|
+
else if (action === 'settings.update') {
|
|
118
|
+
const patch = body !== null && body.patch !== undefined ? body.patch : {}
|
|
119
|
+
if (Array.isArray(patch.endpoints)) {
|
|
120
|
+
STATE.endpoints = patch.endpoints
|
|
121
|
+
if (!STATE.endpoints.some(function (line) { return line.id === STATE.activeEndpointId })) {
|
|
122
|
+
STATE.activeEndpointId = STATE.endpoints[0].id
|
|
123
|
+
STATE.baseUrl = STATE.endpoints[0].baseUrl
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (typeof patch.baseUrl === 'string') {
|
|
127
|
+
STATE.baseUrl = patch.baseUrl
|
|
128
|
+
STATE.endpoints = STATE.endpoints.map(function (line) {
|
|
129
|
+
return line.id === STATE.activeEndpointId ? Object.assign({}, line, { baseUrl: patch.baseUrl }) : line
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
if (patch.defaultModels !== undefined) STATE.defaultModels = patch.defaultModels
|
|
133
|
+
payload = { ok: true, value: STATE }
|
|
134
|
+
}
|
|
135
|
+
else if (action === 'endpoints.select') {
|
|
136
|
+
const line = STATE.endpoints.find(function (item) { return item.id === (body === null ? '' : body.id) })
|
|
137
|
+
if (line === undefined) payload = { ok: false, error: { code: 'unknown-endpoint', message: '没有这条连接线路' } }
|
|
138
|
+
else {
|
|
139
|
+
STATE.activeEndpointId = line.id
|
|
140
|
+
STATE.baseUrl = line.baseUrl
|
|
141
|
+
payload = { ok: true, value: STATE }
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
else if (action === 'endpoints.probe') {
|
|
145
|
+
const line = STATE.endpoints.find(function (item) { return item.id === (body === null ? '' : body.id) }) || STATE.endpoints[0]
|
|
146
|
+
const reachable = line.id !== 'local'
|
|
147
|
+
payload = { ok: true, value: {
|
|
148
|
+
id: line.id,
|
|
149
|
+
name: line.name,
|
|
150
|
+
baseUrl: line.baseUrl,
|
|
151
|
+
reachable: reachable,
|
|
152
|
+
latencyMs: reachable ? 21 : 3,
|
|
153
|
+
gateway: reachable ? { version: '0.3.0', authMode: 'always' } : null,
|
|
154
|
+
comfy: reachable ? { reachable: true, upstreams: 2 } : null,
|
|
155
|
+
error: reachable ? null : { code: 'connection-refused', message: '本机没有服务监听' }
|
|
156
|
+
} }
|
|
157
|
+
}
|
|
158
|
+
else if (action === 'endpoints.probeAll') {
|
|
159
|
+
payload = { ok: true, value: { results: STATE.endpoints.map(function (line) {
|
|
160
|
+
const reachable = line.id !== 'local'
|
|
161
|
+
return {
|
|
162
|
+
id: line.id,
|
|
163
|
+
name: line.name,
|
|
164
|
+
baseUrl: line.baseUrl,
|
|
165
|
+
reachable: reachable,
|
|
166
|
+
latencyMs: reachable ? 18 : 3,
|
|
167
|
+
gateway: reachable ? { version: '0.3.0', authMode: 'always' } : null,
|
|
168
|
+
comfy: reachable ? { reachable: true, upstreams: 2 } : null,
|
|
169
|
+
error: reachable ? null : { code: 'connection-refused', message: '本机没有服务监听' }
|
|
170
|
+
}
|
|
171
|
+
}) } }
|
|
172
|
+
}
|
|
111
173
|
else if (action === 'key.clear') payload = { ok: true, value: STATE }
|
|
112
174
|
else payload = { ok: false, error: { code: 'not-found', message: 'unexpected ' + action } }
|
|
113
175
|
return Promise.resolve({ status: 200, json: function () { return Promise.resolve(payload) } })
|
|
@@ -236,6 +298,64 @@ const clearedUpdate = calls.filter(function (call) { return call.action === 'set
|
|
|
236
298
|
check(clearedUpdate !== undefined && clearedUpdate.body.patch.defaultModels !== undefined, 'auto row posts a defaultModels patch')
|
|
237
299
|
check(clearedUpdate !== undefined && clearedUpdate.body.patch.defaultModels.text2image === undefined, 'auto row clears the type default')
|
|
238
300
|
|
|
301
|
+
/* ---------- 连接线路 ---------- */
|
|
302
|
+
const lineRows = findAll(render(), function (node) {
|
|
303
|
+
return typeof node.props.className === 'string' && /(^|\s)km-line(\s|$)/.test(node.props.className)
|
|
304
|
+
})
|
|
305
|
+
check(lineRows.length === 2, 'two connection lines are rendered')
|
|
306
|
+
const activeRows = lineRows.filter(function (row) { return row.props['data-active'] === 'true' })
|
|
307
|
+
check(activeRows.length === 1 && textOf(activeRows[0]).includes('125.64.108.97:54157'), 'the stored active line is marked')
|
|
308
|
+
|
|
309
|
+
const localRow = lineRows.find(function (row) { return textOf(row).includes('127.0.0.1:8288') })
|
|
310
|
+
const localRadio = findAll(localRow, function (node) { return node.props.className === 'km-line-radio' })[0]
|
|
311
|
+
check(localRadio !== undefined, 'each line has a radio switch')
|
|
312
|
+
localRadio.props.onClick()
|
|
313
|
+
await settle()
|
|
314
|
+
const selectCall = calls.filter(function (call) { return call.action === 'endpoints.select' }).pop()
|
|
315
|
+
check(selectCall !== undefined && selectCall.body.id === 'local', 'switching a line posts endpoints.select')
|
|
316
|
+
|
|
317
|
+
const probeAllButton = findAll(render(), function (node) { return node.type === 'button' && textOf(node) === 'lineTestAll' })[0]
|
|
318
|
+
check(probeAllButton !== undefined, 'test-all button exists')
|
|
319
|
+
probeAllButton.props.onClick()
|
|
320
|
+
await settle()
|
|
321
|
+
const probeAllCall = calls.filter(function (call) { return call.action === 'endpoints.probeAll' }).pop()
|
|
322
|
+
check(probeAllCall !== undefined, 'test all posts endpoints.probeAll')
|
|
323
|
+
const statuses = findAll(render(), function (node) {
|
|
324
|
+
return typeof node.props.className === 'string' && node.props.className.indexOf('km-line-status') >= 0
|
|
325
|
+
}).map(textOf)
|
|
326
|
+
check(statuses.indexOf('18ms') >= 0 && statuses.indexOf('lineFailed') >= 0, 'test all renders per-line latency and failure')
|
|
327
|
+
|
|
328
|
+
const urlInput = findAll(render(), function (node) { return node.props.id === 'km-base-url' })[0]
|
|
329
|
+
urlInput.props.onChange({ target: { value: 'http://192.168.1.5:8288' } })
|
|
330
|
+
await settle()
|
|
331
|
+
const addButton = findAll(render(), function (node) { return node.type === 'button' && textOf(node) === 'lineAdd' })[0]
|
|
332
|
+
addButton.props.onClick()
|
|
333
|
+
await settle()
|
|
334
|
+
const addCall = calls.filter(function (call) { return call.action === 'settings.update' }).pop()
|
|
335
|
+
check(addCall !== undefined && addCall.body.patch.endpoints.length === 3, 'adding the current address appends a line')
|
|
336
|
+
check(addCall !== undefined && addCall.body.patch.endpoints[2].baseUrl === 'http://192.168.1.5:8288', 'the new line keeps the typed address')
|
|
337
|
+
|
|
338
|
+
const lanRow = findAll(render(), function (node) {
|
|
339
|
+
return typeof node.props.className === 'string' && /(^|\s)km-line(\s|$)/.test(node.props.className) && textOf(node).includes('192.168.1.5:8288')
|
|
340
|
+
})[0]
|
|
341
|
+
check(lanRow !== undefined, 'the new line is rendered after saving')
|
|
342
|
+
const removeButton = findAll(lanRow, function (node) { return node.type === 'button' && textOf(node) === '×' })[0]
|
|
343
|
+
removeButton.props.onClick()
|
|
344
|
+
await settle()
|
|
345
|
+
const removeCall = calls.filter(function (call) { return call.action === 'settings.update' }).pop()
|
|
346
|
+
check(removeCall !== undefined && removeCall.body.patch.endpoints.length === 2, 'removing a line posts the remaining lines')
|
|
347
|
+
|
|
348
|
+
/* ---------- 旧宿主(state 不带 endpoints)不崩、给出重启提示 ---------- */
|
|
349
|
+
const savedEndpoints = STATE.endpoints
|
|
350
|
+
const savedActive = STATE.activeEndpointId
|
|
351
|
+
delete STATE.endpoints
|
|
352
|
+
delete STATE.activeEndpointId
|
|
353
|
+
const legacyTree = render()
|
|
354
|
+
check(textOf(legacyTree).includes('linesUnavailable'), 'old host state renders the restart hint instead of crashing')
|
|
355
|
+
check(findAll(legacyTree, function (node) { return typeof node.props.className === 'string' && /(^|\s)km-line(\s|$)/.test(node.props.className) }).length === 0, 'old host state renders no line rows')
|
|
356
|
+
STATE.endpoints = savedEndpoints
|
|
357
|
+
STATE.activeEndpointId = savedActive
|
|
358
|
+
|
|
239
359
|
if (failures > 0) {
|
|
240
360
|
console.error('client-test: ' + String(failures) + ' failure(s)')
|
|
241
361
|
process.exit(1)
|
package/scripts/selftest.mjs
CHANGED
|
@@ -180,6 +180,7 @@ check(forbidden.status === 403, 'non-loopback Host is rejected with 403')
|
|
|
180
180
|
const stateResponse = await callRoute({ url: '/dsh-kylin-media/api/state' })
|
|
181
181
|
check(stateResponse.status === 200, 'GET state is 200')
|
|
182
182
|
check(stateResponse.body.ok === true && stateResponse.body.value.hasKey === true, 'state reports key configured')
|
|
183
|
+
check(Array.isArray(stateResponse.body.value.endpoints) && stateResponse.body.value.endpoints.length === 1, 'state exposes the default connection line')
|
|
183
184
|
check(JSON.stringify(stateResponse.body).includes(KEY) === false, 'state response never contains plaintext key')
|
|
184
185
|
const badBase = await callRoute({ method: 'POST', url: '/dsh-kylin-media/api/settings.update', body: { patch: { baseUrl: 'ftp://nope' } } })
|
|
185
186
|
check(badBase.status === 400, 'invalid base URL is rejected with 400')
|
|
@@ -196,6 +197,47 @@ check(testResponse.status === 200 && testResponse.body.value.reachable === true,
|
|
|
196
197
|
check(testResponse.body.value.catalog.types.length >= 1, 'test action returns a catalogue')
|
|
197
198
|
check(testResponse.body.value.catalog.types[0].models[0].id === 'mock-image', 'test payload lists concrete models')
|
|
198
199
|
|
|
200
|
+
// 连接线路:多地址保存、切换、探测(都用 mock 网关;备用线走不存在的路径,快速返回 404)
|
|
201
|
+
const linesUpdate = await callRoute({ method: 'POST', url: '/dsh-kylin-media/api/settings.update', body: { patch: {
|
|
202
|
+
endpoints: [
|
|
203
|
+
{ id: 'primary', name: '主线路', baseUrl },
|
|
204
|
+
{ id: 'secondary', name: '备用线路', baseUrl: baseUrl + '/backup' },
|
|
205
|
+
{ id: 'public', name: '公网', baseUrl: baseUrl + '/public' }
|
|
206
|
+
],
|
|
207
|
+
activeEndpointId: 'public'
|
|
208
|
+
} } })
|
|
209
|
+
check(linesUpdate.status === 200, 'settings.update accepts connection lines')
|
|
210
|
+
check(linesUpdate.body.value.endpoints.length === 3, 'three connection lines are stored')
|
|
211
|
+
check(linesUpdate.body.value.activeEndpointId === 'public' && linesUpdate.body.value.baseUrl === baseUrl + '/public', 'the active line drives baseUrl')
|
|
212
|
+
|
|
213
|
+
const switchLine = await callRoute({ method: 'POST', url: '/dsh-kylin-media/api/endpoints.select', body: { id: 'primary' } })
|
|
214
|
+
check(switchLine.status === 200 && switchLine.body.value.activeEndpointId === 'primary', 'endpoints.select switches the active line')
|
|
215
|
+
check(switchLine.body.value.baseUrl === baseUrl, 'switching restores that line address')
|
|
216
|
+
const unknownLine = await callRoute({ method: 'POST', url: '/dsh-kylin-media/api/endpoints.select', body: { id: 'nope' } })
|
|
217
|
+
check(unknownLine.status === 404, 'unknown line is 404')
|
|
218
|
+
|
|
219
|
+
const probeOne = await callRoute({ method: 'POST', url: '/dsh-kylin-media/api/endpoints.probe', body: { id: 'primary' } })
|
|
220
|
+
check(probeOne.status === 200 && probeOne.body.value.reachable === true, 'endpoints.probe reaches the mock gateway')
|
|
221
|
+
check(probeOne.body.value.gateway.version === '0.3.0', 'probe reports the gateway version')
|
|
222
|
+
const probeBad = await callRoute({ method: 'POST', url: '/dsh-kylin-media/api/endpoints.probe', body: { baseUrl: 'ftp://nope' } })
|
|
223
|
+
check(probeBad.status === 400, 'endpoints.probe rejects a non-http address')
|
|
224
|
+
|
|
225
|
+
const probeAll = await callRoute({ method: 'POST', url: '/dsh-kylin-media/api/endpoints.probeAll', body: {} })
|
|
226
|
+
check(probeAll.status === 200 && probeAll.body.value.results.length === 3, 'endpoints.probeAll probes every line')
|
|
227
|
+
const probeById = {}
|
|
228
|
+
for (const item of probeAll.body.value.results) probeById[item.id] = item
|
|
229
|
+
check(probeById.primary.reachable === true, 'probeAll marks the reachable line')
|
|
230
|
+
check(probeById.secondary.reachable === false && typeof probeById.secondary.error.message === 'string', 'probeAll reports the unreachable line with a reason')
|
|
231
|
+
|
|
232
|
+
const duplicateLines = restarted.update({ endpoints: [{ id: 'a', baseUrl }, { id: 'b', baseUrl }] })
|
|
233
|
+
check(duplicateLines.ok === false, 'duplicate line addresses are rejected')
|
|
234
|
+
const invalidLine = restarted.update({ endpoints: [{ id: 'x', baseUrl: 'ftp://nope' }] })
|
|
235
|
+
check(invalidLine.ok === false, 'non-http line addresses are rejected')
|
|
236
|
+
|
|
237
|
+
// 还原成单线路,后面的工具用例继续打 mock 根地址
|
|
238
|
+
const restoredLine = restarted.update({ endpoints: [{ id: 'default', name: 'mock', baseUrl }], activeEndpointId: 'default' })
|
|
239
|
+
check(restoredLine.ok && restoredLine.state.baseUrl === baseUrl, 'restoring a single line resets baseUrl')
|
|
240
|
+
|
|
199
241
|
const tools = createTools({ store: restarted, workingDirectory: { get: function () { return OUTPUT } } })
|
|
200
242
|
const byName = {}
|
|
201
243
|
for (const definition of tools) byName[definition.name] = definition
|
package/scripts/smoke.mjs
CHANGED
|
@@ -61,6 +61,8 @@ check(!/request\('[^']+'\)/.test(client), 'client always POSTs write actions (no
|
|
|
61
61
|
check(client.includes("query('state')"), 'client reads state through the GET query helper')
|
|
62
62
|
check(client.includes('defaultModels'), 'client persists per-type default models')
|
|
63
63
|
check(client.includes('km-model-radio'), 'client renders selectable model rows')
|
|
64
|
+
check(client.includes("request('endpoints.select'"), 'client switches connection lines')
|
|
65
|
+
check(client.includes('km-line-radio'), 'client renders line switches')
|
|
64
66
|
|
|
65
67
|
const entryModule = await import(new URL('../entry.js', import.meta.url))
|
|
66
68
|
check(entryModule.name === PACKAGE, 'entry name matches package')
|
|
@@ -16,7 +16,7 @@ description: 调用本地 comfyui-gateway 的多媒体能力(文生图/改图/
|
|
|
16
16
|
|
|
17
17
|
| type | 用途 | 典型模型 | 必填 params |
|
|
18
18
|
|---|---|---|---|
|
|
19
|
-
| text2image | 文生图 | zimage-turbo、qwen-image-2.1 | prompt |
|
|
19
|
+
| text2image | 文生图 | zimage-turbo、qwen-image-2.1-lite | prompt |
|
|
20
20
|
| image_edit | 参考图改图 | qwen-image-edit-2509 | prompt、image |
|
|
21
21
|
| text2video | 文生视频 | minimax-h3-t2v | prompt |
|
|
22
22
|
| image2video | 图生视频 / 首尾帧 / 多参考 | minimax-h3-fl2va、-flf2v、-multiref、wan2.2-ti2v-5b | prompt、image |
|
|
@@ -42,3 +42,4 @@ description: 调用本地 comfyui-gateway 的多媒体能力(文生图/改图/
|
|
|
42
42
|
- 长任务(视频、音乐、分离)优先异步:media_generate wait=false 拿到 id,再用 media_job 轮询,避免一次工具调用长时间占用。
|
|
43
43
|
- 参数名必须来自 paramSchema,未知字段会被网关 400 拒绝;参数校验失败会回传 details。
|
|
44
44
|
- 不要向用户回显访问 Key;插件界面只显示脱敏串。
|
|
45
|
+
- 连不上先看地址形态:127.0.0.1 只对同机网关有效,局域网要求同网段且防火墙放行;用户在「设置 → 多媒体网关 → 连接线路」里保存多条地址并切换,media_* 一律走当前线路。
|