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 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.1.1'
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
- const combined = combineSignal(opts.signal, timeoutMs)
129
- try {
130
- const response = await fetch(base + path, {
131
- method: opts.method || 'GET',
132
- headers: opts.headers || authHeaders({}),
133
- body: opts.body,
134
- signal: combined.signal
135
- })
136
- const text = await response.text()
137
- let payload = null
138
- if (text !== '') {
139
- try {
140
- payload = JSON.parse(text)
141
- } catch {
142
- payload = null
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.1.1' } } }
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 { ...this.data, defaultModels: { ...this.data.defaultModels } }
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
- this.data = normalizeSettings({ ...this.data, ...result.patch })
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-kylin-media",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "麒麟(QiLin)/ DSH 多媒体网关插件:连接本地 comfyui-gateway,在会话里出图、出视频、出音乐、对口型,并做语音转写、合成与分离。",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -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.1.1'
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
- calls.push({ action, method: options === undefined || options.method === undefined ? 'GET' : options.method, body: options === undefined || options.body === undefined ? null : JSON.parse(options.body) })
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') payload = { ok: true, value: STATE }
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)
@@ -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_* 一律走当前线路。