@mrrisega/dsh-remote 0.6.11 → 0.6.12

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.
Files changed (44) hide show
  1. package/dsh-setup.mjs +55 -15
  2. package/package.json +3 -3
  3. package/packages/dsh-remote-web/lib/client.js +137 -0
  4. package/packages/dsh-remote-web/lib/fix-prompt.js +160 -0
  5. package/packages/dsh-remote-web/lib/index.js +748 -18
  6. package/packages/dsh-remote-web/lib/offline-update.mjs +66 -0
  7. package/packages/dsh-remote-web/lib/tarball-update.js +442 -0
  8. package/packages/dsh-remote-web/package.json +5 -1
  9. package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +1451 -0
  10. package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-events.mjs +1991 -0
  11. package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-client.mjs +728 -0
  12. package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-shim-script.js +767 -0
  13. package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-shim.mjs +163 -0
  14. package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +1575 -0
  15. package/packages/dsh-remote-web/runtime/clients/dsh-remote/package.json +11 -0
  16. package/packages/dsh-remote-web/runtime/clients/dsh-remote/src/lifecycle.mjs +45 -0
  17. package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-channel.mjs +2825 -0
  18. package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-runtime.mjs +1984 -0
  19. package/packages/dsh-remote-web/runtime/clients/dsh-web/native.html +2526 -0
  20. package/packages/dsh-remote-web/runtime/dsh-setup.mjs +2390 -0
  21. package/packages/dsh-remote-web/runtime/node_modules/ws/LICENSE +20 -0
  22. package/packages/dsh-remote-web/runtime/node_modules/ws/README.md +548 -0
  23. package/packages/dsh-remote-web/runtime/node_modules/ws/browser.js +8 -0
  24. package/packages/dsh-remote-web/runtime/node_modules/ws/index.js +22 -0
  25. package/packages/dsh-remote-web/runtime/node_modules/ws/lib/buffer-util.js +131 -0
  26. package/packages/dsh-remote-web/runtime/node_modules/ws/lib/constants.js +19 -0
  27. package/packages/dsh-remote-web/runtime/node_modules/ws/lib/event-target.js +292 -0
  28. package/packages/dsh-remote-web/runtime/node_modules/ws/lib/extension.js +203 -0
  29. package/packages/dsh-remote-web/runtime/node_modules/ws/lib/limiter.js +55 -0
  30. package/packages/dsh-remote-web/runtime/node_modules/ws/lib/permessage-deflate.js +530 -0
  31. package/packages/dsh-remote-web/runtime/node_modules/ws/lib/receiver.js +743 -0
  32. package/packages/dsh-remote-web/runtime/node_modules/ws/lib/sender.js +607 -0
  33. package/packages/dsh-remote-web/runtime/node_modules/ws/lib/stream.js +161 -0
  34. package/packages/dsh-remote-web/runtime/node_modules/ws/lib/subprotocol.js +62 -0
  35. package/packages/dsh-remote-web/runtime/node_modules/ws/lib/validation.js +152 -0
  36. package/packages/dsh-remote-web/runtime/node_modules/ws/lib/websocket-server.js +562 -0
  37. package/packages/dsh-remote-web/runtime/node_modules/ws/lib/websocket.js +1407 -0
  38. package/packages/dsh-remote-web/runtime/node_modules/ws/package.json +74 -0
  39. package/packages/dsh-remote-web/runtime/node_modules/ws/wrapper.mjs +21 -0
  40. package/packages/dsh-remote-web/test/connect-stuck-visibility.test.mjs +396 -0
  41. package/packages/dsh-remote-web/test/diag-report.test.mjs +236 -0
  42. package/packages/dsh-remote-web/test/fix-prompt.test.mjs +170 -0
  43. package/packages/dsh-remote-web/test/runtime-bundle.test.mjs +198 -0
  44. package/packages/dsh-remote-web/test/tarball-update.test.mjs +460 -0
package/dsh-setup.mjs CHANGED
@@ -88,8 +88,13 @@ function findDepWs(startDir) {
88
88
  return null;
89
89
  }
90
90
 
91
- /** 同步运行时文件时一并覆盖的客户端脚本(bridge 会把这些直接发给手机/桌面,必须与装置器一致)。 */
92
- const RUNTIME_CLIENT_FILES = ["mobile-adapter.mjs", "dsh-bridge.mjs", "e2ee-shim.mjs", "e2ee-client.mjs", "e2ee-shim-script.js", "mobile-adapter.test.mjs", "wechat-channel.mjs", "dsh-events.mjs", "wechat-runtime.mjs"];
91
+ /**
92
+ * 同步运行时文件时一并覆盖的客户端脚本(bridge 会把这些直接发给手机/桌面,必须与装置器一致)。
93
+ * ⚠️ `src/lifecycle.mjs` 也在表里:本安装器**自己**import 它(进程优雅停机的共用实现),
94
+ * 漏同步就会出现「入口脚本在、依赖不在」的半装运行时(插件半据此判定「运行环境已就绪」,
95
+ * 却永远起不来 bridge —— 2026-09-23 用户反馈的现场)。
96
+ */
97
+ const RUNTIME_CLIENT_FILES = ["mobile-adapter.mjs", "dsh-bridge.mjs", "e2ee-shim.mjs", "e2ee-client.mjs", "e2ee-shim-script.js", "mobile-adapter.test.mjs", "wechat-channel.mjs", "dsh-events.mjs", "wechat-runtime.mjs", "src/lifecycle.mjs"];
93
98
 
