@yanglaofish/dsh-proxy-pro 1.0.6 → 1.0.8

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/docs/LESSONS.md CHANGED
@@ -750,4 +750,68 @@ scope.watch(() => { void requestSync().catch(() => {}) })
750
750
 
751
751
  **教训**:① ESM 顶层 import 命中断言是**加载期**错误——一次错误的 import 能杀死整个进程,所以
752
752
  对外部包的导出要查运行环境的实际版本,而不是凭记忆;② 能从服务拿到的(settings 是 inject 服务)
753
- 就不要 import 包;③ 「桌面版」与「全局 CLI 版」是两套依赖树,一个功能必须两端都能加载。
753
+ 就不要 import 包;③ 「桌面版」与「全局 CLI 版」是两套依赖树,一个功能必须两端都能加载。
754
+
755
+ ---
756
+
757
+ ## 27. HEAD 404 ≠ 路径不存在:探测必须 GET 复测(2026-09-18)
758
+
759
+ **症状**:面板测 `https://dashscope.aliyuncs.com/compatible-mode/v1` 和 `.../v1/models`
760
+ 一律显示「降级 — HTTP 404 — 路径不存在」;连测几个厂商的 API 都是 404,用户开始怀疑
761
+ 「是不是网络不可达」。
762
+
763
+ **真相**(实测对照):
764
+
765
+ | 请求 | DashScope 应答 |
766
+ | --- | --- |
767
+ | GET `/compatible-mode/v1/models` | **401**(端点存在,只缺 API Key) |
768
+ | GET `/compatible-mode/v1/chat/completions` | **400**(端点存在,请求不合法) |
769
+ | GET `/` | 404 |
770
+ | HEAD `/compatible-mode/v1/models` | **404** ← 同一个端点! |
771
+
772
+ 网关不给 HEAD 注册路由,对 HEAD 一律回 404;而 404 被探测当成「路径不存在」,健康的
773
+ API 端点就这样被误判。**404 从来不是「网络不可达」的证据**——它恰恰证明拿到了 HTTP 应答
774
+ (真正的不可达是 ENOTFOUND/ECONNREFUSED/ETIMEDOUT/代理 407)。
775
+
776
+ **改法**(1.0.7):回退条件从 405/501 扩到 **404/405/501**,提为纯函数
777
+ `headNeedsGetRetry(status)`(proxy-core,可单测)。GET 复测带 `Range: bytes=0-0` 只取首
778
+ 字节,代价极小;note 写明「HEAD 返回 X,GET 得到 Y — 以 GET 为准」。404 的 why/fix 改为
779
+ 「HEAD 与 GET 都 404」并引导换真实完整端点复测。
780
+
781
+ **教训**:① 探测方法本身就是误判来源——HEAD「轻量」不等于「等价」;② 把 404 当「不存在」
782
+ 之前先问「服务器认不认这个方法」;③ 状态码是二手的,链路层结论只能由「是否拿到 HTTP
783
+ 应答」给出。
784
+
785
+ ---
786
+
787
+ ## 28. 发布铁律:每次 npm publish 后必须主动强制触发淘宝源同步(用户定案,2026-09-18)
788
+
789
+ **用户的规矩(说了两次,不可违背)**:每次我们更新 npm 包(publish 成功)后,**必须手动
790
+ 主动触发淘宝源(npmmirror)更新,不许等待**——用户原话「每次更新了 npm,都请你手动强制
791
+ 触发淘宝源更新」「不要等待,就是走淘宝的主动同步」。
792
+
793
+ **为什么**:npmmirror 自己拉取官方源有队列延迟,干等(只 PUT 一次 syncs 然后 poll)会 stuck
794
+ 几分钟甚至更久,导致 `dsh plugin add <新版本>` 在淘宝源上解析不到。**主动循环触发**(每轮
795
+ 先 PUT syncs、再 poll)实测 1.0.5=11 轮、1.0.6=13 轮、1.0.7=33 轮(约 2-4 分钟)必定拉到。
796
+
797
+ **标准流程(每一步都要做)**:
798
+ 1. `npm publish --access=public --registry=https://registry.npmjs.org --replace-registry-host=never` 成功;
799
+ 2. 循环最多 60 轮:`PUT https://registry.npmmirror.com/-/package/@yanglaofish%2Fdsh-proxy-pro/syncs`
800
+ 带 body `{"version":"<新版本>"}`(ContentType application/json),随后
801
+ `GET https://registry.npmmirror.com/@yanglaofish%2Fdsh-proxy-pro` 看 `dist-tags.latest`;
802
+ 每轮间隔 8-10s,**一直循环到 latest == 新版本**(不许提前放弃);
803
+ 3. latest 到位后立即:`dsh plugin --profile test add '@yanglaofish/dsh-proxy-pro@^<新版本>'`
804
+ 与 `dsh plugin --profile web add ...`,并验证两个 profile 的 node_modules 版本。
805
+
806
+ **教训**:① 「PUT syncs 返回 201」只是入队确认,不是同步完成——以 `dist-tags.latest` 为准;
807
+ ② 官方 registry 先可见不代表淘宝可见,淘宝拉取是独立队列;③ 用户规则优先于"省事"——主动
808
+ 触发是默认动作,不是可选优化。
809
+
810
+ **补充:git push 的代理必须与「被暖窗的代理」一致(2026-09-18 实测)**:git 全局配置里写死的
811
+ `http.proxy=http://proxyhk.huawei.com:8080` 是历史遗留——当前网络环境该代理已 407 失效,
812
+ 而系统代理是 `proxyza`(南非)。正确姿势:push 前用 `Invoke-WebRequest https://github.com`
813
+ 暖**当前系统代理**(.NET 自动 NTLM),然后 `git -c http.proxy=http://<当前系统代理> push`
814
+ (用 `-c` 临时覆盖写死的 config)。若暖窗对象与 git 实际走的代理不一致,必然 407 CONNECT
815
+ tunnel failed(proxyhk 暖窗 407、proxyza 暖窗 200 → 只有后者能推成功)。长期可选:
816
+ `git config --global --unset http.proxy` 让 git 走插件写入的 `http_proxy` 环境变量(动态跟随
817
+ 当前系统代理),注意 CLI 环境无插件时需手动 `-c` 指定。
package/docs/SPEC.md CHANGED
@@ -199,7 +199,9 @@ installProxyFromEnvironment 的 none-branch 完成:恢复首次安装前的 en
199
199
 
