panrouter 3.7.0 → 4.1.0

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 (4) hide show
  1. package/cli.mjs +157 -181
  2. package/package.json +1 -1
  3. package/server.mjs +63 -87
  4. package/tray-daemon.ps1 +129 -28
package/cli.mjs CHANGED
@@ -10,7 +10,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
10
10
  const HOME = process.env.USERPROFILE || process.env.HOME;
11
11
  const CLAUDE_DIR = path.join(HOME, ".claude");
12
12
  const SETTINGS_PATH = path.join(CLAUDE_DIR, "settings.json");
13
- const BACKUP_PATH = path.join(CLAUDE_DIR, "settings.json.panrouter.backup");
13
+ const VERSION = "3.7.0";
14
14
 
15
15
  function log(label, msg, color = "") {
16
16
  const colors = { green: "\x1b[32m", yellow: "\x1b[33m", red: "\x1b[31m", cyan: "\x1b[36m", reset: "\x1b[0m" };
@@ -18,13 +18,28 @@ function log(label, msg, color = "") {
18
18
  console.log(`${c}[${label}]${colors.reset} ${msg}`);
19
19
  }
20
20
 
21
- // ─── 配置 ────────────────────────────────────────────────
21
+ function installClaudeCode() {
22
+ log("..", "正在检查 Claude Code...", "yellow");
23
+ try {
24
+ execSync("claude --version", { stdio: "pipe" });
25
+ log("OK", "Claude Code 已就绪", "green");
26
+ return true;
27
+ } catch {
28
+ log("..", "正在安装 Claude Code...", "yellow");
29
+ try {
30
+ execSync("npm install -g @anthropic-ai/claude-code", { stdio: "inherit" });
31
+ log("OK", "Claude Code 安装成功", "green");
32
+ return true;
33
+ } catch {
34
+ log("!!", "Claude Code 安装失败", "red");
35
+ return false;
36
+ }
37
+ }
38
+ }
22
39
 
23
40
  function writeConfig() {
24
41
  log("..", "正在配置 Claude Code 路由...", "yellow");
25
42
  if (!fs.existsSync(CLAUDE_DIR)) fs.mkdirSync(CLAUDE_DIR, { recursive: true });
26
- if (fs.existsSync(SETTINGS_PATH)) fs.copyFileSync(SETTINGS_PATH, BACKUP_PATH);
27
-
28
43
  const config = {
29
44
  env: {
30
45
  ANTHROPIC_BASE_URL: "http://127.0.0.1:50816",
@@ -39,27 +54,6 @@ function writeConfig() {
39
54
  log("OK", "配置完成", "green");
40
55
  }
41
56
 
42
- // ─── 安装 Claude Code ────────────────────────────────────
43
-
44
- function ensureClaudeCode() {
45
- try {
46
- execSync("claude --version", { stdio: "pipe" });
47
- return true; // 已装
48
- } catch {
49
- log("..", "正在安装 Claude Code...", "yellow");
50
- try {
51
- execSync("npm install -g @anthropic-ai/claude-code", { stdio: "inherit", timeout: 120000 });
52
- log("OK", "Claude Code 安装成功", "green");
53
- return true;
54
- } catch {
55
- log("!!", "Claude Code 安装失败", "red");
56
- return false;
57
- }
58
- }
59
- }
60
-
61
- // ─── 端口检测 ────────────────────────────────────────────
62
-
63
57
  async function isPortOpen() {
64
58
  return new Promise(rs => {
65
59
  const req = http.get("http://127.0.0.1:50816/health", () => {});
@@ -69,205 +63,187 @@ async function isPortOpen() {
69
63
  });
70
64
  }
71
65
 
72
- // ─── 启动前台服务 ────────────────────────────────────────
66
+ function stopAll() {
67
+ log("..", "正在停止所有 Pan Router 进程...", "yellow");
68
+ try {
69
+ if (process.platform === "win32") {
70
+ execSync('wmic process where "name=\'node.exe\' and CommandLine like \'%server.mjs%\'" call terminate >nul 2>&1', { stdio: "pipe" });
71
+ execSync('wmic process where "name=\'powershell.exe\' and CommandLine like \'%tray-daemon.ps1%\'" call terminate >nul 2>&1', { stdio: "pipe" });
72
+ }
73
+ log("OK", "已停止所有进程", "green");
74
+ } catch (e) {
75
+ log("!!", "停止进程时遇到问题", "red");
76
+ }
77
+ }
78
+
79
+ function showStatus() {
80
+ console.log(`\n\x1b[36m=== Pan Router 状态 ===\x1b[0m\n`);
81
+ try {
82
+ const nodeOut = execSync('wmic process where "name=\'node.exe\' and CommandLine like \'%server.mjs%\'" get ProcessId 2>nul').toString();
83
+ const psOut = execSync('wmic process where "name=\'powershell.exe\' and CommandLine like \'%tray-daemon.ps1%\'" get ProcessId 2>nul').toString();
84
+
85
+ const nodePids = nodeOut.match(/\d+/g) || [];
86
+ const psPids = psOut.match(/\d+/g) || [];
87
+
88
+ if (nodePids.length > 0) log("OK", `代理服务 (Node): 运行中 [PID: ${nodePids.join(', ')}]`, "green");
89
+ else log("!!", "代理服务 (Node): 未运行", "red");
90
+
91
+ if (psPids.length > 0) log("OK", `系统托盘 (PowerShell): 运行中 [PID: ${psPids.join(', ')}]`, "green");
92
+ else log("!!", "系统托盘 (PowerShell): 未运行", "red");
93
+ } catch (e) {
94
+ log("!!", "无法获取状态", "red");
95
+ }
96
+ console.log("");
97
+ }
98
+
99
+ function openLogs() {
100
+ const logFile = path.join(process.env.TEMP, "panrouter_tray.log");
101
+ if (fs.existsSync(logFile)) {
102
+ execSync(`start notepad "${logFile}"`);
103
+ log("OK", "日志已在记事本中打开", "green");
104
+ } else {
105
+ log("!!", "暂无托盘日志文件", "red");
106
+ }
107
+ }
73
108
 
74
109
  async function startServer() {
75
110
  const serverPath = path.join(__dirname, "server.mjs");
76
- try {
77
- execSync('taskkill /f /fi "WINDOWTITLE eq Pan Router*" >nul 2>&1', { stdio: "pipe" });
78
- } catch {}
111
+ stopAll();
79
112
 
80
113
  log("..", "正在启动代理...", "yellow");
81
- execSync(`start "Pan Router" cmd /c "node ${serverPath} & pause"`, { stdio: "pipe" });
114
+ if (process.platform === "win32") {
115
+ execSync(`start "Pan Router" cmd /c "node ${serverPath} & pause"`, { stdio: "pipe" });
116
+ } else {
117
+ spawn("node", [serverPath], { cwd: __dirname, stdio: "ignore", detached: true }).unref();
118
+ }
82
119
 
83
120
  for (let i = 0; i < 15; i++) {
84
121
  if (await isPortOpen()) break;
85
122
  await new Promise(rs => setTimeout(rs, 1000));
86
123
  }
87
- log("OK", "Pan Router 运行中", "green");
124
+ log("OK", "Pan Router 运行中,可以执行 claude 命令了", "green");
88
125
  }
89
126
 
90
- // ─── 启动托盘(后台) ────────────────────────────────────
91
-
92
127
  async function startTray() {
93
128
  const serverPath = path.join(__dirname, "server.mjs");
94
129
  const psPath = path.join(__dirname, "tray-daemon.ps1");
95
130
 
96
- // 给 PS 脚本加 BOM(中文系统兼容)
131
+ stopAll();
132
+ log("..", "正在后台启动代理...", "yellow");
133
+
134
+ // 追加 UTF-8 BOM 修复乱码
97
135
  try {
98
- const content = fs.readFileSync(psPath, "utf8");
99
- if (content.charCodeAt(0) !== 0xFEFF) {
136
+ const psContent = fs.readFileSync(psPath, "utf8");
137
+ if (!psContent.startsWith("")) {
100
138
  const bom = Buffer.from([0xEF, 0xBB, 0xBF]);
101
- fs.writeFileSync(psPath, Buffer.concat([bom, Buffer.from(content, "utf8")]));
139
+ fs.writeFileSync(psPath, Buffer.concat([bom, Buffer.from(psContent, "utf8")]));
102
140
  }
103
- } catch {}
104
-
105
- // 清理旧进程
106
- try { execSync('taskkill /f /fi "WINDOWTITLE eq Pan Router*" >nul 2>&1', { stdio: "pipe" }); } catch {}
141
+ } catch (e) { }
107
142
 
108
- // 启动 server(隐藏)
109
143
  const srv = spawn(process.execPath, [serverPath], {
110
- cwd: __dirname, stdio: "ignore", windowsHide: true, detached: true,
144
+ cwd: __dirname,
145
+ stdio: "ignore",
146
+ windowsHide: true,
147
+ detached: true
111
148
  });
112
149
  srv.unref();
113
150
 
114
- // 启动托盘(隐藏)
115
- const tray = spawn("powershell.exe", [
116
- "-NoProfile", "-STA", "-ExecutionPolicy", "Bypass",
117
- "-WindowStyle", "Hidden", "-File", `"${psPath}"`,
118
- ], {
119
- cwd: __dirname, stdio: "ignore", windowsHide: true, shell: true,
120
- env: { ...process.env, PANROUTER_NODE: process.execPath },
121
- });
122
- tray.unref();
123
-
124
- // 验证端口
125
151
  let ok = false;
126
152
  for (let i = 0; i < 15; i++) {
127
153
  if (await isPortOpen()) { ok = true; break; }
128
154
  await new Promise(rs => setTimeout(rs, 1000));
129
155
  }
130
156
 
131
- if (ok) {
132
- log("OK", "Pan Router 已在后台运行(托盘在右下角)", "green");
133
- } else {
134
- log("!!", "代理启动超时,请检查端口 50816", "red");
135
- }
136
- }
137
-
138
- // ─── 停止 ────────────────────────────────────────────────
139
-
140
- function stopAll() {
141
- log("..", "正在停止...", "yellow");
142
- try { execSync('taskkill /f /fi "WINDOWTITLE eq Pan Router*" >nul 2>&1', { stdio: "pipe" }); } catch {}
143
- try { execSync("taskkill /f /im powershell.exe >nul 2>&1", { stdio: "pipe" }); } catch {}
144
- // 杀 server.mjs 相关 node 进程
145
- try {
146
- const out = execSync(
147
- 'wmic process where "name=\'node.exe\'" get ProcessId,CommandLine /format:csv 2>nul',
148
- { encoding: "utf8", windowsHide: true, timeout: 3000 }
149
- );
150
- for (const line of out.split("\n")) {
151
- if (line.includes("server.mjs")) {
152
- const m = line.match(/(\d+),.*?server\.mjs/);
153
- if (m) try { process.kill(parseInt(m[1]), "SIGKILL"); } catch {}
154
- }
155
- }
156
- } catch {}
157
- log("OK", "已停止", "green");
158
- }
159
-
160
- // ─── 状态 ────────────────────────────────────────────────
157
+ if (ok) log("OK", "代理服务已就绪!(端口 50816)", "green");
158
+ else log("!!", "服务启动超时,但仍将尝试加载托盘", "red");
161
159
 
162
- async function showStatus() {
163
- const online = await isPortOpen();
164
- const version = "3.6.0";
165
- console.log(`\n Pan Router v${version}`);
166
- console.log(` 端口 50816: ${online ? "✓ 运行中" : "✗ 未启动"}`);
167
- if (online) {
168
- try {
169
- const out = execSync(
170
- 'wmic process where "name=\'node.exe\'" get ProcessId,CommandLine /format:csv 2>nul',
171
- { encoding: "utf8", windowsHide: true, timeout: 3000 }
172
- );
173
- for (const line of out.split("\n")) {
174
- if (line.includes("server.mjs")) {
175
- const m = line.match(/(\d+),.*?server\.mjs/);
176
- if (m) console.log(` PID: ${m[1]}`);
177
- }
178
- }
179
- } catch {}
180
- }
181
- console.log("");
182
- }
160
+ log("..", "正在加载系统托盘与控制台引擎...", "yellow");
183
161
 
184
- // ─── 版本 ────────────────────────────────────────────────
162
+ const tray = spawn("powershell.exe", [
163
+ "-NoProfile",
164
+ "-STA",
165
+ "-ExecutionPolicy", "Bypass",
166
+ "-WindowStyle", "Hidden",
167
+ "-File", `"${psPath}"`
168
+ ], {
169
+ cwd: __dirname,
170
+ stdio: ['ignore', 'pipe', 'pipe'],
171
+ windowsHide: true,
172
+ shell: true,
173
+ env: { ...process.env, PANROUTER_NODE: process.execPath }
174
+ });
185
175
 
186
- function showVersion() {
187
- console.log("3.6.0");
188
- }
176
+ let psOutput = "";
177
+ tray.stdout.on("data", d => psOutput += d.toString());
178
+ tray.stderr.on("data", d => psOutput += d.toString());
189
179
 
190
- // ─── 日志 ────────────────────────────────────────────────
180
+ await new Promise(rs => setTimeout(rs, 2500));
191
181
 
192
- function openLogs() {
193
- const logPath = path.join(process.env.TEMP || "/tmp", "panrouter_tray.log");
194
- if (fs.existsSync(logPath)) {
195
- execSync(`notepad "${logPath}"`, { stdio: "inherit" });
182
+ if (tray.exitCode !== null) {
183
+ log("!!", "托盘进程未能驻留,发生闪退!", "red");
184
+ console.log(`\n\x1b[31m=== PowerShell 启动失败原因 ===\x1b[0m\n${psOutput || "(无输出)"}\n\x1b[31m===============================\x1b[0m\n`);
196
185
  } else {
197
- log("!!", "日志文件不存在", "red");
186
+ tray.unref();
187
+ console.log(" 托盘图标已在右下角显示。双击图标即可打开原生数据控制台。");
198
188
  }
199
189
  }
200
190
 
201
- // ─── 主流程 ──────────────────────────────────────────────
191
+ function printHelp() {
192
+ console.log(`
193
+ \x1b[36m=== Pan Router v${VERSION} ===\x1b[0m
194
+
195
+ | 指令 | 功能 |
196
+ |------|------|
197
+ | \x1b[33mpanrouter\x1b[0m | 🔥 自动检测 Claude Code → 安装(如需要) → 配路由 → 启托盘 |
198
+ | \x1b[33mpanrouter --setup\x1b[0m | 只配路由(恢复配置用) |
199
+ | \x1b[33mpanrouter --status\x1b[0m | 查看运行状态 + PID |
200
+ | \x1b[33mpanrouter --stop\x1b[0m | 停止所有进程 |
201
+ | \x1b[33mpanrouter --restart\x1b[0m | 重启托盘 |
202
+ | \x1b[33mpanrouter --server\x1b[0m | 前台窗口模式 |
203
+ | \x1b[33mpanrouter --logs\x1b[0m | 打开日志 |
204
+ | \x1b[33mpanrouter --version\x1b[0m | 版本号 |
205
+ | \x1b[33mpanrouter --help\x1b[0m | 帮助 |
206
+ `);
207
+ }
202
208
 
203
209
  async function main() {
204
210
  const args = process.argv.slice(2);
205
-
206
- // --help
207
- if (args.includes("--help") || args.includes("-h")) {
208
- console.log(`
209
- Pan Router — Claude Code 免费路由代理
210
-
211
- 用法:
212
- panrouter 安装/配置 → 启动托盘(最常用)
213
- panrouter --setup 只配置 Claude Code 路由
214
- panrouter --server 前台窗口模式启动
215
- panrouter --stop 停止所有
216
- panrouter --status 查看运行状态
217
- panrouter --logs 查看日志
218
- panrouter --version 版本号
219
- panrouter --help 帮助
220
- `);
221
- return;
222
- }
223
-
224
- // --version
225
- if (args.includes("--version") || args.includes("-v")) {
226
- showVersion();
227
- return;
228
- }
229
-
230
- // --status
231
- if (args.includes("--status")) {
232
- await showStatus();
233
- return;
211
+ const cmd = args[0];
212
+
213
+ switch(cmd) {
214
+ case "--help":
215
+ case "-h":
216
+ printHelp();
217
+ break;
218
+ case "--version":
219
+ case "-v":
220
+ console.log(`v${VERSION}`);
221
+ break;
222
+ case "--setup":
223
+ writeConfig();
224
+ break;
225
+ case "--status":
226
+ showStatus();
227
+ break;
228
+ case "--stop":
229
+ stopAll();
230
+ break;
231
+ case "--logs":
232
+ openLogs();
233
+ break;
234
+ case "--restart":
235
+ await startTray();
236
+ break;
237
+ case "--server":
238
+ await startServer();
239
+ break;
240
+ default:
241
+ console.log(`\n\x1b[36m=== Pan Router v${VERSION} ===\x1b[0m\n`);
242
+ if (!installClaudeCode()) return process.exit(1);
243
+ writeConfig();
244
+ await startTray();
245
+ break;
234
246
  }
235
-
236
- // --logs
237
- if (args.includes("--logs")) {
238
- openLogs();
239
- return;
240
- }
241
-
242
- // --stop
243
- if (args.includes("--stop")) {
244
- stopAll();
245
- return;
246
- }
247
-
248
- // --setup(只配置)
249
- if (args.includes("--setup")) {
250
- writeConfig();
251
- return;
252
- }
253
-
254
- // --server(前台窗口)
255
- if (args.includes("--server") || args.includes("-s")) {
256
- await startServer();
257
- return;
258
- }
259
-
260
- // --tray(显式指定,兼容旧指令)
261
- if (args.includes("--tray") || args.includes("-t")) {
262
- writeConfig();
263
- await startTray();
264
- return;
265
- }
266
-
267
- // 默认:安装 + 配置 + 托盘
268
- if (!ensureClaudeCode()) process.exit(1);
269
- writeConfig();
270
- await startTray();
271
247
  }
272
248
 
273
249
  main();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "panrouter",
3
- "version": "3.7.0",
3
+ "version": "4.1.0",
4
4
  "description": "让 Claude Code 免费使用 DeepSeek 等模型,无需 API Key",
5
5
  "type": "module",
6
6
  "bin": {
package/server.mjs CHANGED
@@ -1,20 +1,13 @@
1
1
  /**
2
2
  * Pan Router
3
- *
4
3
  * 将 Claude Code 的 Anthropic 格式请求转发到 OpenCode Free,
5
- * 自动完成格式翻译 (Anthropic OpenAI)。
6
- *
7
- * 照抄 9Router 的 openaiToClaudeResponse + formatSSE 逻辑
8
- *
9
- * 用法:
10
- * node pan-router.mjs
11
- * # ~/.claude/settings.json 的 env 设:
12
- * # ANTHROPIC_BASE_URL: http://127.0.0.1:50816
13
- * # ANTHROPIC_AUTH_TOKEN: public
4
+ * 增加本地 Token 统计和原生面板 API 接口。
14
5
  */
15
6
 
16
7
  import http from "node:http";
17
8
  import https from "node:https";
9
+ import fs from "node:fs";
10
+ import path from "node:path";
18
11
 
19
12
  const PORT = 50816;
20
13
  const OPENCODE_BASE = "opencode.ai";
@@ -23,6 +16,15 @@ const AUTH_TOKEN = "public";
23
16
  const MODEL_MAP = { "combo": "deepseek-v4-flash-free" };
24
17
  const DEFAULT_MODEL = "deepseek-v4-flash-free";
25
18
 
19
+ // ─── 统计存储配置 ────────────────────────────────────────────────────────────
20
+ const HOME = process.env.USERPROFILE || process.env.HOME;
21
+ const CLAUDE_DIR = path.join(HOME, ".claude");
22
+ const USAGE_FILE = path.join(CLAUDE_DIR, "panrouter_usage.ndjson");
23
+
24
+ if (!fs.existsSync(CLAUDE_DIR)) {
25
+ fs.mkdirSync(CLAUDE_DIR, { recursive: true });
26
+ }
27
+
26
28
  function json(s) { try { return JSON.parse(s); } catch { return null; } }
27
29
 
28
30
  // ─── 请求翻译: Anthropic → OpenAI ────────────────────────────────────────────
@@ -108,7 +110,7 @@ function flatText(c) {
108
110
  return "";
109
111
  }
110
112
 
111
- // ─── 照抄 9Router: extractReasoningText ─────────────────────────────────────
113
+ // ─── 解析逻辑 ─────────────────────────────────────────────────────────────────
112
114
 
113
115
  function extractReasoningText(delta) {
114
116
  if (!delta || typeof delta !== "object") return "";
@@ -119,9 +121,6 @@ function extractReasoningText(delta) {
119
121
  return "";
120
122
  }
121
123
 
122
- // ─── 照抄 9Router: openaiToClaudeResponse (流式) ────────────────────────────
123
- // 来源: 9router/open-sse/translator/response/openai-to-claude.js
124
-
125
124
  function convertFinishReason(reason) {
126
125
  const map = { stop: "end_turn", tool_calls: "tool_use", length: "max_tokens", content_filter: "content_filter" };
127
126
  return map[reason] || reason;
@@ -133,14 +132,12 @@ function openaiToClaudeResponse(chunk, state) {
133
132
  const choice = chunk.choices[0];
134
133
  const delta = choice.delta;
135
134
 
136
- // Track usage
137
135
  if (chunk.usage && typeof chunk.usage === "object") {
138
136
  const pt = typeof chunk.usage.prompt_tokens === "number" ? chunk.usage.prompt_tokens : 0;
139
137
  const ot = typeof chunk.usage.completion_tokens === "number" ? chunk.usage.completion_tokens : 0;
140
138
  state.usage = { input_tokens: pt, output_tokens: ot };
141
139
  }
142
140
 
143
- // First chunk - message_start
144
141
  if (!state.messageStartSent) {
145
142
  state.messageStartSent = true;
146
143
  state.messageId = chunk.id?.replace("chatcmpl-", "") || `msg_${Date.now()}`;
@@ -157,10 +154,8 @@ function openaiToClaudeResponse(chunk, state) {
157
154
  });
158
155
  }
159
156
 
160
- // Handle reasoning (thinking)
161
157
  const reasoningContent = extractReasoningText(delta);
162
158
  if (reasoningContent) {
163
- // Stop text block if running
164
159
  if (state.textBlockStarted) { stopTextBlock(state, results); }
165
160
  if (!state.thinkingBlockStarted) {
166
161
  state.thinkingBlockIndex = state.nextBlockIndex++;
@@ -170,7 +165,6 @@ function openaiToClaudeResponse(chunk, state) {
170
165
  results.push({ type: "content_block_delta", index: state.thinkingBlockIndex, delta: { type: "thinking_delta", thinking: reasoningContent } });
171
166
  }
172
167
 
173
- // Handle regular content
174
168
  if (delta?.content) {
175
169
  if (state.thinkingBlockStarted) { stopThinkingBlock(state, results); }
176
170
  if (!state.textBlockStarted) {
@@ -182,7 +176,6 @@ function openaiToClaudeResponse(chunk, state) {
182
176
  results.push({ type: "content_block_delta", index: state.textBlockIndex, delta: { type: "text_delta", text: delta.content } });
183
177
  }
184
178
 
185
- // Tool calls
186
179
  if (delta?.tool_calls) {
187
180
  for (const tc of delta.tool_calls) {
188
181
  const idx = tc.index ?? 0;
@@ -200,7 +193,6 @@ function openaiToClaudeResponse(chunk, state) {
200
193
  }
201
194
  }
202
195
 
203
- // Finish
204
196
  if (choice.finish_reason) {
205
197
  stopThinkingBlock(state, results);
206
198
  stopTextBlock(state, results);
@@ -233,20 +225,13 @@ function stopTextBlock(state, results) {
233
225
  state.textBlockStarted = false;
234
226
  }
235
227
 
236
- // ─── 照抄 9Router: formatSSE ────────────────────────────────────────────────
237
-
238
228
  function formatSSE(data) {
239
229
  if (data === null || data === undefined) return "data: null\n\n";
240
230
  if (data && data.done) return "data: [DONE]\n\n";
241
- // Claude format: events have a type field
242
- if (data && data.type) {
243
- return `event: ${data.type}\ndata: ${JSON.stringify(data)}\n\n`;
244
- }
231
+ if (data && data.type) return `event: ${data.type}\ndata: ${JSON.stringify(data)}\n\n`;
245
232
  return `data: ${JSON.stringify(data)}\n\n`;
246
233
  }
247
234
 
248
- // ─── SSE 行解析 (照抄 9Router parseSSELine) ─────────────────────────────────
249
-
250
235
  function parseSSELine(line) {
251
236
  if (!line) return null;
252
237
  if (line.charCodeAt(0) !== 100) return null; // 'd'
@@ -255,31 +240,6 @@ function parseSSELine(line) {
255
240
  try { return JSON.parse(data); } catch { return null; }
256
241
  }
257
242
 
258
- // ─── 非流式响应 ──────────────────────────────────────────────────────────────
259
-
260
- function openAIToClaudeResponse(resp) {
261
- const choice = resp.choices?.[0];
262
- if (!choice) return { type: "message", role: "assistant", content: [{ type: "text", text: "" }], model: resp.model || "unknown", stop_reason: "end_turn", stop_sequence: null };
263
- const msg = choice.message || {};
264
- const blocks = [];
265
- const tc = msg.tool_calls;
266
- if (msg.reasoning_content || msg.reasoning) blocks.push({ type: "thinking", thinking: msg.reasoning_content || msg.reasoning });
267
- if (Array.isArray(tc) && tc.length > 0) {
268
- if (msg.content) blocks.push({ type: "text", text: msg.content });
269
- for (const t of tc) blocks.push({ type: "tool_use", id: t.id, name: t.function.name, input: safeJson(t.function.arguments) });
270
- } else {
271
- blocks.push({ type: "text", text: msg.content || "" });
272
- }
273
- const finishMap = { stop: "end_turn", tool_calls: "tool_use", length: "max_tokens" };
274
- const result = { id: resp.id || `msg_${Date.now()}`, type: "message", role: "assistant", content: blocks, model: resp.model || "unknown", stop_reason: finishMap[choice.finish_reason] || choice.finish_reason || "end_turn", stop_sequence: null };
275
- if (resp.usage) result.usage = { input_tokens: resp.usage.prompt_tokens || 0, output_tokens: resp.usage.completion_tokens || 0 };
276
- return result;
277
- }
278
-
279
- function safeJson(s) { try { return JSON.parse(s); } catch { return s; } }
280
-
281
- // ─── HTTP 请求 ───────────────────────────────────────────────────────────────
282
-
283
243
  function fetchOpenCode(path, body, stream) {
284
244
  const data = JSON.stringify(body);
285
245
  return new Promise((resolve, reject) => {
@@ -299,9 +259,6 @@ function fetchOpenCode(path, body, stream) {
299
259
  });
300
260
  }
301
261
 
302
-
303
- // ─── 路径标准化 ──────────────────────────────────────────────────────────────
304
-
305
262
  function normalizePath(pathname) {
306
263
  const p = pathname.replace(/\/+$/, "");
307
264
  if (p.startsWith("/v1/v1/")) return p.replace("/v1/v1/", "/v1/");
@@ -309,7 +266,7 @@ function normalizePath(pathname) {
309
266
  return p;
310
267
  }
311
268
 
312
- // ─── 服务器 ───────────────────────────────────────────────────────────────────
269
+ // ─── 服务器核心 ───────────────────────────────────────────────────────────────
313
270
 
314
271
  const server = http.createServer(async (req, res) => {
315
272
  const url = new URL(req.url, `http://${req.headers.host}`);
@@ -318,37 +275,59 @@ const server = http.createServer(async (req, res) => {
318
275
  const corsHeaders = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, POST, OPTIONS", "Access-Control-Allow-Headers": "*" };
319
276
  if (req.method === "OPTIONS") { res.writeHead(204, corsHeaders); res.end(); return; }
320
277
 
321
- if (req.method === "GET" && url.pathname === "/health") {
322
- res.writeHead(200, { "Content-Type": "application/json" });
323
- res.end(JSON.stringify({ status: "ok", port: PORT }));
324
- return;
325
- }
278
+ // ====== 统计数据 API 接口 ======
279
+ if (req.method === "GET" && url.pathname === "/api/stats") {
280
+ const period = url.searchParams.get("period") || "all";
281
+ const now = Date.now();
282
+ let cutoff = 0;
283
+ if (period === "1") cutoff = now - 24 * 3600 * 1000;
284
+ else if (period === "7") cutoff = now - 7 * 24 * 3600 * 1000;
285
+ else if (period === "30") cutoff = now - 30 * 24 * 3600 * 1000;
286
+
287
+ let totalReq = 0, totalIn = 0, totalOut = 0;
288
+ const history = [];
289
+
290
+ try {
291
+ if (fs.existsSync(USAGE_FILE)) {
292
+ const content = fs.readFileSync(USAGE_FILE, "utf-8");
293
+ const lines = content.split("\n").filter(Boolean);
294
+ for (const line of lines) {
295
+ try {
296
+ const r = JSON.parse(line);
297
+ if (cutoff === 0 || r.ts >= cutoff) {
298
+ totalReq++;
299
+ totalIn += (r.i || 0);
300
+ totalOut += (r.o || 0);
301
+ history.push(r);
302
+ }
303
+ } catch(e) {}
304
+ }
305
+ }
306
+ } catch(e) {}
307
+
308
+ // 按时间倒序,返回最近 100 条给前端展示
309
+ history.sort((a,b) => b.ts - a.ts);
310
+ const recent = history.slice(0, 100);
326
311
 
327
- if (req.method === "GET" && url.pathname === "/v1/models") {
328
312
  res.writeHead(200, { "Content-Type": "application/json" });
329
- res.end(JSON.stringify({ object: "list", data: [{ id: "combo", object: "model", owned_by: "combo" }] }));
313
+ res.end(JSON.stringify({ totalReq, totalIn, totalOut, recent }));
330
314
  return;
331
315
  }
332
316
 
333
- if (req.method === "GET" && url.pathname.startsWith("/v1/models/")) {
317
+ // 基础接口
318
+ if (req.method === "GET" && url.pathname === "/health") {
334
319
  res.writeHead(200, { "Content-Type": "application/json" });
335
- res.end(JSON.stringify({ id: url.pathname.split("/").pop(), object: "model", owned_by: "combo" }));
320
+ res.end(JSON.stringify({ status: "ok", port: PORT }));
336
321
  return;
337
322
  }
338
323
 
339
- if (req.method === "POST" && url.pathname === "/v1/messages/count_tokens") {
340
- const raw = []; for await (const c of req) raw.push(c);
341
- const body = json(Buffer.concat(raw).toString()) || {};
342
- let chars = 0;
343
- for (const msg of (body.messages || [])) {
344
- if (typeof msg.content === "string") chars += msg.content.length;
345
- else if (Array.isArray(msg.content)) for (const p of msg.content) { if (p.type === "text" && p.text) chars += p.text.length; }
346
- }
324
+ if (req.method === "GET" && url.pathname === "/v1/models") {
347
325
  res.writeHead(200, { "Content-Type": "application/json" });
348
- res.end(JSON.stringify({ input_tokens: Math.ceil(chars / 4) || 1 }));
326
+ res.end(JSON.stringify({ object: "list", data: [{ id: "combo", object: "model", owned_by: "combo" }] }));
349
327
  return;
350
328
  }
351
329
 
330
+ // 请求转发逻辑
352
331
  if (req.method === "POST" && url.pathname === "/v1/messages") {
353
332
  const raw = []; for await (const c of req) raw.push(c);
354
333
  const body = json(Buffer.concat(raw).toString());
@@ -368,8 +347,6 @@ const server = http.createServer(async (req, res) => {
368
347
  return;
369
348
  }
370
349
 
371
- // ─── 照抄 9Router 的流式处理 ─────────────────────────────────────────────
372
- // pipeline: 解析 SSE → openaiToClaudeResponse → formatSSE → 写出
373
350
  const state = {
374
351
  messageStartSent: false, messageId: null, model: null,
375
352
  thinkingBlockStarted: false, thinkingBlockIndex: -1,
@@ -395,7 +372,6 @@ const server = http.createServer(async (req, res) => {
395
372
  const parsed = parseSSELine(trimmed);
396
373
  if (!parsed || parsed.done) continue;
397
374
 
398
- // 用 9Router 的 openaiToClaudeResponse 处理这个 chunk
399
375
  const events = openaiToClaudeResponse(parsed, state);
400
376
  if (!events) continue;
401
377
 
@@ -405,7 +381,14 @@ const server = http.createServer(async (req, res) => {
405
381
  }
406
382
  });
407
383
 
384
+ // 请求结束时记录统计数据
408
385
  upstream.stream.on("end", () => {
386
+ if (state.model) {
387
+ const inTokens = state.usage?.input_tokens || 0;
388
+ const outTokens = state.usage?.output_tokens || 0;
389
+ const record = { ts: Date.now(), m: state.model, i: inTokens, o: outTokens };
390
+ fs.appendFile(USAGE_FILE, JSON.stringify(record) + "\n", () => {});
391
+ }
409
392
  res.end();
410
393
  });
411
394
 
@@ -418,12 +401,5 @@ const server = http.createServer(async (req, res) => {
418
401
  });
419
402
 
420
403
  server.listen(PORT, "127.0.0.1", () => {
421
- console.log(`
422
- ╔═══════════════════════════════════════════╗
423
- ║ Pan Router :${PORT} ║
424
- ║ ║
425
- ║ Claude Code ← Pan Router → OpenCode Free ║
426
- ╚═══════════════════════════════════════════╝
427
- Model: combo → deepseek-v4-flash-free
428
- `);
404
+ console.log(`Pan Router is running at :${PORT}`);
429
405
  });
package/tray-daemon.ps1 CHANGED
@@ -1,6 +1,6 @@
1
1
  <#
2
2
  .SYNOPSIS
3
- Pan Router 托盘守护脚本 (终极完美版)
3
+ Pan Router 托盘守护脚本 (原生桌面控制台版 - 作用域修复)
4
4
  #>
5
5
  $ErrorActionPreference = "Stop"
6
6
 
@@ -15,7 +15,6 @@ try {
15
15
 
16
16
  $scriptDir = Split-Path $MyInvocation.MyCommand.Path -Parent
17
17
  $serverPath = Join-Path $scriptDir "server.mjs"
18
- # 【修复1】:在全局作用域提前把准确的脚本路径存下来,防止在 Click 事件块内丢失
19
18
  $trayScriptPath = $MyInvocation.MyCommand.Path
20
19
 
21
20
  $nodePath = $env:PANROUTER_NODE
@@ -23,12 +22,9 @@ try {
23
22
  $nodeCmd = Get-Command node -ErrorAction SilentlyContinue
24
23
  if ($nodeCmd) { $nodePath = $nodeCmd.Source } else { $nodePath = "node" }
25
24
  }
26
- "2. Node 路径: $nodePath" | Out-File $logFile -Append
27
- " Server 路径: $serverPath" | Out-File $logFile -Append
28
25
 
29
26
  # ─── 管理后台代理 ──────────────────────────────────────────
30
27
  function Start-Backend {
31
- "-> 执行重启后台服务逻辑" | Out-File $logFile -Append
32
28
  Get-WmiObject Win32_Process -Filter "Name = 'node.exe'" |
33
29
  Where-Object CommandLine -match "server\.mjs" |
34
30
  ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }
@@ -43,31 +39,21 @@ try {
43
39
  $psi.UseShellExecute = $false
44
40
  $psi.RedirectStandardOutput = $true
45
41
  $psi.RedirectStandardError = $true
46
-
47
- $proc = [System.Diagnostics.Process]::Start($psi)
48
- "-> Node 服务进程已原生启动 (PID: $($proc.Id))" | Out-File $logFile -Append
49
- } catch {
50
- "-> Node 进程启动失败: $($_.Exception.Message)" | Out-File $logFile -Append
51
- }
42
+ [System.Diagnostics.Process]::Start($psi) | Out-Null
43
+ } catch { }
52
44
  }
53
45
 
54
- # 检查端口
55
46
  $portOpen = $false
56
47
  try {
57
48
  $tcp = New-Object System.Net.Sockets.TcpClient
58
49
  $ar = $tcp.BeginConnect("127.0.0.1", 50816, $null, $null)
59
50
  $portOpen = $ar.AsyncWaitHandle.WaitOne(500, $false)
60
51
  $tcp.Close()
61
- "3. 端口探测完成,已开放状态: $portOpen" | Out-File $logFile -Append
62
- } catch {
63
- "3. 端口探测报错: $($_.Exception.Message)" | Out-File $logFile -Append
64
- }
52
+ } catch {}
65
53
 
66
- if (-not $portOpen) {
67
- Start-Backend
68
- }
54
+ if (-not $portOpen) { Start-Backend }
69
55
 
70
- # ─── 初始化托盘 ────────────────────────────────────────────
56
+ # ─── 初始化托盘图标 ─────────────────────────────────────────
71
57
  $notifyIcon = New-Object System.Windows.Forms.NotifyIcon
72
58
  $notifyIcon.Text = "Pan Router (:50816)"
73
59
 
@@ -82,7 +68,120 @@ try {
82
68
  $notifyIcon.Icon = [System.Drawing.SystemIcons]::Shield
83
69
  }
84
70
 
85
- # ─── 原生菜单配置 (ContextMenu) ────────────────────────────
71
+ # ====== 【原生 WinForms 数据面板 (修复变量回收 Bug) ======
72
+ $global:dashForm = $null
73
+ $script:lblReq = $null
74
+ $script:lblIn = $null
75
+ $script:lblOut = $null
76
+ $script:cmbPeriod = $null
77
+ $script:lv = $null
78
+
79
+ function Show-Dashboard {
80
+ if ($global:dashForm -ne $null -and -not $global:dashForm.IsDisposed) {
81
+ $global:dashForm.BringToFront()
82
+ return
83
+ }
84
+
85
+ $form = New-Object System.Windows.Forms.Form
86
+ $global:dashForm = $form
87
+ $form.Text = "Pan Router 本地控制台"
88
+ $form.Size = New-Object System.Drawing.Size(560, 480)
89
+ $form.StartPosition = 'CenterScreen'
90
+ $form.Font = New-Object System.Drawing.Font("Microsoft YaHei", 9)
91
+ $form.FormBorderStyle = 'FixedSingle'
92
+ $form.MaximizeBox = $false
93
+ $form.Icon = $notifyIcon.Icon
94
+
95
+ $grpSummary = New-Object System.Windows.Forms.GroupBox
96
+ $grpSummary.Text = "使用汇总"
97
+ $grpSummary.Location = New-Object System.Drawing.Point(15, 10)
98
+ $grpSummary.Size = New-Object System.Drawing.Size(515, 65)
99
+ $form.Controls.Add($grpSummary)
100
+
101
+ $script:lblReq = New-Object System.Windows.Forms.Label
102
+ $script:lblReq.Location = New-Object System.Drawing.Point(20, 28)
103
+ $script:lblReq.Size = New-Object System.Drawing.Size(140, 20)
104
+ $script:lblReq.Text = "请求总数: -"
105
+ $grpSummary.Controls.Add($script:lblReq)
106
+
107
+ $script:lblIn = New-Object System.Windows.Forms.Label
108
+ $script:lblIn.Location = New-Object System.Drawing.Point(160, 28)
109
+ $script:lblIn.Size = New-Object System.Drawing.Size(160, 20)
110
+ $script:lblIn.Text = "输入 Token: -"
111
+ $script:lblIn.ForeColor = [System.Drawing.Color]::MediumBlue
112
+ $grpSummary.Controls.Add($script:lblIn)
113
+
114
+ $script:lblOut = New-Object System.Windows.Forms.Label
115
+ $script:lblOut.Location = New-Object System.Drawing.Point(340, 28)
116
+ $script:lblOut.Size = New-Object System.Drawing.Size(160, 20)
117
+ $script:lblOut.Text = "输出 Token: -"
118
+ $script:lblOut.ForeColor = [System.Drawing.Color]::ForestGreen
119
+ $grpSummary.Controls.Add($script:lblOut)
120
+
121
+ $lblFilter = New-Object System.Windows.Forms.Label
122
+ $lblFilter.Text = "时间筛选:"
123
+ $lblFilter.Location = New-Object System.Drawing.Point(15, 88)
124
+ $lblFilter.AutoSize = $true
125
+ $form.Controls.Add($lblFilter)
126
+
127
+ $script:cmbPeriod = New-Object System.Windows.Forms.ComboBox
128
+ $script:cmbPeriod.Items.AddRange(@("最近 1 天", "最近 7 天", "最近 30 天", "全部时间"))
129
+ $script:cmbPeriod.SelectedIndex = 3
130
+ $script:cmbPeriod.Location = New-Object System.Drawing.Point(80, 85)
131
+ $script:cmbPeriod.Size = New-Object System.Drawing.Size(120, 20)
132
+ $script:cmbPeriod.DropDownStyle = 'DropDownList'
133
+ $form.Controls.Add($script:cmbPeriod)
134
+
135
+ $script:lv = New-Object System.Windows.Forms.ListView
136
+ $script:lv.Location = New-Object System.Drawing.Point(15, 115)
137
+ $script:lv.Size = New-Object System.Drawing.Size(515, 310)
138
+ $script:lv.View = 'Details'
139
+ $script:lv.FullRowSelect = $true
140
+ $script:lv.GridLines = $true
141
+ $script:lv.Columns.Add("时间", 135) | Out-Null
142
+ $script:lv.Columns.Add("模型", 185) | Out-Null
143
+ $script:lv.Columns.Add("输入", 85) | Out-Null
144
+ $script:lv.Columns.Add("输出", 85) | Out-Null
145
+ $form.Controls.Add($script:lv)
146
+
147
+ $updateData = {
148
+ if ($null -eq $script:cmbPeriod -or $null -eq $script:cmbPeriod.SelectedItem) { return }
149
+
150
+ $map = @{"最近 1 天"="1"; "最近 7 天"="7"; "最近 30 天"="30"; "全部时间"="all"}
151
+ $p = $map[$script:cmbPeriod.SelectedItem.ToString()]
152
+
153
+ try {
154
+ $data = Invoke-RestMethod -Uri "http://127.0.0.1:50816/api/stats?period=$p" -Method Get -ErrorAction Stop
155
+
156
+ $script:lblReq.Text = "请求总数: {0:N0}" -f $data.totalReq
157
+ $script:lblIn.Text = "输入 Token: {0:N0}" -f $data.totalIn
158
+ $script:lblOut.Text = "输出 Token: {0:N0}" -f $data.totalOut
159
+
160
+ $script:lv.Items.Clear()
161
+ if ($data.recent) {
162
+ $script:lv.BeginUpdate()
163
+ foreach ($r in $data.recent) {
164
+ $dt = [timezone]::CurrentTimeZone.ToLocalTime(([datetime]'1/1/1970').AddMilliseconds($r.ts))
165
+ $item = New-Object System.Windows.Forms.ListViewItem($dt.ToString("yyyy-MM-dd HH:mm:ss"))
166
+ $item.SubItems.Add($r.m) | Out-Null
167
+ $item.SubItems.Add(("{0:N0} ↑" -f $r.i)) | Out-Null
168
+ $item.SubItems.Add(("{0:N0} ↓" -f $r.o)) | Out-Null
169
+ $script:lv.Items.Add($item) | Out-Null
170
+ }
171
+ $script:lv.EndUpdate()
172
+ }
173
+ } catch {
174
+ $script:lblReq.Text = "无法连接统计接口,请检查服务状态"
175
+ }
176
+ }
177
+
178
+ $script:cmbPeriod.Add_SelectedIndexChanged($updateData)
179
+ & $updateData
180
+
181
+ $form.Show()
182
+ }
183
+
184
+ # ─── 托盘菜单配置 ──────────────────────────────────────────
86
185
  $menu = New-Object System.Windows.Forms.ContextMenu
87
186
 
88
187
  $titleItem = New-Object System.Windows.Forms.MenuItem("Pan Router | :50816")
@@ -90,6 +189,12 @@ try {
90
189
  $menu.MenuItems.Add($titleItem) | Out-Null
91
190
  $menu.MenuItems.Add("-") | Out-Null
92
191
 
192
+ $dashItem = New-Object System.Windows.Forms.MenuItem("打开控制台 (面板)")
193
+ $dashItem.Add_Click({ Show-Dashboard })
194
+ $dashItem.DefaultItem = $true
195
+ $menu.MenuItems.Add($dashItem) | Out-Null
196
+ $menu.MenuItems.Add("-") | Out-Null
197
+
93
198
  $restartItem = New-Object System.Windows.Forms.MenuItem("重启服务")
94
199
  $restartItem.Add_Click({
95
200
  Start-Backend
@@ -104,7 +209,6 @@ try {
104
209
  reg delete "HKCU\Software\Microsoft\Windows\CurrentVersion\Run" /v PanRouter /f 2>&1 | Out-Null
105
210
  $autoItem.Checked = $false
106
211
  } else {
107
- # 【修复2】:写入注册表时,务必加上 -STA 参数防止 UI 闪退,并使用刚刚存好的 $trayScriptPath
108
212
  $cmd = "powershell.exe -WindowStyle Hidden -STA -ExecutionPolicy Bypass -File `"$trayScriptPath`""
109
213
  reg add "HKCU\Software\Microsoft\Windows\CurrentVersion\Run" /v PanRouter /t REG_SZ /d $cmd /f 2>&1 | Out-Null
110
214
  $autoItem.Checked = $true
@@ -123,20 +227,17 @@ try {
123
227
 
124
228
  $notifyIcon.ContextMenu = $menu
125
229
  $notifyIcon.Visible = $true
126
- "5. 托盘图标已绑定并设置为可见" | Out-File $logFile -Append
230
+
231
+ $notifyIcon.Add_DoubleClick({ Show-Dashboard })
127
232
 
128
233
  # ─── 隐形实体主窗口 ─────────────────────────────────────────
129
234
  $form = New-Object System.Windows.Forms.Form
130
235
  $form.ShowInTaskbar = $false
131
236
  $form.WindowState = [System.Windows.Forms.FormWindowState]::Minimized
132
237
  $form.Opacity = 0
133
- $form.Add_Load({
134
- $form.Hide()
135
- "6. 实体窗口初始化完毕,正式进入消息挂起循环..." | Out-File $logFile -Append
136
- })
238
+ $form.Add_Load({ $form.Hide() })
137
239
 
138
240
  [System.Windows.Forms.Application]::Run($form)
139
241
  } catch {
140
- "【致命报错】无法完成执行:" | Out-File $logFile -Append
141
242
  $_.Exception.Message | Out-File $logFile -Append
142
243
  }