94
99
  /**
95
100
  * 把"客户端脚本"补齐到配置目录。
@@ -769,7 +774,39 @@ function restartBridgeService() {
769
774
  }
770
775
 
771
776
  /**
772
- * dsh web 是否正在运行(127.0.0.1:3080)。
777
+ * 上游 dsh web 地址(bridge 的转发目标 + watcher 的「dsh web 在线吗」判据**同一个来源**)。
778
+ *
779
+ * 【2026-09-23 用户实测事故】这里原本处处写死 `http://127.0.0.1:3080`:只要用户的 dsh web
780
+ * 不在默认端口(`dsh web --port 8090`、profile 里配了 port、或 `--port 0` 让系统分配空闲端口),
781
+ * watcher 的在线探测就**永远**失败 → bridge 永远不启动,而面板只会说「正在启动 Bridge…」。
782
+ * 优先级:显式环境变量 > 插件半留下的端口文件 > DSH_WEB_URL > 默认 3080。
783
+ * 为什么要有「端口文件」这一层:开机自启(登录任务 / launchd)拉起 watcher 时**没有**任何环境变量,
784
+ * 只有插件半知道 dsh web 真实监听在哪个端口,所以它会把地址落盘(见插件 half 的 publishUpstream)。
785
+ */
786
+ const UPSTREAM_FILE = ".dsh-upstream";
787
+ function upstreamUrl() {
788
+ const explicit = String(process.env.DSH_BRIDGE_UPSTREAM || "").trim();
789
+ if (explicit) return explicit.replace(/\/+$/, "");
790
+ try {
791
+ const fromFile = String(fs.readFileSync(path.join(CONFIG_DIR, UPSTREAM_FILE), "utf8")).trim();
792
+ if (/^https?:\/\/[^\s]+$/i.test(fromFile)) return fromFile.replace(/\/+$/, "");
793
+ } catch { /* 没写过(旧版/首次)→ 往下回退 */ }
794
+ const web = String(process.env.DSH_WEB_URL || "").trim();
795
+ if (web) {
796
+ try {
797
+ const u = new URL(web);
798
+ if (u.port) return `http://127.0.0.1:${u.port}`; // bridge 的 loopback 围栏只认 127.0.0.1
799
+ } catch { /* 非 URL:忽略 */ }
800
+ }
801
+ return "http://127.0.0.1:3080";
802
+ }
803
+ /** 上游端口(诊断/日志用;取不到就回退 3080)。 */
804
+ function upstreamPort() {
805
+ try { return Number(new URL(upstreamUrl()).port) || 3080; } catch { return 3080; }
806
+ }
807
+
808
+ /**
809
+ * dsh web 是否正在运行(默认 127.0.0.1:3080,端口以 upstreamUrl() 为准)。
773
810
  * bridge 本身依赖 dsh web 才工作:dsh web 没开时 bridge 起来也会立刻退出,
774
811
  * 所以「装完当下 bridge 没在跑」是**正常状态**,不能当失败吓用户(见 install 汇总)。
775
812
  */