200
200
  `proxy_test` 内部:`proxyRouteFor(parsed)` 判路由名(try/catch,不可用时回退快照
201
201
  推导)→ NO_PROXY 列表命中判断 → `fetch(url, {method:'HEAD', redirect:'manual'})`
202
- 实测 6s 超时(AbortController)→ `classifyTargetFailure` 归类
202
+ 实测 6s 超时(AbortController);HEAD 状态为 404/405/501 时按 `headNeedsGetRetry`
203
+ 用 GET(`Range: bytes=0-0`)复测并以 GET 为准(LESSONS §27:网关常不给 HEAD 注册
204
+ 路由,HEAD 404 ≠ 路径不存在)→ `classifyTargetFailure` 归类
203
205
  (EACCES/ENETUNREACH/EHOSTUNREACH → "needs a proxy route";407/NTLM → auth)。
204
206
 
205
207
  ### 4.7 系统提示段落
package/lib/client.js CHANGED
@@ -74,9 +74,15 @@ window.__ModuleLoader__.load({
74
74
  ".dsxprx-diagwhy{font-size:12px;line-height:17px;color:var(--dsw-alias-label-secondary)}",
75
75
  ".dsxprx-diagfix{font-size:12px;line-height:17px;color:var(--dsw-alias-label-primary)}",
76
76
  ".dsxprx-diagnote{font-size:11px;line-height:16px;color:var(--dsw-alias-label-caption)}",
77
+ ".dsxprx-verdict{display:inline-flex;align-items:center;gap:6px;padding:2px 10px;border-radius:999px;font-weight:600;font-size:12px;line-height:18px}",
78
+ ".dsxprx-verdict[data-tone=ok]{background:rgba(16,185,129,.12);color:var(--dsw-alias-state-success-primary,#10b981)}",
79
+ ".dsxprx-verdict[data-tone=warn]{background:rgba(245,158,11,.14);color:#f59e0b}",
80
+ ".dsxprx-verdict[data-tone=bad]{background:rgba(239,68,68,.12);color:var(--dsw-alias-state-error-primary,#ef4444)}",
81
+ ".dsxprx-diagprobe{display:flex;align-items:center;gap:8px;flex-wrap:wrap}",
82
+ ".dsxprx-diotag{flex:none;min-width:44px;font-size:12px;line-height:17px;color:var(--dsw-alias-label-caption)}",
83
+ ".dsxprx-verdictlarge{font-size:13px;font-weight:600;line-height:19px;color:var(--dsw-alias-label-primary)}",
84
+ ".dsxprx-diagverdict{display:flex;flex-direction:column;gap:3px;padding:6px 0 2px;border-top:1px dashed var(--dsw-alias-border-l1)}",
77
85
  ".dsxprx-title{font-size:16px;font-weight:700;line-height:22px;color:var(--dsw-alias-label-primary)}",
78
- ".dsxprx-diagcheck{flex:none;display:flex;align-items:center;gap:5px;font-size:12px;line-height:16px;color:var(--dsw-alias-label-secondary);cursor:pointer;white-space:nowrap}",
79
- ".dsxprx-diagcheck input{width:14px;height:14px;accent-color:#4d6bfe;margin:0}",
80
86
  ".dsxprx-hdr{display:flex;align-items:center;gap:6px;max-width:180px;height:26px;padding:0 10px;border:1px solid var(--dsw-alias-border-l1);border-radius:999px;background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:12px;line-height:16px;cursor:pointer;white-space:nowrap;overflow:hidden}",
81
87
  ".dsxprx-hdr:hover{background:var(--dsw-alias-state-hover)}",
82
88
  ".dsxprx-hdr .dsxprx-dot{width:8px;height:8px}",
@@ -296,26 +302,29 @@ window.__ModuleLoader__.load({
296
302
  });
297
303
  };
298
304
  }
