dsh-kylin-media 0.1.1 → 0.2.1
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 +9 -1
- package/client.js +263 -1
- package/entry.js +1 -1
- package/lib/gateway.js +71 -29
- package/lib/routes.js +88 -1
- package/lib/store.js +132 -5
- package/package.json +1 -1
- package/scripts/client-test.mjs +131 -3
- package/scripts/selftest.mjs +81 -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,13 @@ 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
|
+
>
|
|
70
|
+
> 局域网里偶发 `EHOSTUNREACH`(刚唤醒、Wi-Fi 切换、ARP 未就绪)不用管:所有 GET 探测都会自动重试最多 2 次;仍失败时先在本机 `curl http://<主机IP>:8288/api/health` 复核。
|
|
71
|
+
|
|
64
72
|
## 工具
|
|
65
73
|
|
|
66
74
|
| 工具 | 作用 |
|
package/client.js
CHANGED
|
@@ -38,6 +38,25 @@ 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
|
+
localNetworkHint: '(局域网目标全部 EHOSTUNREACH:多为 macOS「本地网络」权限拦截——去 系统设置 → 隐私与安全性 → 本地网络 放行本 App 后重试)',
|
|
59
|
+
fastestLine: '最快线路:',
|
|
41
60
|
saved: '已保存',
|
|
42
61
|
failed: '操作失败:',
|
|
43
62
|
loadFailed: '配置加载失败,请确认插件已激活后重试。',
|
|
@@ -115,6 +134,25 @@ window.__ModuleLoader__.load({
|
|
|
115
134
|
testing: 'Testing…',
|
|
116
135
|
clearKey: 'Clear key',
|
|
117
136
|
cleared: 'Access key cleared',
|
|
137
|
+
lines: 'Connection lines',
|
|
138
|
+
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.',
|
|
139
|
+
lineAdd: 'Add current address as a line',
|
|
140
|
+
lineTest: 'Test',
|
|
141
|
+
linesUnavailable: 'This host does not support connection lines yet: restart the host (plugin 0.2.0) to manage and switch lines here.',
|
|
142
|
+
lineTestAll: 'Test all',
|
|
143
|
+
lineTesting: 'Testing…',
|
|
144
|
+
lineFailed: 'failed',
|
|
145
|
+
lineActive: 'Active line',
|
|
146
|
+
lineSwitch: 'Switch to this line',
|
|
147
|
+
lineRemove: 'Remove line',
|
|
148
|
+
lineSwitched: 'Switched to this line',
|
|
149
|
+
lineAdded: 'Line added',
|
|
150
|
+
lineRemoved: 'Line removed',
|
|
151
|
+
lineExists: 'That address is already in the list',
|
|
152
|
+
lineKeepOne: 'Keep at least one line',
|
|
153
|
+
noLineReachable: 'No line is reachable: check the gateway process, address/port and firewall',
|
|
154
|
+
localNetworkHint: ' (every LAN target returns EHOSTUNREACH: usually macOS Local Network privacy - allow this app in System Settings > Privacy & Security > Local Network, then retry)',
|
|
155
|
+
fastestLine: 'Fastest line: ',
|
|
118
156
|
saved: 'Saved',
|
|
119
157
|
failed: 'Operation failed: ',
|
|
120
158
|
loadFailed: 'Failed to load settings. Make sure the plugin is active, then retry.',
|
|
@@ -213,6 +251,20 @@ window.__ModuleLoader__.load({
|
|
|
213
251
|
'.km-hint{font-size:11.5px;line-height:1.55;color:var(--dsw-alias-label-tertiary,#8a919c)}',
|
|
214
252
|
'.km-keyrow{display:flex;gap:8px;align-items:stretch}',
|
|
215
253
|
'.km-keyrow input{flex:1;min-width:0}',
|
|
254
|
+
'.km-lines{display:flex;flex-direction:column;gap:6px;margin-top:8px}',
|
|
255
|
+
'.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}',
|
|
256
|
+
'.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)}',
|
|
257
|
+
'.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}',
|
|
258
|
+
'.km-line-radio[data-active=true]{border-color:var(--dsw-alias-brand-primary,#4c7dff)}',
|
|
259
|
+
'.km-line-radio[data-active=true]::after{content:"";position:absolute;inset:2px;border-radius:50%;background:var(--dsw-alias-brand-primary,#4c7dff)}',
|
|
260
|
+
'.km-line-main{flex:1;min-width:0;display:flex;flex-direction:column;gap:1px}',
|
|
261
|
+
'.km-line-name{font-size:12px;color:var(--dsw-alias-label-primary,#1f2329)}',
|
|
262
|
+
'.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}',
|
|
263
|
+
'.km-line-status{flex:none;font-size:11px;color:var(--dsw-alias-label-tertiary,#8a919c);white-space:nowrap}',
|
|
264
|
+
'.km-line-status[data-state=ok]{color:var(--dsw-alias-state-success-primary,#2f8f5b)}',
|
|
265
|
+
'.km-line-status[data-state=error]{color:var(--dsw-alias-state-error-primary,#d9534f)}',
|
|
266
|
+
'.km-line-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px}',
|
|
267
|
+
'.km-btn-mini{padding:4px 9px;font-size:11.5px;line-height:1.2}',
|
|
216
268
|
'.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
269
|
'.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
270
|
'.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 +376,12 @@ window.__ModuleLoader__.load({
|
|
|
324
376
|
var modelNoticeHook = React.useState(null);
|
|
325
377
|
var modelNotice = modelNoticeHook[0];
|
|
326
378
|
var setModelNotice = modelNoticeHook[1];
|
|
379
|
+
var linesHook = React.useState({});
|
|
380
|
+
var lineResults = linesHook[0];
|
|
381
|
+
var setLineResults = linesHook[1];
|
|
382
|
+
var linesBusyHook = React.useState(false);
|
|
383
|
+
var linesBusy = linesBusyHook[0];
|
|
384
|
+
var setLinesBusy = linesBusyHook[1];
|
|
327
385
|
|
|
328
386
|
/* 点选即保存:{ type: modelId },空串表示回到「自动」。 */
|
|
329
387
|
function selectModel(type, modelId) {
|
|
@@ -348,6 +406,155 @@ window.__ModuleLoader__.load({
|
|
|
348
406
|
});
|
|
349
407
|
}
|
|
350
408
|
|
|
409
|
+
function hostOf(url) {
|
|
410
|
+
try { return new URL(url).host || url; } catch (error) { return url; }
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function setLineResult(id, value) {
|
|
414
|
+
setLineResults(function (previous) {
|
|
415
|
+
var next = Object.assign({}, previous);
|
|
416
|
+
next[id] = value;
|
|
417
|
+
return next;
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/* 线路相关的接口都会返回最新 publicState:直接落到快照与地址输入框。 */
|
|
422
|
+
function applyState(state) {
|
|
423
|
+
setSnapshot({ status: 'ready', data: state, error: null });
|
|
424
|
+
setDefaults(state.defaultModels || {});
|
|
425
|
+
setDraft(function (previous) {
|
|
426
|
+
if (previous === null) {
|
|
427
|
+
return {
|
|
428
|
+
baseUrl: state.baseUrl,
|
|
429
|
+
waitSeconds: String(state.waitSeconds),
|
|
430
|
+
outputDir: state.outputDir,
|
|
431
|
+
autoDownload: state.autoDownload
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
return Object.assign({}, previous, { baseUrl: state.baseUrl });
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function selectEndpoint(id) {
|
|
439
|
+
if (busy || linesBusy) return;
|
|
440
|
+
setNotice(null);
|
|
441
|
+
request('endpoints.select', { id: id }).then(function (payload) {
|
|
442
|
+
if (!payload.ok) throw new Error(payloadError(payload));
|
|
443
|
+
applyState(payload.value);
|
|
444
|
+
setNotice({ kind: 'ok', text: t('lineSwitched') });
|
|
445
|
+
runTest();
|
|
446
|
+
}).catch(function (error) {
|
|
447
|
+
setNotice({ kind: 'error', text: t('failed') + (error && error.message ? error.message : String(error)) });
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function probeLine(id) {
|
|
452
|
+
setLineResult(id, { status: 'loading' });
|
|
453
|
+
request('endpoints.probe', { id: id }).then(function (payload) {
|
|
454
|
+
if (!payload.ok) throw new Error(payloadError(payload));
|
|
455
|
+
var value = payload.value;
|
|
456
|
+
setLineResult(id, value.reachable
|
|
457
|
+
? { status: 'ok', latencyMs: value.latencyMs, message: '' }
|
|
458
|
+
: { status: 'error', latencyMs: value.latencyMs, message: value.error ? value.error.message : '' });
|
|
459
|
+
}).catch(function (error) {
|
|
460
|
+
setLineResult(id, { status: 'error', message: error && error.message ? error.message : String(error) });
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function currentLines() {
|
|
465
|
+
return Array.isArray(data.endpoints) ? data.endpoints : [];
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function probeAll() {
|
|
469
|
+
if (linesBusy) return;
|
|
470
|
+
var lines = currentLines();
|
|
471
|
+
if (lines.length === 0) return;
|
|
472
|
+
setLinesBusy(true);
|
|
473
|
+
setNotice(null);
|
|
474
|
+
var pending = {};
|
|
475
|
+
for (var index = 0; index < lines.length; index += 1) pending[lines[index].id] = { status: 'loading' };
|
|
476
|
+
setLineResults(pending);
|
|
477
|
+
request('endpoints.probeAll', {}).then(function (payload) {
|
|
478
|
+
if (!payload.ok) throw new Error(payloadError(payload));
|
|
479
|
+
var next = {};
|
|
480
|
+
var best = null;
|
|
481
|
+
for (var probe = 0; probe < payload.value.results.length; probe += 1) {
|
|
482
|
+
var result = payload.value.results[probe];
|
|
483
|
+
next[result.id] = result.reachable
|
|
484
|
+
? { status: 'ok', latencyMs: result.latencyMs, message: '' }
|
|
485
|
+
: { status: 'error', latencyMs: result.latencyMs, message: result.error ? result.error.message : '' };
|
|
486
|
+
if (result.reachable && (best === null || result.latencyMs < best.latencyMs)) best = result;
|
|
487
|
+
}
|
|
488
|
+
setLineResults(next);
|
|
489
|
+
if (best === null) {
|
|
490
|
+
var blocked = false;
|
|
491
|
+
for (var check = 0; check < payload.value.results.length; check += 1) {
|
|
492
|
+
var failed = payload.value.results[check];
|
|
493
|
+
if (failed.reachable === false && failed.error && /EHOSTUNREACH/.test(String(failed.error.message))) blocked = true;
|
|
494
|
+
}
|
|
495
|
+
setNotice({ kind: 'error', text: t('noLineReachable') + (blocked ? t('localNetworkHint') : '') });
|
|
496
|
+
} else setNotice({ kind: 'ok', text: t('fastestLine') + best.name + ' · ' + String(best.latencyMs) + 'ms' });
|
|
497
|
+
}).catch(function (error) {
|
|
498
|
+
setNotice({ kind: 'error', text: t('failed') + (error && error.message ? error.message : String(error)) });
|
|
499
|
+
}).then(function () {
|
|
500
|
+
setLinesBusy(false);
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function addLine() {
|
|
505
|
+
if (linesBusy || draft === null) return;
|
|
506
|
+
var url = String(draft.baseUrl || '').trim().replace(/\/+$/, '');
|
|
507
|
+
if (url === '') return;
|
|
508
|
+
var lines = currentLines();
|
|
509
|
+
for (var index = 0; index < lines.length; index += 1) {
|
|
510
|
+
if (lines[index].baseUrl === url) {
|
|
511
|
+
setNotice({ kind: 'error', text: t('lineExists') });
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
setLinesBusy(true);
|
|
516
|
+
setNotice(null);
|
|
517
|
+
request('settings.update', { patch: { endpoints: lines.concat([{ name: hostOf(url), baseUrl: url }]) } }).then(function (payload) {
|
|
518
|
+
if (!payload.ok) throw new Error(payloadError(payload));
|
|
519
|
+
applyState(payload.value);
|
|
520
|
+
setNotice({ kind: 'ok', text: t('lineAdded') });
|
|
521
|
+
}).catch(function (error) {
|
|
522
|
+
setNotice({ kind: 'error', text: t('failed') + (error && error.message ? error.message : String(error)) });
|
|
523
|
+
}).then(function () {
|
|
524
|
+
setLinesBusy(false);
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function removeLine(id) {
|
|
529
|
+
if (linesBusy) return;
|
|
530
|
+
var lines = currentLines();
|
|
531
|
+
var kept = [];
|
|
532
|
+
var removedActive = false;
|
|
533
|
+
for (var index = 0; index < lines.length; index += 1) {
|
|
534
|
+
if (lines[index].id === id) {
|
|
535
|
+
removedActive = lines[index].id === data.activeEndpointId;
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
kept.push(lines[index]);
|
|
539
|
+
}
|
|
540
|
+
if (kept.length === 0) {
|
|
541
|
+
setNotice({ kind: 'error', text: t('lineKeepOne') });
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
setLinesBusy(true);
|
|
545
|
+
setNotice(null);
|
|
546
|
+
request('settings.update', { patch: { endpoints: kept } }).then(function (payload) {
|
|
547
|
+
if (!payload.ok) throw new Error(payloadError(payload));
|
|
548
|
+
applyState(payload.value);
|
|
549
|
+
setNotice({ kind: 'ok', text: t('lineRemoved') });
|
|
550
|
+
if (removedActive) runTest();
|
|
551
|
+
}).catch(function (error) {
|
|
552
|
+
setNotice({ kind: 'error', text: t('failed') + (error && error.message ? error.message : String(error)) });
|
|
553
|
+
}).then(function () {
|
|
554
|
+
setLinesBusy(false);
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
|
|
351
558
|
function runTest() {
|
|
352
559
|
setTest({ status: 'loading', data: null, error: null });
|
|
353
560
|
request('test', {}).then(function (payload) {
|
|
@@ -461,6 +668,7 @@ window.__ModuleLoader__.load({
|
|
|
461
668
|
}
|
|
462
669
|
}
|
|
463
670
|
|
|
671
|
+
var endpoints = Array.isArray(data.endpoints) ? data.endpoints : null;
|
|
464
672
|
var connectionCard = React.createElement('section', { className: 'km-card' },
|
|
465
673
|
React.createElement('div', { className: 'km-card-head' },
|
|
466
674
|
React.createElement('h3', null, t('connection')),
|
|
@@ -496,7 +704,61 @@ window.__ModuleLoader__.load({
|
|
|
496
704
|
onClick: clearKey,
|
|
497
705
|
disabled: busy
|
|
498
706
|
}, t('clearKey')) : null),
|
|
499
|
-
React.createElement('span', { className: 'km-hint' }, t('keyHint')))
|
|
707
|
+
React.createElement('span', { className: 'km-hint' }, t('keyHint'))),
|
|
708
|
+
React.createElement('div', { className: 'km-field km-span2' },
|
|
709
|
+
React.createElement('label', null, t('lines')),
|
|
710
|
+
React.createElement('span', { className: 'km-hint' }, t('linesHint')),
|
|
711
|
+
endpoints === null ? React.createElement('span', { className: 'km-hint' }, t('linesUnavailable')) : null,
|
|
712
|
+
React.createElement('div', { className: 'km-lines' },
|
|
713
|
+
(endpoints || []).map(function (endpoint) {
|
|
714
|
+
var active = endpoint.id === data.activeEndpointId;
|
|
715
|
+
var result = lineResults[endpoint.id] || { status: 'idle' };
|
|
716
|
+
var statusText = result.status === 'loading' ? t('lineTesting')
|
|
717
|
+
: result.status === 'ok' ? String(result.latencyMs) + 'ms'
|
|
718
|
+
: result.status === 'error' ? t('lineFailed') : t('idle');
|
|
719
|
+
return React.createElement('div', {
|
|
720
|
+
key: endpoint.id,
|
|
721
|
+
className: 'km-line',
|
|
722
|
+
'data-active': active ? 'true' : 'false'
|
|
723
|
+
},
|
|
724
|
+
React.createElement('button', {
|
|
725
|
+
type: 'button',
|
|
726
|
+
className: 'km-line-radio',
|
|
727
|
+
'data-active': active ? 'true' : 'false',
|
|
728
|
+
title: active ? t('lineActive') : t('lineSwitch'),
|
|
729
|
+
onClick: function () { selectEndpoint(endpoint.id); }
|
|
730
|
+
}),
|
|
731
|
+
React.createElement('div', { className: 'km-line-main' },
|
|
732
|
+
React.createElement('div', { className: 'km-line-name' }, endpoint.name),
|
|
733
|
+
React.createElement('div', { className: 'km-line-url' }, endpoint.baseUrl)),
|
|
734
|
+
React.createElement('span', { className: 'km-line-status', 'data-state': result.status, title: result.message || '' }, statusText),
|
|
735
|
+
React.createElement('button', {
|
|
736
|
+
type: 'button',
|
|
737
|
+
className: 'km-btn km-btn-mini',
|
|
738
|
+
onClick: function () { probeLine(endpoint.id); },
|
|
739
|
+
disabled: result.status === 'loading'
|
|
740
|
+
}, t('lineTest')),
|
|
741
|
+
React.createElement('button', {
|
|
742
|
+
type: 'button',
|
|
743
|
+
className: 'km-btn km-btn-mini km-btn-danger',
|
|
744
|
+
onClick: function () { removeLine(endpoint.id); },
|
|
745
|
+
disabled: linesBusy || endpoints === null || endpoints.length <= 1,
|
|
746
|
+
title: t('lineRemove')
|
|
747
|
+
}, '×'));
|
|
748
|
+
})),
|
|
749
|
+
React.createElement('div', { className: 'km-line-actions' },
|
|
750
|
+
React.createElement('button', {
|
|
751
|
+
type: 'button',
|
|
752
|
+
className: 'km-btn km-btn-mini',
|
|
753
|
+
onClick: addLine,
|
|
754
|
+
disabled: linesBusy
|
|
755
|
+
}, t('lineAdd')),
|
|
756
|
+
React.createElement('button', {
|
|
757
|
+
type: 'button',
|
|
758
|
+
className: 'km-btn km-btn-mini',
|
|
759
|
+
onClick: probeAll,
|
|
760
|
+
disabled: linesBusy
|
|
761
|
+
}, linesBusy ? t('testing') : t('lineTestAll'))))),
|
|
500
762
|
|
|
501
763
|
React.createElement('div', { className: 'km-actions' },
|
|
502
764
|
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.1'
|
|
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,30 @@ 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/i.test(detail)) {
|
|
116
|
+
return '。链路层没通:目标在同一局域网时,先在 macOS「系统设置 → 隐私与安全性 → 本地网络」放行本 App(被拦时就是 EHOSTUNREACH);其次看网关主机是否休眠、刚切过 Wi-Fi 或 ARP 未就绪,隔几秒重试。可在终端 curl ' + base + '/api/health 复核'
|
|
117
|
+
}
|
|
118
|
+
if (/ETIMEDOUT|timed out|timeout/i.test(detail)) return '。SYN 没响应:多半是防火墙丢包(Windows 入站规则要放行该端口),或两台机器不在同一网段'
|
|
119
|
+
return ''
|
|
120
|
+
}
|
|
121
|
+
|
|
98
122
|
/** 把 https/http 写反是最常见的「连不上」:TLS 握手失败时直接给出改法。 */
|
|
99
123
|
function schemeHint(base, detail) {
|
|
100
124
|
if (!looksLikeSchemeMismatch(detail)) return ''
|
|
@@ -122,41 +146,59 @@ export function createGateway(settings) {
|
|
|
122
146
|
return headers
|
|
123
147
|
}
|
|
124
148
|
|
|
149
|
+
/** 刚唤醒 / Wi-Fi 切换 / ARP 未就绪时的瞬时失败,重试就能过去。 */
|
|
150
|
+
function isTransient(detail) {
|
|
151
|
+
return /EHOSTUNREACH|ENETUNREACH|ECONNRESET|EPIPE|EAI_AGAIN|socket hang up/i.test(detail)
|
|
152
|
+
}
|
|
153
|
+
|
|
125
154
|
async function request(path, options) {
|
|
126
155
|
const opts = options === undefined ? {} : options
|
|
156
|
+
const method = opts.method || 'GET'
|
|
127
157
|
const timeoutMs = opts.timeoutMs === undefined ? defaultTimeout : opts.timeoutMs
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
158
|
+
// 只重试幂等的 GET/HEAD;POST 可能已经在网关侧建了任务,绝不重放。
|
|
159
|
+
const attempts = method === 'GET' || method === 'HEAD' ? 3 : 1
|
|
160
|
+
let lastError = null
|
|
161
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
162
|
+
const combined = combineSignal(opts.signal, timeoutMs)
|
|
163
|
+
try {
|
|
164
|
+
const response = await fetch(base + path, {
|
|
165
|
+
method,
|
|
166
|
+
headers: opts.headers || authHeaders({}),
|
|
167
|
+
body: opts.body,
|
|
168
|
+
signal: combined.signal
|
|
169
|
+
})
|
|
170
|
+
const text = await response.text()
|
|
171
|
+
let payload = null
|
|
172
|
+
if (text !== '') {
|
|
173
|
+
try {
|
|
174
|
+
payload = JSON.parse(text)
|
|
175
|
+
} catch {
|
|
176
|
+
payload = null
|
|
177
|
+
}
|
|
143
178
|
}
|
|
179
|
+
if (!response.ok || (isRecord(payload) && payload['ok'] === false)) {
|
|
180
|
+
throw failForStatus(response.status, payload, text)
|
|
181
|
+
}
|
|
182
|
+
return payload
|
|
183
|
+
} catch (error) {
|
|
184
|
+
if (error instanceof GatewayError) throw error
|
|
185
|
+
if (opts.signal !== undefined && opts.signal !== null && opts.signal.aborted) throw error
|
|
186
|
+
if (combined.timedOut()) {
|
|
187
|
+
throw new GatewayError('timeout', '请求网关超时(' + String(timeoutMs) + 'ms):' + base + reachabilityHint(base, 'ETIMEDOUT'), 0, null)
|
|
188
|
+
}
|
|
189
|
+
const detail = describeCause(error)
|
|
190
|
+
const retryNote = attempt < attempts && isTransient(detail) ? '' : (attempts > 1 && isTransient(detail) ? '(已自动重试 ' + String(attempts - 1) + ' 次)' : '')
|
|
191
|
+
lastError = new GatewayError('gateway-unreachable', '无法连接网关 ' + base + ':' + detail + reachabilityHint(base, detail) + retryNote, 0, null)
|
|
192
|
+
if (attempt < attempts && isTransient(detail)) {
|
|
193
|
+
await new Promise(function (resolve) { setTimeout(resolve, 250 * attempt) })
|
|
194
|
+
continue
|
|
195
|
+
}
|
|
196
|
+
throw lastError
|
|
197
|
+
} finally {
|
|
198
|
+
combined.close()
|
|
144
199
|
}
|
|
145
|
-
if (!response.ok || (isRecord(payload) && payload['ok'] === false)) {
|
|
146
|
-
throw failForStatus(response.status, payload, text)
|
|
147
|
-
}
|
|
148
|
-
return payload
|
|
149
|
-
} catch (error) {
|
|
150
|
-
if (error instanceof GatewayError) throw error
|
|
151
|
-
if (opts.signal !== undefined && opts.signal !== null && opts.signal.aborted) throw error
|
|
152
|
-
if (combined.timedOut()) {
|
|
153
|
-
throw new GatewayError('timeout', '请求网关超时(' + String(timeoutMs) + 'ms):' + base, 0, null)
|
|
154
|
-
}
|
|
155
|
-
const detail = describeCause(error)
|
|
156
|
-
throw new GatewayError('gateway-unreachable', '无法连接网关 ' + base + ':' + detail + schemeHint(base, detail), 0, null)
|
|
157
|
-
} finally {
|
|
158
|
-
combined.close()
|
|
159
200
|
}
|
|
201
|
+
throw lastError
|
|
160
202
|
}
|
|
161
203
|
|
|
162
204
|
async function health() {
|
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.1' } } }
|
|
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: 'gateway-unreachable', message: '无法连接网关:EHOSTUNREACH connect EHOSTUNREACH' }
|
|
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,72 @@ 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
|
+
// 全部线路都 EHOSTUNREACH 时给出 macOS 本地网络权限提示
|
|
328
|
+
const savedLineList = STATE.endpoints
|
|
329
|
+
STATE.endpoints = [{ id: 'local', name: '127.0.0.1:8288', baseUrl: 'http://127.0.0.1:8288' }]
|
|
330
|
+
const onlyLocalButton = findAll(render(), function (node) { return node.type === 'button' && textOf(node) === 'lineTestAll' })[0]
|
|
331
|
+
onlyLocalButton.props.onClick()
|
|
332
|
+
await settle()
|
|
333
|
+
check(textOf(render()).includes('localNetworkHint'), 'all-EHOSTUNREACH lines surface the macOS local-network hint')
|
|
334
|
+
STATE.endpoints = savedLineList
|
|
335
|
+
|
|
336
|
+
const urlInput = findAll(render(), function (node) { return node.props.id === 'km-base-url' })[0]
|
|
337
|
+
urlInput.props.onChange({ target: { value: 'http://192.168.1.5:8288' } })
|
|
338
|
+
await settle()
|
|
339
|
+
const addButton = findAll(render(), function (node) { return node.type === 'button' && textOf(node) === 'lineAdd' })[0]
|
|
340
|
+
addButton.props.onClick()
|
|
341
|
+
await settle()
|
|
342
|
+
const addCall = calls.filter(function (call) { return call.action === 'settings.update' }).pop()
|
|
343
|
+
check(addCall !== undefined && addCall.body.patch.endpoints.length === 3, 'adding the current address appends a line')
|
|
344
|
+
check(addCall !== undefined && addCall.body.patch.endpoints[2].baseUrl === 'http://192.168.1.5:8288', 'the new line keeps the typed address')
|
|
345
|
+
|
|
346
|
+
const lanRow = findAll(render(), function (node) {
|
|
347
|
+
return typeof node.props.className === 'string' && /(^|\s)km-line(\s|$)/.test(node.props.className) && textOf(node).includes('192.168.1.5:8288')
|
|
348
|
+
})[0]
|
|
349
|
+
check(lanRow !== undefined, 'the new line is rendered after saving')
|
|
350
|
+
const removeButton = findAll(lanRow, function (node) { return node.type === 'button' && textOf(node) === '×' })[0]
|
|
351
|
+
removeButton.props.onClick()
|
|
352
|
+
await settle()
|
|
353
|
+
const removeCall = calls.filter(function (call) { return call.action === 'settings.update' }).pop()
|
|
354
|
+
check(removeCall !== undefined && removeCall.body.patch.endpoints.length === 2, 'removing a line posts the remaining lines')
|
|
355
|
+
|
|
356
|
+
/* ---------- 旧宿主(state 不带 endpoints)不崩、给出重启提示 ---------- */
|
|
357
|
+
const savedEndpoints = STATE.endpoints
|
|
358
|
+
const savedActive = STATE.activeEndpointId
|
|
359
|
+
delete STATE.endpoints
|
|
360
|
+
delete STATE.activeEndpointId
|
|
361
|
+
const legacyTree = render()
|
|
362
|
+
check(textOf(legacyTree).includes('linesUnavailable'), 'old host state renders the restart hint instead of crashing')
|
|
363
|
+
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')
|
|
364
|
+
STATE.endpoints = savedEndpoints
|
|
365
|
+
STATE.activeEndpointId = savedActive
|
|
366
|
+
|
|
239
367
|
if (failures > 0) {
|
|
240
368
|
console.error('client-test: ' + String(failures) + ' failure(s)')
|
|
241
369
|
process.exit(1)
|
package/scripts/selftest.mjs
CHANGED
|
@@ -130,6 +130,45 @@ await new Promise(function (resolve) { server.listen(0, '127.0.0.1', resolve) })
|
|
|
130
130
|
const port = server.address().port
|
|
131
131
|
const baseUrl = 'http://127.0.0.1:' + String(port)
|
|
132
132
|
|
|
133
|
+
// GET 自动重试:瞬时 EHOSTUNREACH(刚唤醒 / ARP 未就绪)重试后应成功;一直失败时给出已重试次数
|
|
134
|
+
const { createGateway } = await import(new URL('../lib/gateway.js', import.meta.url))
|
|
135
|
+
function hostUnreachable() {
|
|
136
|
+
const error = new TypeError('fetch failed')
|
|
137
|
+
const cause = new Error('connect EHOSTUNREACH 192.168.1.71:8288')
|
|
138
|
+
cause.code = 'EHOSTUNREACH'
|
|
139
|
+
error.cause = cause
|
|
140
|
+
return error
|
|
141
|
+
}
|
|
142
|
+
const originalFetch = globalThis.fetch
|
|
143
|
+
let flakyCalls = 0
|
|
144
|
+
globalThis.fetch = async function (url, init) {
|
|
145
|
+
flakyCalls += 1
|
|
146
|
+
if (flakyCalls === 1) throw hostUnreachable()
|
|
147
|
+
return originalFetch(url, init)
|
|
148
|
+
}
|
|
149
|
+
try {
|
|
150
|
+
const flakyHealth = await createGateway({ baseUrl, apiKey: KEY, requestTimeoutMs: 5000 }).health()
|
|
151
|
+
check(flakyHealth.version === '0.3.0', 'a transient EHOSTUNREACH is retried and then succeeds')
|
|
152
|
+
} finally {
|
|
153
|
+
globalThis.fetch = originalFetch
|
|
154
|
+
}
|
|
155
|
+
check(flakyCalls === 2, 'the transient GET was retried exactly once')
|
|
156
|
+
|
|
157
|
+
let downCalls = 0
|
|
158
|
+
globalThis.fetch = async function () {
|
|
159
|
+
downCalls += 1
|
|
160
|
+
throw hostUnreachable()
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
await createGateway({ baseUrl, apiKey: KEY, requestTimeoutMs: 5000 }).health()
|
|
164
|
+
check(false, 'a permanently unreachable gateway should fail')
|
|
165
|
+
} catch (error) {
|
|
166
|
+
check(error.message.includes('已自动重试 2 次'), 'exhausted retries are reported in the message')
|
|
167
|
+
} finally {
|
|
168
|
+
globalThis.fetch = originalFetch
|
|
169
|
+
}
|
|
170
|
+
check(downCalls === 3, 'a GET is attempted three times before failing')
|
|
171
|
+
|
|
133
172
|
const store = new MediaStore(HOME)
|
|
134
173
|
let result = store.update({ baseUrl, waitSeconds: 10, pollIntervalMs: 500, requestTimeoutMs: 5000, autoDownload: true })
|
|
135
174
|
check(result.ok, 'store.update accepts valid settings')
|
|
@@ -180,6 +219,7 @@ check(forbidden.status === 403, 'non-loopback Host is rejected with 403')
|
|
|
180
219
|
const stateResponse = await callRoute({ url: '/dsh-kylin-media/api/state' })
|
|
181
220
|
check(stateResponse.status === 200, 'GET state is 200')
|
|
182
221
|
check(stateResponse.body.ok === true && stateResponse.body.value.hasKey === true, 'state reports key configured')
|
|
222
|
+
check(Array.isArray(stateResponse.body.value.endpoints) && stateResponse.body.value.endpoints.length === 1, 'state exposes the default connection line')
|
|
183
223
|
check(JSON.stringify(stateResponse.body).includes(KEY) === false, 'state response never contains plaintext key')
|
|
184
224
|
const badBase = await callRoute({ method: 'POST', url: '/dsh-kylin-media/api/settings.update', body: { patch: { baseUrl: 'ftp://nope' } } })
|
|
185
225
|
check(badBase.status === 400, 'invalid base URL is rejected with 400')
|
|
@@ -196,6 +236,47 @@ check(testResponse.status === 200 && testResponse.body.value.reachable === true,
|
|
|
196
236
|
check(testResponse.body.value.catalog.types.length >= 1, 'test action returns a catalogue')
|
|
197
237
|
check(testResponse.body.value.catalog.types[0].models[0].id === 'mock-image', 'test payload lists concrete models')
|
|
198
238
|
|
|
239
|
+
// 连接线路:多地址保存、切换、探测(都用 mock 网关;备用线走不存在的路径,快速返回 404)
|
|
240
|
+
const linesUpdate = await callRoute({ method: 'POST', url: '/dsh-kylin-media/api/settings.update', body: { patch: {
|
|
241
|
+
endpoints: [
|
|
242
|
+
{ id: 'primary', name: '主线路', baseUrl },
|
|
243
|
+
{ id: 'secondary', name: '备用线路', baseUrl: baseUrl + '/backup' },
|
|
244
|
+
{ id: 'public', name: '公网', baseUrl: baseUrl + '/public' }
|
|
245
|
+
],
|
|
246
|
+
activeEndpointId: 'public'
|
|
247
|
+
} } })
|
|
248
|
+
check(linesUpdate.status === 200, 'settings.update accepts connection lines')
|
|
249
|
+
check(linesUpdate.body.value.endpoints.length === 3, 'three connection lines are stored')
|
|
250
|
+
check(linesUpdate.body.value.activeEndpointId === 'public' && linesUpdate.body.value.baseUrl === baseUrl + '/public', 'the active line drives baseUrl')
|
|
251
|
+
|
|
252
|
+
const switchLine = await callRoute({ method: 'POST', url: '/dsh-kylin-media/api/endpoints.select', body: { id: 'primary' } })
|
|
253
|
+
check(switchLine.status === 200 && switchLine.body.value.activeEndpointId === 'primary', 'endpoints.select switches the active line')
|
|
254
|
+
check(switchLine.body.value.baseUrl === baseUrl, 'switching restores that line address')
|
|
255
|
+
const unknownLine = await callRoute({ method: 'POST', url: '/dsh-kylin-media/api/endpoints.select', body: { id: 'nope' } })
|
|
256
|
+
check(unknownLine.status === 404, 'unknown line is 404')
|
|
257
|
+
|
|
258
|
+
const probeOne = await callRoute({ method: 'POST', url: '/dsh-kylin-media/api/endpoints.probe', body: { id: 'primary' } })
|
|
259
|
+
check(probeOne.status === 200 && probeOne.body.value.reachable === true, 'endpoints.probe reaches the mock gateway')
|
|
260
|
+
check(probeOne.body.value.gateway.version === '0.3.0', 'probe reports the gateway version')
|
|
261
|
+
const probeBad = await callRoute({ method: 'POST', url: '/dsh-kylin-media/api/endpoints.probe', body: { baseUrl: 'ftp://nope' } })
|
|
262
|
+
check(probeBad.status === 400, 'endpoints.probe rejects a non-http address')
|
|
263
|
+
|
|
264
|
+
const probeAll = await callRoute({ method: 'POST', url: '/dsh-kylin-media/api/endpoints.probeAll', body: {} })
|
|
265
|
+
check(probeAll.status === 200 && probeAll.body.value.results.length === 3, 'endpoints.probeAll probes every line')
|
|
266
|
+
const probeById = {}
|
|
267
|
+
for (const item of probeAll.body.value.results) probeById[item.id] = item
|
|
268
|
+
check(probeById.primary.reachable === true, 'probeAll marks the reachable line')
|
|
269
|
+
check(probeById.secondary.reachable === false && typeof probeById.secondary.error.message === 'string', 'probeAll reports the unreachable line with a reason')
|
|
270
|
+
|
|
271
|
+
const duplicateLines = restarted.update({ endpoints: [{ id: 'a', baseUrl }, { id: 'b', baseUrl }] })
|
|
272
|
+
check(duplicateLines.ok === false, 'duplicate line addresses are rejected')
|
|
273
|
+
const invalidLine = restarted.update({ endpoints: [{ id: 'x', baseUrl: 'ftp://nope' }] })
|
|
274
|
+
check(invalidLine.ok === false, 'non-http line addresses are rejected')
|
|
275
|
+
|
|
276
|
+
// 还原成单线路,后面的工具用例继续打 mock 根地址
|
|
277
|
+
const restoredLine = restarted.update({ endpoints: [{ id: 'default', name: 'mock', baseUrl }], activeEndpointId: 'default' })
|
|
278
|
+
check(restoredLine.ok && restoredLine.state.baseUrl === baseUrl, 'restoring a single line resets baseUrl')
|
|
279
|
+
|
|
199
280
|
const tools = createTools({ store: restarted, workingDirectory: { get: function () { return OUTPUT } } })
|
|
200
281
|
const byName = {}
|
|
201
282
|
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_* 一律走当前线路。
|