@@ -777,7 +814,7 @@ async function isDshWebUp(timeoutMs = 1200) {
777
814
  try {
778
815
  const ac = new AbortController();
779
816
  const timer = setTimeout(() => ac.abort(), timeoutMs);
780
- await fetch("http://127.0.0.1:3080/", { signal: ac.signal });
817
+ await fetch(upstreamUrl() + "/", { signal: ac.signal });
781
818
  clearTimeout(timer);
782
819
  return true;
783
820
  } catch { return false; }
@@ -794,13 +831,14 @@ function resolveProfileDir(argv = []) {
794
831
  }
795
832
 
796
833
  /**
797
- * 监听 127.0.0.1:3080 的进程 pid(= dsh web)。
834
+ * 监听本机 dsh web 端口的进程 pid(= dsh web)。端口取 upstreamPort()(不再写死 3080)。
798
835
  * macOS/Linux:lsof → pgrep;Windows:netstat -ano 取 LISTENING 行的最后一列
799
836
  * (netstat 是系统自带,比每次都起 PowerShell 快得多)。
800
837
  */
801
838
  function dshWebPid() {
839
+ const port = upstreamPort();
802
840
  if (IS_WIN) {
803
- const r = sh('netstat -ano | findstr ":3080"');
841
+ const r = sh(`netstat -ano | findstr ":${port}"`);
804
842
  for (const line of String(r.stdout || "").split("\n")) {
805
843
  if (!/LISTENING/i.test(line)) continue;
806
844
  const m = line.trim().match(/(\d+)\s*$/);
@@ -808,7 +846,7 @@ function dshWebPid() {
808
846
  }
809
847
  return null;
810
848
  }
811
- const l = sh("lsof -nP -iTCP:3080 -sTCP:LISTEN -t 2>/dev/null || true");
849
+ const l = sh(`lsof -nP -iTCP:${port} -sTCP:LISTEN -t 2>/dev/null || true`);
812
850
  const pid = String(l.stdout || "").trim().split(/\s+/).filter(Boolean)[0];
813
851
  if (pid && /^\d+$/.test(pid)) return Number(pid);
814
852
  const p = sh("pgrep -f 'dsh web' 2>/dev/null | head -1");
@@ -884,7 +922,7 @@ function restartDshWeb() {
884
922
  */
885
923
  function restartDshWebWindows() {
886
924
  const pid = dshWebPid();
887
- if (!pid) return { ok: false, how: "", detail: "未找到监听 3080 的进程(dsh web 没在运行?)" };
925
+ if (!pid) return { ok: false, how: "", detail: `未找到监听 ${upstreamPort()} 的进程(dsh web 没在运行?)` };
888
926
  const cmdline = windowsProcessCommandLine(pid);
889
927
  if (!cmdline) return { ok: false, how: "", detail: "无法获取 dsh web 的启动命令行,请手动重启" };
890
928
  if (/_npx|node_modules\\\.bin|node_modules\/\.bin/.test(cmdline)) {
@@ -1087,15 +1125,15 @@ async function runBridge() {
1087
1125
  /** 限流提示只打一次,避免刷屏。 */
1088
1126
  let warnedRateLimit = false;
1089
1127
 
1090
- // watcher:检测 dsh web(127.0.0.1:3080)存活,存活才启动 bridge
1128
+ // watcher:检测 dsh web 是否存活(端口以 upstreamUrl() 为准,绝不写死 3080),存活才启动 bridge
1091
1129
  const checkUpstream = () => new Promise((resolve) => {
1092
1130
  const t = setTimeout(() => resolve(false), 2000);
1093
- fetch("http://127.0.0.1:3080/")
1131
+ fetch(upstreamUrl() + "/")
1094
1132
  .then(() => { clearTimeout(t); resolve(true); })
1095
1133
  .catch(() => { clearTimeout(t); resolve(false); });
1096
1134
  });
1097
1135
 
1098
- console.log("[dsh-remote] 等待 dsh web(127.0.0.1:3080)启动...");
1136
+ console.log(`[dsh-remote] 等待 dsh web(${upstreamUrl()})启动...`);
1099
1137
  let bridgeProc = null;
1100
1138
  let starting = false;
1101
1139
 
@@ -1158,6 +1196,8 @@ async function runBridge() {
1158
1196
  ...process.env,
1159
1197
  DSH_BRIDGE_CONFIG: CONFIG_PATH,
1160
1198
  DSH_BRIDGE_TUNNEL_URL: cfg.tunnel_url,
1199
+ // 上游端口显式传给 bridge:它自己在旧版本里也回退到写死的 3080(见 dsh-bridge.mjs 的 UPSTREAM)。
1200
+ DSH_BRIDGE_UPSTREAM: upstreamUrl(),
1161
1201
  // 安装口径透传给 bridge(bridge 把它随设备登记请求一起上报给账号 API)
1162
1202
  DSH_BRIDGE_INSTALL_SOURCE: INSTALL_SOURCE,
1163
1203
  DSH_BRIDGE_INSTALL_VERSION: INSTALL_VERSION,
@@ -1335,7 +1375,7 @@ async function setup(argv) {
1335
1375
  fs.writeFileSync(patchPath, cur);
1336
1376
  } catch { /* 忽略:轮询会给出结论 */ }
1337
1377
  for (let i = 0; i < 45; i += 1) {
1338
- const r = await fetch("http://127.0.0.1:3080/dsh-remote/self", { signal: AbortSignal.timeout(900) })
1378
+ const r = await fetch(upstreamUrl() + "/dsh-remote/self", { signal: AbortSignal.timeout(900) })
1339
1379
  .catch(() => null);
1340
1380
  if (r && r.ok) {
1341
1381
  const j = await r.json().catch(() => null);
@@ -1448,7 +1488,7 @@ async function setup(argv) {
1448
1488
  L.push(" 再打开 设置 → 「远程访问」→ 注册/登录手机号即可。");
1449
1489
  } else if (needRestart && restartResult && restartResult.ok) {
1450
1490
  L.push("");
1451
- L.push(" 下一步: dsh web 已重启,直接打开 http://127.0.0.1:3080 → 设置 → 「远程访问」→ 注册/登录手机号。");
1491
+ L.push(" 下一步: dsh web 已重启,直接打开 " + upstreamUrl() + " → 设置 → 「远程访问」→ 注册/登录手机号。");
1452
1492
  } else if (needRestart && pluginResult && pluginResult.hotPatch && pluginResult.changed) {
1453
1493
  // patch 已写入但探测窗口内没等到面板接口:热加载很可能还在进行(插件节点半启动+注册路由)。
1454
1494
  // 这种情况**不能**直接让用户重启 —— 先让他等几秒刷新,再给兜底方案。
@@ -1466,8 +1506,8 @@ async function setup(argv) {
1466
1506
  L.push("⚠️ 还需要重启一次 dsh web,插件面板才会出现(插件只在进程启动时装载)。");
1467
1507
  L.push(" 请手动执行(任选其一):");
1468
1508
  L.push(" launchctl kickstart -k gui/$(id -u)/com.dshweb.dev # 用 launchd 托管时");
1469
- L.push(" kill $(lsof -ti tcp:3080 -sTCP:LISTEN) && dsh web --no-open # 手动启动时");
1470
- L.push(" 重启后:打开 http://127.0.0.1:3080 → 设置 → 「远程访问」→ 注册/登录手机号。");
1509
+ L.push(` kill $(lsof -ti tcp:${upstreamPort()} -sTCP:LISTEN) && dsh web --no-open # 手动启动时`);
1510
+ L.push(` 重启后:打开 ${upstreamUrl()} → 设置 → 「远程访问」→ 注册/登录手机号。`);
1471
1511
  } else {
1472
1512
  L.push("");
1473
1513
  L.push(" 下一步: 打开 dsh web → 设置 → 「远程访问」→ 注册/登录手机号即可(无需任何命令)。");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrrisega/dsh-remote",
3
- "version": "0.6.11",
3
+ "version": "0.6.12",
4
4
  "description": "手机远程控制 DeepSeek Harness · Remote control DeepSeek Harness (dsh web) from any phone browser — 100% 全功能 App 级体验:发消息、看工具执行、审批权限、改设置、管凭据,含特权操作,免内网穿透。一条命令安装 npx @mrrisega/dsh-remote。Mobile remote control for DSH, self-host or SaaS, no server needed on LAN.",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -52,8 +52,8 @@
52
52
  "scripts": {
53
53
  "test": "npm run test:router && npm run test:plugin && npm run test:bridge",
54
54
  "test:router": "NODE_OPTIONS=\"--require $PWD/scripts/test-net-guard.cjs ${NODE_OPTIONS:-}\" node --test packages/relay-router/test/native-device-selection.test.mjs packages/relay-router/test/native-app-entry.test.mjs packages/relay-router/test/presence-report.test.mjs packages/relay-router/test/logger.test.mjs",
55
- "_comment:test:plugin": "DSH_RELAY_SKIP_SERVICE=1 = 测试隔离:插件加载时不做真实系统操作(npx 补装 / launchctl / 重启 harness);需要真实分支的用例自行 unset(见 account-switch / runtime-provision / harness-restart 用例注释)。DSH_RELAY_DEFAULT_API= 死端口:用例拆掉临时目录后,仍在飞的后台请求(装机上报重试等)会读到空配置并回落「默认云端地址」—— 指到本地死端口才不会打生产。",
56
- "test:plugin": "DSH_REMOTE_TELEMETRY=0 DSH_RELAY_SKIP_SERVICE=1 DSH_RELAY_DEFAULT_API=http://127.0.0.1:1 NODE_OPTIONS=\"--require $PWD/scripts/test-net-guard.cjs ${NODE_OPTIONS:-}\" node --test --test-concurrency=1 packages/dsh-remote-web/test/*.test.mjs",
55
+ "_comment:test:plugin": "DSH_RELAY_BUNDLED_RUNTIME=/nonexistent = 强制走 npx 兜底分支(否则本机跑过 npm pack 后 packages/dsh-remote-web/runtime 存在,用例会改成走本地拷贝)。DSH_RELAY_SKIP_SERVICE=1 = 测试隔离:插件加载时不做真实系统操作(npx 补装 / launchctl / 重启 harness);需要真实分支的用例自行 unset(见 account-switch / runtime-provision / harness-restart 用例注释)。DSH_RELAY_DEFAULT_API= 死端口:用例拆掉临时目录后,仍在飞的后台请求(装机上报重试等)会读到空配置并回落「默认云端地址」—— 指到本地死端口才不会打生产。",
56
+ "test:plugin": "DSH_REMOTE_TELEMETRY=0 DSH_RELAY_SKIP_SERVICE=1 DSH_RELAY_DEFAULT_API=http://127.0.0.1:1 DSH_RELAY_BUNDLED_RUNTIME=/nonexistent-dsh-bundled NODE_OPTIONS=\"--require $PWD/scripts/test-net-guard.cjs ${NODE_OPTIONS:-}\" node --test --test-concurrency=1 packages/dsh-remote-web/test/*.test.mjs",
57
57
  "_comment:test:bridge": "⚠️ 必须 --test-concurrency=1(与 test:plugin 对齐):tunnel-auth 有一条「bridge 5 秒内必须重连」的硬期限,并行跑时新增用例抢 CPU 会把它撞破 → 偶发假红(实测单独跑 2/2 通过、并发跑偶发 0/2)。假红比慢更贵。",
58
58
  "test:bridge": "DSH_RELAY_DEFAULT_API=http://127.0.0.1:1 NODE_OPTIONS=\"--require $PWD/scripts/test-net-guard.cjs ${NODE_OPTIONS:-}\" node --test --test-concurrency=1 clients/dsh-remote/test/*.test.mjs",
59
59
  "check": "node --check scripts/check-profile-activation.mjs && node --check clients/dsh-remote/dsh-bridge.mjs && node --check clients/dsh-remote/wechat-channel.mjs && node --check clients/dsh-remote/wechat-runtime.mjs && node --check clients/dsh-remote/dsh-events.mjs && node --check clients/dsh-remote/e2ee-shim.mjs && node --check dsh-setup.mjs && node --check packages/dsh-remote-web/lib/index.js && node --check packages/dsh-remote-web/lib/client.js && node --check packages/relay-router/src/index.mjs && node --check packages/relay-router/src/logger.mjs && bash -n deploy/install-open.sh && grep -q 'command -v setsid' deploy/install-open.sh && grep -q 'command -v ss' deploy/install-open.sh",
@@ -2103,6 +2103,16 @@ window.__ModuleLoader__.load({
2103
2103
  var mktFoldArr = useState(mktMoreWasFolded() || inviteTipWasDismissed()); var mktFolded = mktFoldArr[0]; var setMktFolded = mktFoldArr[1];
2104
2104
  // 「更多」这一层当前是否展开:纯界面态,不持久化(下次进面板回到收起,主界面保持干净)。
2105
2105
  var moreOpenArr = useState(false); var moreOpen = moreOpenArr[0]; var setMoreOpen = moreOpenArr[1];
2106
+ // 🧾 故障上报 / 修复提示词(0.6.12):全部 hook 追加在末尾,不动既有 hook 序号。
2107
+ // diagView: null=不展开 | "report"=上报(预览+确认) | "fix"=让 DSH 自己修(提示词+回传)
2108
+ var diagViewArr = useState(null); var diagView = diagViewArr[0]; var setDiagView = diagViewArr[1];
2109
+ var diagBundleArr = useState(null); var diagBundle = diagBundleArr[0]; var setDiagBundle = diagBundleArr[1];
2110
+ var diagNoteArr = useState(""); var diagNote = diagNoteArr[0]; var setDiagNote = diagNoteArr[1];
2111
+ var diagAttachArr = useState(true); var diagAttachLogs = diagAttachArr[0]; var setDiagAttachLogs = diagAttachArr[1];
2112
+ var diagBusyArr = useState(""); var diagBusy = diagBusyArr[0]; var setDiagBusy = diagBusyArr[1];
2113
+ var diagMsgArr = useState(null); var diagMsg = diagMsgArr[0]; var setDiagMsg = diagMsgArr[1];
2114
+ var fixPromptArr = useState(null); var fixPrompt = fixPromptArr[0]; var setFixPrompt = fixPromptArr[1];
2115
+ var fixReportArr = useState(""); var fixReport = fixReportArr[0]; var setFixReport = fixReportArr[1];
2106
2116
 
2107
2117
  var refresh = useCallback(function () {
2108
2118
  setBusy("status");
@@ -2589,6 +2599,67 @@ window.__ModuleLoader__.load({
2589
2599
  });
2590
2600
  };
2591
2601
 
2602
+ // ── 🧾 故障上报(用户主动、可预览、可编辑)与「让 DeepSeek 帮我修」(0.6.12) ──
2603
+ // 产品原则(2026-09-23 定的):**绝不**后台自动上传日志。开发者要看得见故障现场,
2604
+ // 但必须由用户自己点、并且能看见"将要发出去的每一个字"。于是:
2605
+ // ① 上报道:预览全文(正文+日志分片)→ 用户可写描述、可取消附带日志 → 确认才发;
2606
+ // ② 修复道:给用户一段提示词,让他本机的 DSH 自己修并产出结论报告,再把报告回传。
2607
+ // 这两条把"客服来回问"变成"一次到位",而且不需要我们发版。
2608
+
2609
+ /** 打开「上报故障」:先拉预览(= 确认后真正会发出去的全文)。 */
2610
+ var openDiagReport = function () {
2611
+ setDiagView("report"); setDiagMsg(null); setDiagBundle(null);
2612
+ setDiagBusy("bundle");
2613
+ api("/dsh-remote/diag/bundle").then(function (b) {
2614
+ if (b && b.ok) setDiagBundle(b);
2615
+ else setDiagMsg({ kind: "err", text: "读取诊断信息失败,请稍后重试。" });
2616
+ }).catch(function (e) {
2617
+ setDiagMsg({ kind: "err", text: "读取诊断信息失败:" + e.message });
2618
+ }).finally(function () { setDiagBusy(""); });
2619
+ };
2620
+
2621
+ /** 提交上报:正文(含用户描述)+ 日志分片,全部走既有反馈通道。 */
2622
+ var submitDiagReport = function (extra) {
2623
+ var body = {
2624
+ note: extra && extra.note !== undefined ? extra.note : diagNote,
2625
+ attachLogs: diagAttachLogs,
2626
+ report: (extra && extra.report) || "",
2627
+ };
2628
+ setDiagBusy("report"); setDiagMsg(null);
2629
+ post("/dsh-remote/diag/report", body).then(function (b) {
2630
+ if (b && b.ok) {
2631
+ var warn = (b.warnings && b.warnings.length) ? "(" + b.warnings.join(";") + ")" : "";
2632
+ setDiagMsg({ kind: "ok", text: "✅ 已上报给开发者(编号 " + (b.id || "?") + ")" + warn + "。可在「用户反馈 → 我的反馈」里看到后续回复。" });
2633
+ setFixReport(""); setDiagNote("");
2634
+ } else {
2635
+ setDiagMsg({ kind: "err", text: (b && b.error) || "上报失败,请稍后重试。" });
2636
+ }
2637
+ }).catch(function (e) {
2638
+ setDiagMsg({ kind: "err", text: "上报失败:" + e.message });
2639
+ }).finally(function () { setDiagBusy(""); });
2640
+ };
2641
+
2642
+ /** 打开「让 DeepSeek 帮我修」:拉提示词(含当前故障上下文)。 */
2643
+ var openFixPrompt = function () {
2644
+ setDiagView("fix"); setDiagMsg(null); setFixPrompt(null);
2645
+ setDiagBusy("prompt");
2646
+ api("/dsh-remote/diag/fix-prompt").then(function (b) {
2647
+ if (b && b.ok && b.prompt) setFixPrompt(b);
2648
+ else setDiagMsg({ kind: "err", text: "读取修复提示词失败,请稍后重试。" });
2649
+ }).catch(function (e) {
2650
+ setDiagMsg({ kind: "err", text: "读取修复提示词失败:" + e.message });
2651
+ }).finally(function () { setDiagBusy(""); });
2652
+ };
2653
+
2654
+ var copyFixPrompt = function () {
2655
+ var text = (fixPrompt && fixPrompt.prompt) || "";
2656
+ if (!text) return;
2657
+ copyText(text).then(function (done) {
2658
+ if (done) setDiagMsg({ kind: "ok", text: "✅ 提示词已复制。粘到 DeepSeek Harness 里发送,它会自己检查并修复;跑完把它的结论报告贴回下面。" });
2659
+ else setDiagMsg({ kind: "warn", text: "复制失败(浏览器可能限制了剪贴板):可手动选中下面的文本复制。" });
2660
+ });
2661
+ };
2662
+
2592
2663
  function setMsg(kind, text) { setMessage({ kind: kind, text: text }); }
2593
2664
 
2594
2665
  // ---------- 登录(密码/短信) ----------
@@ -3652,6 +3723,68 @@ window.__ModuleLoader__.load({
3652
3723
  * 面向非技术用户——非 online 阶段一律说明「正在自动进行,无需操作」;
3653
3724
  * 只有失败(error)才给可操作项:重试 / 复制诊断信息 / 日志路径,绝不出现死胡同。
3654
3725
  */
3726
+ /**
3727
+ * 🧾 故障上报 / 让 DeepSeek 帮我修(0.6.12)
3728
+ * 产品原则:绝不后台自动上传日志 —— 这里全部由用户主动触发,且**先把要发的内容摊开给他看**。
3729
+ */
3730
+ function renderDiagTools() {
3731
+ var rows = [];
3732
+ if (diagMsg) {
3733
+ rows.push(h("div", { className: "dru-msg" + (diagMsg.kind ? " dru-msg-" + diagMsg.kind : ""), style: { marginTop: 6 } }, diagMsg.text));
3734
+ }
3735
+ if (diagView === null) {
3736
+ rows.push(h("div", { className: "dru-actions", style: { marginTop: 8 } },
3737
+ h("button", { type: "button", className: "dru-btn dru-btn-ghost", disabled: busy !== "" || diagBusy !== "", onClick: openDiagReport },
3738
+ diagBusy === "bundle" ? "准备中…" : "🧾 上报故障给开发者"),
3739
+ h("button", { type: "button", className: "dru-btn dru-btn-ghost", disabled: busy !== "" || diagBusy !== "", onClick: openFixPrompt },
3740
+ diagBusy === "prompt" ? "准备中…" : "🛠 让 DeepSeek 帮我修")));
3741
+ rows.push(h("div", { className: "dru-hint", style: { marginTop: 4 } },
3742
+ "「上报」会先让你看到将要发送的全部内容(可写描述、可取消附带日志),你确认后才发给我们;"
3743
+ + "「帮我修」会给你一段提示词,让电脑上的 DeepSeek Harness 自己排查并修复。"));
3744
+ return h("div", null, rows);
3745
+ }
3746
+ if (diagView === "report") {
3747
+ var preview = diagBundle ? (diagBundle.summary + (diagBundle.parts && diagBundle.parts.length ? "\n\n" + diagBundle.parts.join("\n\n") : "")) : "";
3748
+ rows.push(h("div", { className: "dru-hint", style: { marginTop: 6 } },
3749
+ "下面就是**将要发送**的全部内容(可先改成你愿意分享的样子;手机号与密钥已自动隐去):"));
3750
+ rows.push(h("pre", {
3751
+ style: { margin: "6px 0 0", padding: "8px 10px", maxHeight: 220, overflow: "auto", whiteSpace: "pre-wrap", wordBreak: "break-all", fontSize: 12, lineHeight: 1.5, background: "var(--dru-surface-2)", border: "1px solid var(--dru-border-ctl)", borderRadius: 8 },
3752
+ }, diagBusy === "bundle" ? "正在准备…" : (preview || "(暂无内容)")));
3753
+ rows.push(h("textarea", {
3754
+ className: "dru-fb-textarea", style: { marginTop: 6 }, maxLength: 600, value: diagNote,
3755
+ placeholder: "用一句话说说你遇到的问题(可选,但很有帮助)",
3756
+ onChange: function (e) { setDiagNote(e.target.value); },
3757
+ }));
3758
+ rows.push(h("label", { className: "dru-hint", style: { display: "block", marginTop: 6, cursor: "pointer" } },
3759
+ h("input", { type: "checkbox", checked: diagAttachLogs, onChange: function (e) { setDiagAttachLogs(e.target.checked); } }),
3760
+ " 附带两个日志的末尾(不含手机号/密码/令牌)"));
3761
+ rows.push(h("div", { className: "dru-actions", style: { marginTop: 8 } },
3762
+ h("button", { type: "button", className: "dru-btn dru-btn-primary", disabled: diagBusy !== "" || !diagBundle, onClick: function () { submitDiagReport(); } },
3763
+ diagBusy === "report" ? "上报中…" : "确认上报"),
3764
+ h("button", { type: "button", className: "dru-btn dru-btn-ghost", disabled: diagBusy !== "", onClick: function () { setDiagView(null); setDiagMsg(null); } }, "取消")));
3765
+ return h("div", null, rows);
3766
+ }
3767
+ // fix:复制提示词 → 本机 DSH 自己修 → 把结论报告贴回来(可选回传给我们)
3768
+ var promptText = (fixPrompt && fixPrompt.prompt) || "";
3769
+ rows.push(h("div", { className: "dru-hint", style: { marginTop: 6 } },
3770
+ "① 复制下面的提示词;② 粘到电脑上的 DeepSeek Harness 里发送,它会自己检查并修复;③ 把它给出的结论报告贴回下面,回传给我们(可选)。"));
3771
+ rows.push(h("pre", {
3772
+ style: { margin: "6px 0 0", padding: "8px 10px", maxHeight: 200, overflow: "auto", whiteSpace: "pre-wrap", wordBreak: "break-word", fontSize: 12, lineHeight: 1.5, background: "var(--dru-surface-2)", border: "1px solid var(--dru-border-ctl)", borderRadius: 8 },
3773
+ }, diagBusy === "prompt" ? "正在准备…" : (promptText || "(暂无内容)")));
3774
+ rows.push(h("div", { className: "dru-actions", style: { marginTop: 6 } },
3775
+ h("button", { type: "button", className: "dru-btn dru-btn-ghost", disabled: !promptText, onClick: copyFixPrompt }, "📋 复制提示词")));
3776
+ rows.push(h("textarea", {
3777
+ className: "dru-fb-textarea", style: { marginTop: 6 }, maxLength: 20000, value: fixReport,
3778
+ placeholder: "把 DeepSeek Harness 给出的「结论报告」整段贴到这里,我们一起看",
3779
+ onChange: function (e) { setFixReport(e.target.value); },
3780
+ }));
3781
+ rows.push(h("div", { className: "dru-actions", style: { marginTop: 8 } },
3782
+ h("button", { type: "button", className: "dru-btn dru-btn-primary", disabled: diagBusy !== "" || !fixReport.trim(), onClick: function () { submitDiagReport({ report: fixReport, note: diagNote || "(来自「让 DeepSeek 帮我修」的回传报告)" }); } },
3783
+ diagBusy === "report" ? "回传中…" : "回传结论报告"),
3784
+ h("button", { type: "button", className: "dru-btn dru-btn-ghost", disabled: diagBusy !== "", onClick: function () { setDiagView(null); setDiagMsg(null); } }, "取消")));
3785
+ return h("div", null, rows);
3786
+ }
3787
+
3655
3788
  function renderConnectBlock() {
3656
3789
  if (!loggedInSaaS || !connInfo) return null;
3657
3790
  var phase = connInfo.phase || "";
@@ -3675,6 +3808,8 @@ window.__ModuleLoader__.load({
3675
3808
  h("button", { type: "button", className: "dru-btn dru-btn-ghost", onClick: copyDiagnostics }, copiedDiag ? "已复制" : "复制诊断信息")));
3676
3809
  rows.push(h("div", { className: "dru-hint", style: { marginTop: 6 } },
3677
3810
  "查看日志:后台服务 " + (connInfo.logPath || "") + " ,安装 " + (connInfo.installLogPath || "") + "(把「复制诊断信息」的内容发给客服可加速定位)"));
3811
+ var diagToolsErr = renderDiagTools();
3812
+ if (diagToolsErr) rows.push(h("div", { style: { marginTop: 8, borderTop: "1px dashed var(--dru-border-ctl)", paddingTop: 8 } }, diagToolsErr));
3678
3813
  return h("div", null, rows);
3679
3814
  }
3680
3815
  // 非错误阶段:只解释「正在自动做什么」,并说明不需要任何操作
@@ -3697,6 +3832,8 @@ window.__ModuleLoader__.load({
3697
3832
  rows.push(h("div", { className: "dru-actions", style: { marginTop: 8 } },
3698
3833
  h("button", { type: "button", className: "dru-btn dru-btn-ghost", disabled: busy !== "", onClick: retryConnect }, busy === "connect-retry" ? "立即重试中…" : "立即重试"),
3699
3834
  h("button", { type: "button", className: "dru-btn dru-btn-ghost", onClick: copyDiagnostics }, copiedDiag ? "已复制" : "复制诊断信息")));
3835
+ var diagTools = renderDiagTools();
3836
+ if (diagTools) rows.push(h("div", { style: { marginTop: 8, borderTop: "1px dashed var(--dru-border-ctl)", paddingTop: 8 } }, diagTools));
3700
3837
  return h("div", null, rows);
3701
3838
  }
3702
3839
  // Phase-5:端到端加密(E2EE)状态行 —— 未登录/旧 host 未下发 e2ee 一律不渲染
@@ -0,0 +1,160 @@
1
+ /**
2
+ * 「连接卡住」一键修复提示词 + 固定格式结论报告(纯文本,中文)。
3
+ *
4
+ * 为什么要有这段提示词(真实事故,2026-09-23,Windows 用户):
5
+ * 用户电脑上插件面板一直停在「连接阶段: starting(正在启动 Bridge…)」,手机端看不到任何设备,
6
+ * 反复重装都没用。事后定位到三种机制,它们在面板上**长得一模一样**,光看面板诊断分不开:
7
+ * ① 半装运行环境:~/.dsh-remote/dsh-setup.mjs 在,但它 import 的
8
+ * clients/dsh-remote/src/lifecycle.mjs 不在 → 拉起守护进程时秒退(Cannot find module);
9
+ * ② 守护(watcher,`node <relayDir>/dsh-setup.mjs run`)活着却一直没拉起 bridge,
10
+ * 最常见原因是上游 dsh web 不在默认端口 3080(代码里曾写死 3080);
11
+ * ③ bridge 子进程起来就崩(缺 ws、端口冲突等),崩因只写在 ~/.dsh-remote/.dsh-bridge.log。
12
+ * 这三种机制都只能在用户**本机的真实环境**里取证。所以产品决定:面板给用户一段可一键复制的
13
+ * 提示词,让他在自己电脑的 DeepSeek Harness 里跑,由本机 agent 读日志、验环境、查端口、
14
+ * 重启守护、验证结果,并产出一份固定格式的报告回传给我们。
15
+ *
16
+ * 本文件只产出文本,不做任何 IO;面板侧「复制修复提示词」直接用 buildFixPrompt(ctx)。
17
+ */
18
+
19
+ /** 默认运行环境目录(与 lib/index.js 的 DEFAULT_RELAY_DIR 一致;可被 DSH_RELAY_DIR 覆盖)。 */
20
+ export const DEFAULT_RELAY_DIR = "~/.dsh-remote";
21
+ /** 默认上游端口(与 index.js 的 FALLBACK_UPSTREAM 一致:仅在读不到实际监听端口时使用)。 */
22
+ export const DEFAULT_UPSTREAM_PORT = 3080;
23
+ /** 相对 relayDir 的固定文件名(与 index.js / dsh-bridge.mjs 保持一致)。 */
24
+ export const BRIDGE_LOG_FILE = ".dsh-bridge.log";
25
+ export const INSTALL_LOG_FILE = ".dsh-setup-install.log";
26
+ export const BRIDGE_STATE_FILE = ".dsh-bridge-state.json";
27
+ export const UPSTREAM_FILE = ".dsh-upstream";
28
+
29
+ /** 报告第一行(固定标题,机器解析靠它定位)。 */
30
+ export const REPORT_TITLE = "=== dsh-remote 诊断结论 ===";
31
+
32
+ /** 报告必须出现的六个字段(顺序即报告里的顺序)。 */
33
+ export const REPORT_KEYS = ["症状", "机制判定", "关键证据", "已做的修复", "修复结果", "仍需人工处理"];
34
+
35
+ /**
36
+ * 结论报告模板。首行固定为 REPORT_TITLE,随后是六个字段;「机制判定」要求四选一,
37
+ * 「修复结果」要求明确写「已修复」或「未修复」。
38
+ */
39
+ export const REPORT_FORMAT = `${REPORT_TITLE}
40
+ 症状: <一句话说清现象,例如「面板停在 starting(正在启动 Bridge…),手机端无任何设备」>
41
+ 机制判定: ①半装运行环境 | ②守护活着没起作用 | ③bridge 起来就崩 | 其它 —— 四选一,并写一句理由
42
+ 关键证据: <日志/命令输出的原文(手机号等凭据已掩码),每条一行;确实没有时写明「文件不存在」或「末尾 N 行无错误」>
43
+ 已做的修复: <按顺序列出你实际执行的命令与改动;没动过任何东西就写「未做修改」>
44
+ 修复结果: 已修复 | 未修复(必须明确写其中之一)—— 再补一句验证依据
45
+ 仍需人工处理: <需要用户或我们继续做的事;没有就写「无」>`;
46
+
47
+ /**
48
+ * 提示词模板:`{{字段名}}` 是占位符,由 buildFixPrompt(ctx) 替换(缺省时用默认值)。
49
+ * 模板里同时写出关键路径的**默认值**,即使占位符没被替换也能看懂。
50
+ */
51
+ export const FIX_PROMPT = `你是这台电脑上的 DeepSeek Harness 本机 agent。用户用 dsh-remote 插件从手机远程控制这台电脑上的 dsh web;现在插件面板卡在「连接阶段: {{phaseText}}」,手机端看不到任何设备。请在**本机**按下面 6 步顺序诊断并修复,**每一步都要自己验证**(真的跑命令、看输出)之后再进入下一步,不要跳步、不要凭猜下结论。
52
+
53
+ 【现场信息】(面板已上报;下面的缺省值已在括号里写明)
54
+ - 插件版本: {{pluginVersion}}
55
+ - 运行环境版本: {{runtimeVersion}}
56
+ - 运行环境目录 relayDir: {{relayDir}}(默认 ~/.dsh-remote,可被环境变量 DSH_RELAY_DIR 覆盖)
57
+ - 面板当前阶段: {{phaseText}}
58
+ - 上游 dsh web 地址: {{upstreamUrl}}
59
+ - bridge 日志: {{bridgeLogPath}}
60
+ - 安装日志: {{installLogPath}}
61
+ - 状态文件: {{statePath}}(默认 ~/.dsh-remote/.dsh-bridge-state.json;字段 device_id / phase)
62
+ - 上游端口文件: {{upstreamFilePath}}(默认 ~/.dsh-remote/.dsh-upstream)
63
+ - 两个日志的默认位置(上面的路径读不到时先看这里): ~/.dsh-remote/.dsh-bridge.log 、 ~/.dsh-remote/.dsh-setup-install.log
64
+
65
+ 第 1 步 · 读两个日志的末尾,摘出真实报错原文
66
+ - 读 {{bridgeLogPath}} 与 {{installLogPath}} 的最后 80 行(Windows: \`powershell -Command "Get-Content -Tail 80 '<路径>'"\`;macOS/Linux: \`tail -n 80 '<路径>'\`)。
67
+ - 把**真实报错原文**摘进报告:Cannot find module、EADDRINUSE、ECONNREFUSED、缺 ws、退出码等。日志为空时要写明「文件不存在」或「末尾 N 行无错误」,**不要只写「无错误」**。
68
+ - 验证: 确认你读到的最后一行日志的时间,与面板卡住的时间对得上。
69
+
70
+ 第 2 步 · 检查运行环境是否完整(半装 = ① 号机制)
71
+ - 逐个确认存在: {{relayDir}}/dsh-setup.mjs 、 {{relayDir}}/clients/dsh-remote/src/lifecycle.mjs 、 {{relayDir}}/clients/dsh-remote/dsh-bridge.mjs 、 {{relayDir}}/node_modules/ws 。
72
+ (Windows: \`dir\` 或 \`Test-Path\`;macOS/Linux: \`ls -l\`)
73
+ - 任一缺失就是「半装运行环境」,用一键安装器重新补装: \`npx @mrrisega/dsh-remote\`(网络受限时用 \`npx @mrrisega/dsh-remote@latest\`)。
74
+ - 验证: 补装后上述 4 个路径必须全部存在;再跑 \`node --check "{{relayDir}}/clients/dsh-remote/src/lifecycle.mjs"\` 与 \`node --check "{{relayDir}}/clients/dsh-remote/dsh-bridge.mjs"\`,语法检查必须无输出。
75
+
76
+ 第 3 步 · 核对上游端口(② 号机制最常见的成因)
77
+ - 先查这台电脑上 dsh web 到底监听哪个端口: Windows \`netstat -ano | findstr LISTENING\`(再按 PID 找出 dsh/node 那行);macOS/Linux \`lsof -nP -iTCP -sTCP:LISTEN\`;也可以直接看启动 dsh web 的命令行里有没有 \`--port\`。
78
+ - 再读 {{upstreamFilePath}}(文件不存在就按默认 {{defaultUpstreamUrl}},即默认端口 3080 判断)。
79
+ - 两者不一致(例如实际监听 8090,而文件/默认是 3080)→ 按**实际端口**修正: 把正确地址写进 {{upstreamFilePath}}(形如 http://127.0.0.1:8090),或给守护显式设置 DSH_BRIDGE_UPSTREAM,然后重启 bridge。
80
+ - 验证: 确认 bridge 日志里不再出现连不上上游的报错,且 bridge 进程真的活下来。
81
+
82
+ 第 4 步 · 检查守护进程是否在跑、是不是「活着但没起作用」
83
+ - 找到守护进程(命令行形如 \`node {{relayDir}}/dsh-setup.mjs run\`): Windows \`wmic process where "name='node.exe'" get ProcessId,CommandLine\` 或任务管理器;macOS/Linux \`ps aux | grep dsh-setup.mjs\`。
84
+ - 「活着但没起作用」的判据: 守护进程在,但 bridge 进程没被拉起,且 {{bridgeLogPath}} 长时间没有新行。命中就**先结束卡死的守护,再重新拉起**:
85
+ Windows \`taskkill /PID <pid> /T /F\`(/T 连子进程一起收);macOS/Linux \`kill <pid>\`,不退出再用 \`kill -9 <pid>\`。
86
+ - 重新拉起: \`node "{{relayDir}}/dsh-setup.mjs run"\`(后台方式运行,别阻塞你自己的会话);装了自启动服务的也可以重启那个服务。
87
+ - 验证: 确认守护进程确实在以新 PID 运行,并且它把 bridge 拉起来了。
88
+
89
+ 第 5 步 · 验证结果(必须如实)
90
+ - 读 {{statePath}}: 出现 \`device_id\` 且 \`phase\` 为 \`online\` 就算通了;若还是 connecting / bound / error,把 \`last_error\` 原文摘出来。
91
+ - 同时看面板是否从「正在启动 Bridge…」变成「已连接 ✅」(手机上能看到本机设备)。
92
+ - 如实说明是否成功: **没修好也要照实写**,并把你判断的下一个可疑点写进「仍需人工处理」。
93
+ - 验证: 上面两项(状态文件字段 + 面板文案)都要真的看过;只看进程在就宣布成功不算。
94
+
95
+ 第 6 步 · 输出结论报告(固定格式,我们会机器解析)
96
+ 把下面模板**原样**放进一个 \`\`\` 围栏里(Markdown 代码块),作为你回复的最后一段;围栏内第一行必须是 ${REPORT_TITLE},六个字段一个都不能少:
97
+
98
+ \`\`\`
99
+ ${REPORT_FORMAT}
100
+ \`\`\`
101
+
102
+ - 「机制判定」只能四选一,并给出理由:
103
+ · ① 半装运行环境 —— {{relayDir}}/dsh-setup.mjs 在,但 clients/... 里的文件(尤其 clients/dsh-remote/src/lifecycle.mjs)缺失,或日志里出现 \`Cannot find module\` 且守护一拉起就秒退;
104
+ · ② 守护活着没起作用 —— 守护进程在跑、bridge 却从未被拉起,且端口核对发现 dsh web 的实际监听端口与 {{upstreamFilePath}}(或默认 3080)不一致;
105
+ · ③ bridge 起来就崩 —— bridge 进程确实起来过又退出,且 {{bridgeLogPath}} 里有明确崩因(缺 ws、EADDRINUSE 端口冲突等);
106
+ · 三条都对不上时才写「其它」,并把你的证据写清楚。
107
+ - 「修复结果」必须明确写 \`已修复\` 或 \`未修复\`(二者之一),后面补一句验证依据。
108
+ - 验证: 自己核对一遍——围栏内第一行是标题、六个字段一个不少、没有任何凭据与未脱敏的手机号。
109
+
110
+ 【安全与隐私红线(必须遵守)】
111
+ 1. 报告里**不得**出现手机号、密码、token、JWT、bridge_secret、cookie、访问密钥等任何凭据;日志摘录里的手机号必须掩码,例如 138****0000,token / 密钥一律写成 \`<已隐去>\`。
112
+ 2. **不要删除** {{relayDir}} 目录(默认 ~/.dsh-remote)——删了会丢账号与设备身份;也不要为了「重来一次」卸载自启动服务,除非用户明确要求。
113
+ 3. 只做本机修复与取证: 不要把用户的文件内容、会话内容、业务数据贴进报告,报告只写与本次连接问题有关的命令、日志原文(脱敏后)与结论。
114
+ 4. 改任何配置前先看原值(要能改回去);不要执行 rm -rf、格式化磁盘等破坏性操作。`;
115
+
116
+ /** 提示词里使用的占位符: {{字段名}}(双花括号,避免与 JSON/shell 里的单花括号冲突)。 */
117
+ const TOKEN_RE = /\{\{\s*(\w+)\s*\}\}/g;
118
+
119
+ /** 展示用路径拼接: 统一用正斜杠(Windows 的反斜杠放在提示词里更难读)。 */
120
+ function displayJoin(dir, name) {
121
+ return String(dir).replace(/[\\/]+$/, "") + "/" + name;
122
+ }
123
+
124
+ /** 取一个「有内容的字符串」;空白、非字符串一律当缺省处理(避免 undefined / null 漏进文案)。 */
125
+ function text(value) {
126
+ return typeof value === "string" && value.trim() ? value.trim() : "";
127
+ }
128
+
129
+ /**
130
+ * 把 ctx 归一化成占位符表: 缺省字段一律回退到可读的默认文案,
131
+ * 且日志/状态/端口文件路径都能从 relayDir 推导出来(所以给一个 relayDir 就够用)。
132
+ */
133
+ function contextValues(ctx) {
134
+ const src = ctx && typeof ctx === "object" ? ctx : {};
135
+ const relayDir = text(src.relayDir) || DEFAULT_RELAY_DIR;
136
+ const defaultUpstreamUrl = `http://127.0.0.1:${DEFAULT_UPSTREAM_PORT}`;
137
+ return {
138
+ relayDir,
139
+ bridgeLogPath: text(src.bridgeLogPath) || displayJoin(relayDir, BRIDGE_LOG_FILE),
140
+ installLogPath: text(src.installLogPath) || displayJoin(relayDir, INSTALL_LOG_FILE),
141
+ statePath: displayJoin(relayDir, BRIDGE_STATE_FILE),
142
+ upstreamFilePath: displayJoin(relayDir, UPSTREAM_FILE),
143
+ pluginVersion: text(src.pluginVersion) || "未知(面板未上报)",
144
+ runtimeVersion: text(src.runtimeVersion) || "未知(未读到 .dsh-setup-version)",
145
+ upstreamUrl: text(src.upstreamUrl) || `${defaultUpstreamUrl}(默认端口 ${DEFAULT_UPSTREAM_PORT},需按第 3 步核实)`,
146
+ phaseText: text(src.phaseText) || "starting(正在启动 Bridge…)",
147
+ defaultUpstreamUrl,
148
+ };
149
+ }
150
+
151
+ /**
152
+ * 用 ctx 渲染完整提示词。
153
+ * @param {object} [ctx] { relayDir, bridgeLogPath, installLogPath, pluginVersion, runtimeVersion, upstreamUrl, phaseText }
154
+ * @returns {string} 可直接复制给用户本机 agent 的提示词全文
155
+ */
156
+ export function buildFixPrompt(ctx = {}) {
157
+ const values = contextValues(ctx);
158
+ // 未识别的占位符原样保留(宁可留下 {{x}} 也不写出 "undefined")
159
+ return FIX_PROMPT.replace(TOKEN_RE, (raw, key) => (key in values ? String(values[key]) : raw));
160
+ }