299
- var diagUseProxy = useState(true);
300
- var setDiagUseProxy = diagUseProxy[1];
301
- /** Three-state headline for one probe verdict (usable / degraded / unusable). */
302
- function probeHeadline(probe) {
303
- if (!probe) return "未知";
304
- if (probe.verdict === "usable") return "可用" + (probe.status === undefined ? "" : "(HTTP " + probe.status + ")");
305
- if (probe.verdict === "degraded") return "降级:" + (probe.short || "未知");
306
- if (probe.verdict === "unusable") return "不可用:" + (probe.short || "未知");
307
- return probe.ok ? "可用" : (probe.short || "未知");
308
- }
305
+ // 诊断双通道并行(走代理 + 直连),由 host 一次给出两侧徽章和推荐,
306
+ // 不再让用户手动勾选通道(2026-09-18:单侧探针分不清被拒者是谁)
309
307
  function runDiag() {
310
308
  var target = diagUrl.trim();
311
309
  if (!target) return;
312
310
  setDiagBusy(true);
313
311
  setDiagResult(null);
314
- fetchJson("/route?url=" + encodeURIComponent(target) + "&useProxy=" + (diagUseProxy[0] ? "1" : "0")).then(function (data) {
312
+ fetchJson("/route?url=" + encodeURIComponent(target)).then(function (data) {
315
313
  setDiagResult(data === null ? { error: "诊断接口不可用" } : data);
316
314
  }).finally(function () { setDiagBusy(false); });
317
315
  }
318
316
  var diag = diagResult[0];
317
+ /** 通道徽章:优先用 host 的 proxyBadge/directBadge(人话+色),缺失时回退旧判定。 */
318
+ function channelBadge(name) {
319
+ var b = diag && typeof diag[name] === "object" && diag[name] !== null ? diag[name] : null;
320
+ if (b && b.text && b.tone) return b;
321
+ var fallback = name === "proxyBadge" ? diag?.proxy : diag?.direct;
322
+ if (!fallback) return { text: "未知", tone: "warn" };
323
+ if (fallback.verdict === "usable") return { text: "链路通 · 目标正常响应", tone: "ok" };
324
+ if (fallback.verdict === "degraded") return { text: "链路通 · 这次请求被目标拒绝", tone: "warn" };
325
+ if (fallback.verdict === "unusable") return { text: "链路不通 · 网络/代理问题", tone: "bad" };
326
+ return fallback.ok ? { text: "链路通", tone: "ok" } : { text: "链路不通", tone: "bad" };
327
+ }
319
328
 
320
329
  return react.createElement("div", { className: "dsxprx", "data-proxy-settings": true },
321
330
 
@@ -402,27 +411,29 @@ window.__ModuleLoader__.load({
402
411
  onChange: function (event) { setDiagUrl(event.target.value); },
403
412
  onKeyDown: function (event) { if (event.key === "Enter") runDiag(); },
404
413
  }),
405
- react.createElement("label", { className: "dsxprx-diagcheck" },
406
- react.createElement("input", {
407
- type: "checkbox",
408
- checked: diagUseProxy[0],
409
- onChange: function (event) { setDiagUseProxy(event.target.checked); },
410
- }),
411
- "使用代理"),
412
414
  react.createElement("button", { type: "button", className: "dsxprx-diagbtn", onClick: runDiag, disabled: diagBusy[0] || !diagUrl.trim() }, "测试路由")
413
415
  ),
414
- react.createElement("span", { className: "dsxprx-hint" }, "勾选「使用代理」= 强制走代理测通不通;不勾 = 强制直连测。对比可判断该域名该不该加进 NO_PROXY。"),
416
+ react.createElement("span", { className: "dsxprx-hint" }, "同时测「走代理」与「直连」两条通道并给出推荐,可判断该域名该不该加进 NO_PROXY。"),
415
417
  diag !== null
416
418
  ? react.createElement("div", { className: "dsxprx-diagout" },
417
419
  diag.error
418
420
  ? react.createElement("span", null, "诊断失败:" + diag.error)
419
421
  : [
420
- react.createElement("span", { key: "c" }, "测试通道: " + (diag.channel === "proxy" ? "经代理(强制)" : diag.channel === "direct" ? "直连(强制绕过)" : "按当前策略")),
421
422
  react.createElement("span", { key: "r" }, "策略判定: " + diag.route + (diag.bypassed ? "(NO_PROXY 命中)" : "")),
422
- react.createElement("span", { key: "p" }, "连通探测: " + probeHeadline(diag.probe)),
423
- diag.probe && diag.probe.why ? react.createElement("span", { key: "w", className: "dsxprx-diagwhy" }, "可能原因: " + diag.probe.why) : null,
424
- diag.probe && diag.probe.fix ? react.createElement("span", { key: "f", className: "dsxprx-diagfix" }, "挽救措施: " + diag.probe.fix) : null,
425
- diag.probe && diag.probe.note ? react.createElement("span", { key: "n", className: "dsxprx-diagnote" }, "备注: " + diag.probe.note) : null,
423
+ react.createElement("span", { key: "px", className: "dsxprx-diagprobe" },
424
+ react.createElement("span", { className: "dsxprx-diotag" }, "走代理"),
425
+ react.createElement("span", { className: "dsxprx-verdict", "data-tone": channelBadge("proxyBadge").tone }, channelBadge("proxyBadge").text)
426
+ ),
427
+ react.createElement("span", { key: "dx", className: "dsxprx-diagprobe" },
428
+ react.createElement("span", { className: "dsxprx-diotag" }, "直连"),
429
+ react.createElement("span", { className: "dsxprx-verdict", "data-tone": channelBadge("directBadge").tone }, channelBadge("directBadge").text)
430
+ ),
431
+ diag.verdict ? react.createElement("span", { key: "v", className: "dsxprx-diagverdict" },
432
+ react.createElement("span", { className: "dsxprx-verdictlarge" }, "推荐: " + (diag.short || "未知")),
433
+ diag.why ? react.createElement("span", { className: "dsxprx-diagwhy" }, "可能原因: " + diag.why) : null,
434
+ diag.fix ? react.createElement("span", { className: "dsxprx-diagfix" }, "挽救措施: " + diag.fix) : null
435
+ ) : null,
436
+ diag.proxy && diag.proxy.note ? react.createElement("span", { key: "n", className: "dsxprx-diagnote" }, "备注: " + diag.proxy.note) : null,
426
437
  diag.hint ? react.createElement("span", { key: "h" }, "提示: " + diag.hint) : null,
427
438
  ]
428
439
  )
package/lib/index.js CHANGED
@@ -32,6 +32,8 @@ import { promisify } from 'node:util'
32
32
  import {
33
33
  classifyProbeStatus,
34
34
  classifyTargetFailure,
35
+ combineDualProbe,
36
+ headNeedsGetRetry,
35
37
  keepaliveHint,
36
38
  makeSystemProxyReader,
37
39
  normalizeProxyUrl,
@@ -171,10 +173,12 @@ const PROBE_UA = 'dsh-proxy-pro/0.1'
171
173
  const PROBE_TIMEOUT_MS = 6000
172
174
 
173
175
  /**
174
- * 一个探测尝试:先 HEAD,端点不支持 HEAD(405/501)时再用 GET 复测——
175
- * 很多 API 只实现 GET,只用 HEAD 会冤枉一个可用主机。`request` 是调用方
176
- * 的 fetch:两条通道现在都用 app undici 的 `und.fetch` 配同副本 dispatcher
177
- * (LESSONS §22/§25 —— 绝不要把两份副本混在一起)。
176
+ * 一个探测尝试:先 HEAD,HEAD 结论不可信(404/405/501)时再用 GET 复测——
177
+ * 405/501 是「不支持该方法」,404 是网关不给 HEAD 注册路由时的伪装
178
+ * (LESSONS §27:DashScope `HEAD /models` → 404,同路径 GET → 401),只用
179
+ * HEAD 会把健康端点误判成「路径不存在」。`request` 是调用方的 fetch:两条
180
+ * 通道都用 app undici 的 `und.fetch` 配同副本 dispatcher(LESSONS §22/§25
181
+ * —— 绝不要把两份副本混在一起)。
178
182
  *
179
183
  * @returns classifyProbeStatus / classifyTargetFailure 的判定。
180
184
  */
@@ -197,13 +201,16 @@ async function probeHttp(request, target, dispatcher) {
197
201
  const head = await attempt('HEAD')
198
202
  if (head.error !== undefined) return classifyTargetFailure(head.error)
199
203
  const verdict = classifyProbeStatus(head.res.status)
200
- if (verdict.kind !== 'http-error' || (head.res.status !== 405 && head.res.status !== 501)) return verdict
204
+ if (verdict.kind !== 'http-error' || !headNeedsGetRetry(head.res.status)) return verdict
201
205
  const get = await attempt('GET')
202
206
  if (get.error !== undefined) return classifyTargetFailure(get.error)
203
207
  const retried = classifyProbeStatus(get.res.status)
204
- retried.note = retried.ok === true
205
- ? 'HEAD 被拒绝但 GET 可用 — 主机可用。'
206
- : 'HEAD 被拒绝;GET 复测也遇到同类问题。'
208
+ const sameStatus = get.res.status === head.res.status
209
+ retried.note = sameStatus
210
+ ? `HEAD 与 GET 均返回 ${get.res.status} — 该路径确实不可用。`
211
+ : retried.ok === true
212
+ ? `HEAD 返回 ${head.res.status},GET 得到 ${get.res.status} — 以 GET 为准(网关常不给 HEAD 注册路由)。`
213
+ : `HEAD 返回 ${head.res.status},GET 得到 ${get.res.status} — 以 GET 为准。`
207
214
  return retried
208
215
  }
209
216
 
@@ -261,18 +268,25 @@ async function policyProbe(target) {
261
268
  }
262
269
 
263
270
  /**
264
- * 按 web_fetch 的方式探测一个目标:先给路由判定,再用策略通道发真实请求
265
- * (app undici 的 fetch + 全局 dispatcher,见 policyProbe)。`channel` 选择
266
- * 探测路径:'policy'(默认)、'proxy'(强制走代理)、'direct'(强制绕过)。
271
+ * 按 web_fetch 的方式探测一个目标,**同时**强制走代理 + 强制直连两个通道,
272
+ * 再把两侧结果综合成一条人话推荐(combineDualProbe)。
273
+ *
274
+ * 单侧探测无法判断被拒是谁干的(目标 or 代理?)——只有两侧对比才能得出
275
+ * 方向性结论(直连通+代理失败 → 问题在代理一侧;代理通+直连失败 → 代理是
276
+ * 唯一通路)。`channel` 保留兼容:'policy'/'proxy'/'direct' 仍是旧的单通道
277
+ * 探测(HTTP API 的历史 useProxy 参数使用),默认 'dual' 走双通道并行。
267
278
  */
268
- async function probeTarget(url, snapshot, channel = 'policy') {
279
+ async function probeTarget(url, snapshot, channel = 'dual') {
269
280
  let parsed
270
281
  try {
271
282
  parsed = new URL(url)
272
283
  } catch {
273
- return { url, route: 'invalid', bypassed: false,
274
- probe: { ok: false, verdict: 'unusable', kind: 'invalid', short: 'URL 无效 — 需要 http(s)://主机/路径',
275
- why: '输入不是可解析的绝对 URL。', fix: '用完整形式,如 https://github.com/foo/bar。' } }
284
+ return { url, route: 'invalid', bypassed: false, channel,
285
+ proxy: { ok: false, verdict: 'unusable', kind: 'invalid', short: 'URL 无效 — 需要 http(s)://主机/路径',
286
+ why: '输入不是可解析的绝对 URL。', fix: '用完整形式,如 https://github.com/foo/bar。' },
287
+ direct: { ok: false, verdict: 'unusable', kind: 'invalid', short: 'URL 无效 — 需要 http(s)://主机/路径',
288
+ why: '输入不是可解析的绝对 URL。', fix: '用完整形式,如 https://github.com/foo/bar。' },
289
+ verdict: 'both-bad', short: 'URL 无效', why: '输入不是可解析的绝对 URL。', fix: '用完整形式,如 https://github.com/foo/bar。' }
276
290
  }
277
291
  let routeName = 'DIRECT'
278
292
  try {
@@ -286,6 +300,8 @@ async function probeTarget(url, snapshot, channel = 'policy') {
286
300
  const host = entry.trim().replace(/^\*?\./, '').toLowerCase()
287
301
  return host.length > 0 && (host === parsed.hostname.toLowerCase() || parsed.hostname.toLowerCase().endsWith(`.${host}`))
288
302
  })
303
+
304
+ // 兼容旧单通道调用(HTTP API 历史 useProxy 参数)
289
305
  if (channel === 'proxy' || channel === 'direct') {
290
306
  const proxyUrl = channel === 'proxy' ? (snapshot?.effective?.url ?? '') : ''
291
307
  const probe = proxyUrl === '' && channel === 'proxy'
@@ -295,8 +311,27 @@ async function probeTarget(url, snapshot, channel = 'policy') {
295
311
  : await forceChannelProbe(parsed, channel, proxyUrl)
296
312
  return { url, route: routeName, bypassed, channel, probe }
297
313
  }
298
- const probe = await policyProbe(parsed)
299
- return { url, route: routeName, bypassed, probe }
314
+ if (channel === 'policy') {
315
+ const probe = await policyProbe(parsed)
316
+ return { url, route: routeName, bypassed, channel, probe }
317
+ }
318
+
319
+ // 双通道并行:强制走代理 + 强制直连,各自独立探测(互不干扰)
320
+ const proxyUrl = snapshot?.effective?.url ?? ''
321
+ const proxyProbe = proxyUrl === ''
322
+ ? { ok: false, verdict: 'unusable', kind: 'bad-proxy', short: '当前快照里没有可用的代理地址',
323
+ why: '代理开关处于关闭(或 Windows 系统代理关闭),没有可强制走代理的对象。',
324
+ fix: '打开代理,或在「代理配置」里修复地址/模式。' }
325
+ : await forceChannelProbe(parsed, 'proxy', proxyUrl)
326
+ const directProbe = await forceChannelProbe(parsed, 'direct', '')
327
+ const combined = combineDualProbe(proxyProbe, directProbe)
328
+ return {
329
+ url, route: routeName, bypassed, channel: 'dual',
330
+ proxy: proxyProbe, direct: directProbe,
331
+ verdict: combined.verdict, short: combined.short,
332
+ why: combined.why, fix: combined.fix,
333
+ proxyBadge: combined.proxyBadge, directBadge: combined.directBadge,
334
+ }
300
335
  }
301
336
 
302
337
  // ---- browser-trust fence (mirrors dsh's /api fence) ------------------------
@@ -526,18 +561,16 @@ function apply(ctx, config) {
526
561
  output: {
527
562
  schema: PROXY_TEST_SCHEMA,
528
563
  render: (_args, value) => {
529
- const p = value.probe ?? {}
530
- const head = p.verdict === 'usable'
531
- ? `可用${p.status === undefined ? '' : `(HTTP ${p.status})`}`
532
- : `${p.verdict === 'degraded' ? '降级' : '不可用'} — ${p.short ?? '未知'}`
564
+ const badge = (b) => (b?.tone === 'ok' ? '🟢' : b?.tone === 'warn' ? '🟡' : '🔴') + ' ' + (b?.text ?? '未知')
533
565
  const lines = [
534
566
  `路由: ${value.route}`,
535
567
  `NO_PROXY 命中: ${value.bypassed}`,
536
- `连通探测: ${head}`,
568
+ `走代理: ${badge(value.proxyBadge)}`,
569
+ `直连: ${badge(value.directBadge)}`,
570
+ `结论: ${value.short ?? '未知'}`,
537
571
  ]
538
- if (p.why) lines.push(`可能原因: ${p.why}`)
539
- if (p.fix) lines.push(`挽救措施: ${p.fix}`)
540
- if (p.note) lines.push(`备注: ${p.note}`)
572
+ if (value.why) lines.push(`可能原因: ${value.why}`)
573
+ if (value.fix) lines.push(`挽救措施: ${value.fix}`)
541
574
  if (value.hint) lines.push(`提示: ${value.hint}`)
542
575
  return [{ type: 'text', text: lines.join('\n') }]
543
576
  },
@@ -545,7 +578,7 @@ function apply(ctx, config) {
545
578
  execute: async (args) => {
546
579
  await requestSync()
547
580
  const result = await probeTarget(String(args.url ?? ''), snapshot)
548
- result.hint = keepaliveHint(result.probe?.short ?? '', snapshot?.effective?.url ?? '')
581
+ result.hint = keepaliveHint(`${result.proxy?.short ?? ''} ${result.direct?.short ?? ''}`, snapshot?.effective?.url ?? '')
549
582
  return result
550
583
  },
551
584
  presentCall: (args) => ({
@@ -662,10 +695,14 @@ function apply(ctx, config) {
662
695
  }
663
696
  if (is('/route', 'GET')) {
664
697
  const target = url.searchParams.get('url') ?? ''
698
+ // useProxy 保留兼容:'1'/'0' 走旧的单通道强制;无参数(新版面板)走双通道
665
699
  const useProxy = url.searchParams.get('useProxy')
666
- const channel = useProxy === '1' ? 'proxy' : useProxy === '0' ? 'direct' : 'policy'
700
+ const channel = useProxy === '1' ? 'proxy' : useProxy === '0' ? 'direct' : 'dual'
667
701
  const result = await probeTarget(target, snapshot, channel)
668
- result.hint = keepaliveHint(result.probe?.short ?? '', snapshot?.effective?.url ?? '')
702
+ result.hint = keepaliveHint(
703
+ `${result.proxy?.short ?? ''} ${result.direct?.short ?? ''}`.trim(),
704
+ snapshot?.effective?.url ?? ''
705
+ )
669
706
  sendJson(res, 200, { ok: true, ...result })
670
707
  return
671
708
  }
package/lib/proxy-core.js CHANGED
@@ -282,6 +282,23 @@ export function keepaliveHint(message = '', proxyUrl = '') {
282
282
  * @param status - 探测收到的 HTTP 状态码。
283
283
  * @returns `{ ok, verdict, kind, status, short, why, fix }`(中文文案)。
284
284
  */
285
+ /**
286
+ * HEAD 结论不可信、必须换 GET 复测的状态码。
287
+ *
288
+ * 405/501 是服务器明说「不支持该方法」;**404 同样是陷阱**:很多网关不给
289
+ * HEAD 注册路由,对 HEAD 一律回 404,而同一路径 GET 会正常回 401/200 ——
290
+ * 实测 DashScope:`HEAD /compatible-mode/v1/models` → 404,同路径 GET → 401
291
+ * (2026-09-18 用户报「测什么都 404」时定位)。不复测就会把健康端点误判成
292
+ * 「路径不存在」。
293
+ *
294
+ * @param status - HEAD 探测收到的状态码。
295
+ * @returns 是否需要用 GET 复测。
296
+ */
297
+ export function headNeedsGetRetry(status) {
298
+ const code = Number(status)
299
+ return code === 404 || code === 405 || code === 501
300
+ }
301
+
285
302
  export function classifyProbeStatus(status) {
286
303
  const code = Number(status)
287
304
  const base = { status: code }
@@ -305,8 +322,8 @@ export function classifyProbeStatus(status) {
305
322
  }
306
323
  if (code === 404) {
307
324
  return { ...base, ok: false, verdict: 'degraded', kind: 'http-error', short: 'HTTP 404 — 路径不存在',
308
- why: '路由已到服务器,但该路径不存在(探测常打根路径)。',
309
- fix: '用真实调用的完整路径复测;能通就忽略此结果。' }
325
+ why: 'HEAD 与 GET 都拿到了 404:路由已到服务器,但该路径确实没有资源(探测常打根路径或裸前缀)。',
326
+ fix: '改用真实调用的完整端点复测(如 .../models、.../chat/completions);那里有响应就说明主机可用。' }
310
327
  }
311
328
  if (code === 405 || code === 501) {
312
329
  return { ...base, ok: false, verdict: 'degraded', kind: 'http-error', short: `HTTP ${code} — 端点不支持 HEAD`,
@@ -399,3 +416,82 @@ export function classifyTargetFailure(error) {
399
416
  why: '请求失败,探测器无法归类原因。',
400
417
  fix: '对比直连/代理两侧并重试;用 proxy_status 查看生效路由。' }
401
418
  }
419
+
420
+ /**
421
+ * 双通道综合判定:把「强制走代理」与「强制直连」两次探测合成为一份可给人
422
+ * 看的结果:两条徽章(各自带 tone 色)+ 一条推荐。
423
+ *
424
+ * 单侧探针拿不到应答者身份,因此「代理策略拦截」只能靠两侧对比得到方向性
425
+ * 结论(LESSONS §30:直连通+代理失败 → 问题在代理一侧;代理通+直连失败 →
426
+ * 代理是唯一通路;都失败 → 环境/服务端问题;都通 → 保持现状)。
427
+ *
428
+ * @param proxyProbe - 强制走代理通道的探测结果(classify 输出形态)。
429
+ * @param directProbe - 强制直连通道的探测结果。
430
+ * @returns `{ verdict, short, why, fix, proxyBadge, directBadge }`,
431
+ * badge = { text, tone: 'ok'|'warn'|'bad' }。
432
+ */
433
+ export function combineDualProbe(proxyProbe, directProbe) {
434
+ const proxyOk = proxyProbe?.ok === true
435
+ const directOk = directProbe?.ok === true
436
+ const proxyAuth = proxyProbe?.kind === 'auth'
437
+ || (proxyProbe?.verdict === 'unusable' && /NTLM|407/.test(String(proxyProbe?.short ?? '')))
438
+
439
+ const notReachable = (probe, label) => {
440
+ const detail = (probe?.short ?? '未知原因').replace(/^HTTP \d+ — /, '')
441
+ return `${label}:${detail}`
442
+ }
443
+
444
+ // 都通 → 都可以
445
+ if (proxyOk && directOk) {
446
+ return {
447
+ verdict: 'both-ok',
448
+ short: '代理与直连均通',
449
+ why: '两条通道都拿到了正常应答,目标本身可用。',
450
+ fix: '保持当前策略即可;无需改 NO_PROXY。',
451
+ proxyBadge: { text: '链路通 · 目标正常响应', tone: 'ok' },
452
+ directBadge: { text: '链路通 · 目标正常响应', tone: 'ok' },
453
+ }
454
+ }
455
+
456
+ // 只有代理通 → 走代理
457
+ if (proxyOk && !directOk) {
458
+ return {
459
+ verdict: 'proxy-only',
460
+ short: '只有走代理能通',
461
+ why: `直连 ${notReachable(directProbe, '失败')};走代理正常——该目标在当前网络需经代理访问。`,
462
+ fix: '确认该域名不在 NO_PROXY 直连名单里;保持代理开启。',
463
+ proxyBadge: { text: '链路通 · 目标正常响应', tone: 'ok' },
464
+ directBadge: { text: '链路不通 · 直连失败', tone: 'bad' },
465
+ }
466
+ }
467
+
468
+ // 只有直连通 → 问题在代理一侧(可能是策略拦截/认证/代理故障)
469
+ if (!proxyOk && directOk) {
470
+ const base = proxyAuth
471
+ ? {
472
+ why: '代理要求 NTLM 认证(407),而直连正常——该目标不值得绕代理。',
473
+ fix: '该域名走代理会被认证卡住:把它加入 NO_PROXY 直连名单即可;无需打开认证窗口。',
474
+ }
475
+ : {
476
+ why: '经代理失败而直连正常——问题最可能在代理一侧:访问策略拦截、代理故障,或代理到目标之间的链路异常。',
477
+ fix: '先重试排除临时故障;仍失败则检查代理可用性或联系管理员。若确认是策略拦截,把该域名加入 NO_PROXY 直连名单。',
478
+ }
479
+ return {
480
+ verdict: proxyAuth ? 'direct-only-auth' : 'direct-only',
481
+ short: proxyAuth ? '直连可通 · 代理需认证' : '直连可通 · 代理被拦',
482
+ ...base,
483
+ proxyBadge: { text: proxyAuth ? '链路不通 · 代理要求认证' : '链路不通 · 经代理访问失败', tone: 'bad' },
484
+ directBadge: { text: '链路通 · 目标正常响应', tone: 'ok' },
485
+ }
486
+ }
487
+
488
+ // 都失败 → 环境/服务端问题
489
+ return {
490
+ verdict: 'both-bad',
491
+ short: '代理与直连均失败',
492
+ why: `两条通道都没拿到正常应答:代理 ${notReachable(proxyProbe, '失败')};直连 ${notReachable(directProbe, '失败')}。`,
493
+ fix: '是环境或服务端问题,与代理开关无关:检查网络/目标服务,或稍后重试。',
494
+ proxyBadge: { text: `链路不通 · ${notReachable(proxyProbe, '代理失败')}`, tone: 'bad' },
495
+ directBadge: { text: `链路不通 · ${notReachable(directProbe, '直连失败')}`, tone: 'bad' },
496
+ }
497
+ }
@@ -54,7 +54,28 @@ export const PROXY_TEST_PARAMETERS = {
54
54
  },
55
55
  }
56
56
 
57
- /** Output schema of `proxy_test`. */
57
+ /** Reusable per-channel probe result (a classifyProbeStatus / classifyTargetFailure
58
+ * output). Schema DSL has no $ref, so this inlines into PROXY_TEST_SCHEMA. */
59
+ const PROBE_RESULT_SCHEMA = {
60
+ type: 'object',
61
+ additionalProperties: false,
62
+ required: true,
63
+ properties: {
64
+ ok: { type: 'boolean', required: true },
65
+ verdict: { type: 'string' },
66
+ status: { type: 'number' },
67
+ kind: { type: 'string' },
68
+ short: { type: 'string' },
69
+ why: { type: 'string' },
70
+ fix: { type: 'string' },
71
+ note: { type: 'string' },
72
+ },
73
+ }
74
+
75
+ /** Output schema of `proxy_test`. Dual-channel: forces both legs in parallel
76
+ * (走代理 / 直连), then combines them into one human verdict + recommendation
77
+ * (2026-09-18: single-side probes cannot tell WHO answered, so a strategy
78
+ * block can only be inferred by comparing both sides — LESSONS §30). */
58
79
  export const PROXY_TEST_SCHEMA = {
59
80
  type: 'object',
60
81
  additionalProperties: false,
@@ -62,25 +83,13 @@ export const PROXY_TEST_SCHEMA = {
62
83
  url: { type: 'string', required: true },
63
84
  route: { type: 'string', required: true },
64
85
  bypassed: { type: 'boolean', required: true },
65
- probe: {
66
- type: 'object',
67
- additionalProperties: false,
68
- required: true,
69
- properties: {
70
- ok: { type: 'boolean', required: true },
71
- // Three-state verdict plus the actionable pair the panel and the tool
72
- // render (2026-09-17: a 504 used to print as "reachable"). Every field
73
- // must be listed here — the object is additionalProperties:false, so an
74
- // undeclared field fails schema validation.
75
- verdict: { type: 'string' },
76
- status: { type: 'number' },
77
- kind: { type: 'string' },
78
- short: { type: 'string' },
79
- why: { type: 'string' },
80
- fix: { type: 'string' },
81
- note: { type: 'string' },
82
- },
83
- },
86
+ channel: { type: 'string', required: true },
87
+ proxy: PROBE_RESULT_SCHEMA,
88
+ direct: PROBE_RESULT_SCHEMA,
89
+ verdict: { type: 'string', required: true },
90
+ short: { type: 'string', required: true },
91
+ why: { type: 'string', required: true },
92
+ fix: { type: 'string', required: true },
84
93
  hint: { type: 'string' },
85
94
  },
86
95
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yanglaofish/dsh-proxy-pro",
3
- "version": "1.0.6",
3
+ "version": "1.0.8",
4
4
  "description": "DSH proxy manager: route model requests, web fetches and spawned tools through the Windows system proxy or a custom address; per-URL route diagnosis, one-click toggle from the conversation header, a dedicated Settings page, and keepalive-aware 407 hints.",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",