dsh-kylin-media 0.2.0 → 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
@@ -66,6 +66,8 @@ npm publish # prepack 会自动跑 smoke
66
66
  > 1. `127.0.0.1` / `localhost` 只有**网关和插件跑在同一台机器**时才通。网关在远端(例如 Windows 主机)而插件在 Mac 上时,本机地址必然被拒绝。
67
67
  > 2. 局域网地址要求两者**在同一网段**,且网关监听 `HOST=0.0.0.0`(默认)、系统防火墙放行 8288(仓库里有 `add-firewall-rule.bat`)。可先用 `curl http://<主机IP>:8288/api/health` 验证。
68
68
  > 3. 上面两条都不满足时只能用 frp / 公网地址。把三条地址都加进「连接线路」,点「全部测速」即可看出哪条通、哪条最快;失败原因直接写在行内(拒绝连接 / 不可达 / 域名解析失败 / 协议写反)。
69
+ >
70
+ > 局域网里偶发 `EHOSTUNREACH`(刚唤醒、Wi-Fi 切换、ARP 未就绪)不用管:所有 GET 探测都会自动重试最多 2 次;仍失败时先在本机 `curl http://<主机IP>:8288/api/health` 复核。
69
71
 
70
72
  ## 工具
71
73
 
package/client.js CHANGED
@@ -55,6 +55,7 @@ window.__ModuleLoader__.load({
55
55
  lineExists: '这个地址已经在列表里了',
56
56
  lineKeepOne: '至少要保留一条线路',
57
57
  noLineReachable: '所有线路都不通:检查网关是否运行、地址/端口是否正确、防火墙是否放行',
58
+ localNetworkHint: '(局域网目标全部 EHOSTUNREACH:多为 macOS「本地网络」权限拦截——去 系统设置 → 隐私与安全性 → 本地网络 放行本 App 后重试)',
58
59
  fastestLine: '最快线路:',
59
60
  saved: '已保存',
60
61
  failed: '操作失败:',
@@ -150,6 +151,7 @@ window.__ModuleLoader__.load({
150
151
  lineExists: 'That address is already in the list',
151
152
  lineKeepOne: 'Keep at least one line',
152
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)',
153
155
  fastestLine: 'Fastest line: ',
154
156
  saved: 'Saved',
155
157
  failed: 'Operation failed: ',
@@ -484,8 +486,14 @@ window.__ModuleLoader__.load({
484
486
  if (result.reachable && (best === null || result.latencyMs < best.latencyMs)) best = result;
485
487
  }
486
488
  setLineResults(next);
487
- if (best === null) setNotice({ kind: 'error', text: t('noLineReachable') });
488
- else setNotice({ kind: 'ok', text: t('fastestLine') + best.name + ' · ' + String(best.latencyMs) + 'ms' });
489
+ 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' });
489
497
  }).catch(function (error) {
490
498
  setNotice({ kind: 'error', text: t('failed') + (error && error.message ? error.message : String(error)) });
491
499
  }).then(function () {
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.2.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
@@ -112,7 +112,10 @@ function reachabilityHint(base, detail) {
112
112
  return '。目标拒绝连接:确认网关进程在运行、端口正确,并已放行防火墙'
113
113
  }
114
114
  if (/ENOTFOUND|EAI_AGAIN|getaddrinfo/i.test(detail)) return '。域名解析失败:检查地址拼写或 DNS'
115
- if (/EHOSTUNREACH|ENETUNREACH|ETIMEDOUT|timed out|timeout/i.test(detail)) return '。地址不可达:确认与网关在同一网络,Windows 防火墙已放行该端口'
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 入站规则要放行该端口),或两台机器不在同一网段'
116
119
  return ''
117
120
  }
118
121
 
@@ -143,41 +146,59 @@ export function createGateway(settings) {
143
146
  return headers
144
147
  }
145
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
+
146
154
  async function request(path, options) {
147
155
  const opts = options === undefined ? {} : options
156
+ const method = opts.method || 'GET'
148
157
  const timeoutMs = opts.timeoutMs === undefined ? defaultTimeout : opts.timeoutMs
149
- const combined = combineSignal(opts.signal, timeoutMs)
150
- try {
151
- const response = await fetch(base + path, {
152
- method: opts.method || 'GET',
153
- headers: opts.headers || authHeaders({}),
154
- body: opts.body,
155
- signal: combined.signal
156
- })
157
- const text = await response.text()
158
- let payload = null
159
- if (text !== '') {
160
- try {
161
- payload = JSON.parse(text)
162
- } catch {
163
- 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
+ }
164
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()
165
199
  }
166
- if (!response.ok || (isRecord(payload) && payload['ok'] === false)) {
167
- throw failForStatus(response.status, payload, text)
168
- }
169
- return payload
170
- } catch (error) {
171
- if (error instanceof GatewayError) throw error
172
- if (opts.signal !== undefined && opts.signal !== null && opts.signal.aborted) throw error
173
- if (combined.timedOut()) {
174
- throw new GatewayError('timeout', '请求网关超时(' + String(timeoutMs) + 'ms):' + base + reachabilityHint(base, 'ETIMEDOUT'), 0, null)
175
- }
176
- const detail = describeCause(error)
177
- throw new GatewayError('gateway-unreachable', '无法连接网关 ' + base + ':' + detail + reachabilityHint(base, detail), 0, null)
178
- } finally {
179
- combined.close()
180
200
  }
201
+ throw lastError
181
202
  }
182
203
 
183
204
  async function health() {
package/lib/routes.js CHANGED
@@ -171,7 +171,7 @@ export async function handleRequest(store, request, body) {
171
171
  if (!pathname.startsWith(API_PREFIX)) return undefined
172
172
  const action = pathname.slice(API_PREFIX.length).replace(/^\/+/, '')
173
173
  if (method === 'GET' && action === 'state') {
174
- return { status: 200, payload: { ok: true, value: { ...store.publicState(), name: PLUGIN_NAME, version: '0.2.0' } } }
174
+ return { status: 200, payload: { ok: true, value: { ...store.publicState(), name: PLUGIN_NAME, version: '0.2.1' } } }
175
175
  }
176
176
  if (method !== 'POST') {
177
177
  return { status: 405, payload: errorPayload('method-not-allowed', '写操作必须使用 POST') }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-kylin-media",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "麒麟(QiLin)/ DSH 多媒体网关插件:连接本地 comfyui-gateway,在会话里出图、出视频、出音乐、对口型,并做语音转写、合成与分离。",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -166,7 +166,7 @@ function respond(action, options) {
166
166
  latencyMs: reachable ? 18 : 3,
167
167
  gateway: reachable ? { version: '0.3.0', authMode: 'always' } : null,
168
168
  comfy: reachable ? { reachable: true, upstreams: 2 } : null,
169
- error: reachable ? null : { code: 'connection-refused', message: '本机没有服务监听' }
169
+ error: reachable ? null : { code: 'gateway-unreachable', message: '无法连接网关:EHOSTUNREACH connect EHOSTUNREACH' }
170
170
  }
171
171
  }) } }
172
172
  }
@@ -324,6 +324,14 @@ const statuses = findAll(render(), function (node) {
324
324
  return typeof node.props.className === 'string' && node.props.className.indexOf('km-line-status') >= 0
325
325
  }).map(textOf)
326
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
327
335
 
328
336
  const urlInput = findAll(render(), function (node) { return node.props.id === 'km-base-url' })[0]
329
337
  urlInput.props.onChange({ target: { value: 'http://192.168.1.5:8288' } })
@@ -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')