panrouter 3.7.0 → 4.0.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 +150 -179
  2. package/package.json +1 -1
  3. package/server.mjs +61 -87
  4. package/tray-daemon.ps1 +121 -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,13 +63,63 @@ async function isPortOpen() {
69
63
  });
70
64
  }
71
65
 
72
- // ─── 启动前台服务 ────────────────────────────────────────
73
-
74
- async function startServer() {
75
- const serverPath = path.join(__dirname, "server.mjs");
66
+ function stopAll() {
67
+ log("..", "正在停止所有 Pan Router 进程...", "yellow");
76
68
  try {
77
69
  execSync('taskkill /f /fi "WINDOWTITLE eq Pan Router*" >nul 2>&1', { stdio: "pipe" });
78
70
  } catch {}
71
+ try {
72
+ execSync("taskkill /f /im powershell.exe >nul 2>&1", { stdio: "pipe" });
73
+ } catch {}
74
+ try {
75
+ const out = execSync(
76
+ 'wmic process where "name=\'node.exe\'" get ProcessId,CommandLine /format:csv 2>nul',
77
+ { encoding: "utf8", windowsHide: true, timeout: 5000 }
78
+ );
79
+ for (const line of out.split("\n")) {
80
+ if (line.includes("server.mjs")) {
81
+ const m = line.match(/(\d+),.*?server\.mjs/);
82
+ if (m) try { process.kill(parseInt(m[1]), "SIGKILL"); } catch {}
83
+ }
84
+ }
85
+ } catch {}
86
+ log("OK", "已停止所有进程", "green");
87
+ }
88
+
89
+ function showStatus() {
90
+ console.log(`\n\x1b[36m=== Pan Router 状态 ===\x1b[0m\n`);
91
+ try {
92
+ const out = execSync(
93
+ 'wmic process where "name=\'node.exe\'" get ProcessId,CommandLine /format:csv 2>nul',
94
+ { encoding: "utf8", timeout: 5000 }
95
+ );
96
+ const nodePids = [];
97
+ for (const line of out.split("\n")) {
98
+ if (line.includes("server.mjs")) {
99
+ const m = line.match(/(\d+),/);
100
+ if (m) nodePids.push(m[1]);
101
+ }
102
+ }
103
+ if (nodePids.length > 0) log("OK", `代理服务 (Node): 运行中 [PID: ${nodePids.join(", ")}]`, "green");
104
+ else log("!!", "代理服务 (Node): 未运行", "red");
105
+ } catch {
106
+ log("!!", "无法获取状态", "red");
107
+ }
108
+ console.log("");
109
+ }
110
+
111
+ function openLogs() {
112
+ const logFile = path.join(process.env.TEMP, "panrouter_tray.log");
113
+ if (fs.existsSync(logFile)) {
114
+ execSync(`start notepad "${logFile}"`);
115
+ } else {
116
+ log("!!", "暂无托盘日志文件", "red");
117
+ }
118
+ }
119
+
120
+ async function startServer() {
121
+ const serverPath = path.join(__dirname, "server.mjs");
122
+ stopAll();
79
123
 
80
124
  log("..", "正在启动代理...", "yellow");
81
125
  execSync(`start "Pan Router" cmd /c "node ${serverPath} & pause"`, { stdio: "pipe" });
@@ -84,190 +128,117 @@ async function startServer() {
84
128
  if (await isPortOpen()) break;
85
129
  await new Promise(rs => setTimeout(rs, 1000));
86
130
  }
87
- log("OK", "Pan Router 运行中", "green");
131
+ log("OK", "Pan Router 运行中,可以执行 claude 命令了", "green");
88
132
  }
89
133
 
90
- // ─── 启动托盘(后台) ────────────────────────────────────
91
-
92
134
  async function startTray() {
93
135
  const serverPath = path.join(__dirname, "server.mjs");
94
136
  const psPath = path.join(__dirname, "tray-daemon.ps1");
95
137
 
96
- // 给 PS 脚本加 BOM(中文系统兼容)
138
+ stopAll();
139
+ log("..", "正在后台启动代理...", "yellow");
140
+
141
+ // UTF-8 BOM
97
142
  try {
98
- const content = fs.readFileSync(psPath, "utf8");
99
- if (content.charCodeAt(0) !== 0xFEFF) {
143
+ const psContent = fs.readFileSync(psPath, "utf8");
144
+ if (psContent.charCodeAt(0) !== 0xFEFF) {
100
145
  const bom = Buffer.from([0xEF, 0xBB, 0xBF]);
101
- fs.writeFileSync(psPath, Buffer.concat([bom, Buffer.from(content, "utf8")]));
146
+ fs.writeFileSync(psPath, Buffer.concat([bom, Buffer.from(psContent, "utf8")]));
102
147
  }
103
148
  } catch {}
104
149
 
105
- // 清理旧进程
106
- try { execSync('taskkill /f /fi "WINDOWTITLE eq Pan Router*" >nul 2>&1', { stdio: "pipe" }); } catch {}
107
-
108
- // 启动 server(隐藏)
109
150
  const srv = spawn(process.execPath, [serverPath], {
110
- cwd: __dirname, stdio: "ignore", windowsHide: true, detached: true,
151
+ cwd: __dirname, stdio: "ignore", windowsHide: true, detached: true
111
152
  });
112
153
  srv.unref();
113
154
 
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
155
  let ok = false;
126
156
  for (let i = 0; i < 15; i++) {
127
157
  if (await isPortOpen()) { ok = true; break; }
128
158
  await new Promise(rs => setTimeout(rs, 1000));
129
159
  }
130
160
 
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
- // ─── 状态 ────────────────────────────────────────────────
161
+ if (ok) log("OK", "代理服务已就绪!(端口 50816)", "green");
162
+ else log("!!", "服务启动超时,但仍将尝试加载托盘", "red");
161
163
 
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
- }
164
+ log("..", "正在加载系统托盘...", "yellow");
183
165
 
184
- // ─── 版本 ────────────────────────────────────────────────
166
+ const tray = spawn("powershell.exe", [
167
+ "-NoProfile", "-STA", "-ExecutionPolicy", "Bypass",
168
+ "-WindowStyle", "Hidden", "-File", `"${psPath}"`,
169
+ ], {
170
+ cwd: __dirname, stdio: ["ignore", "pipe", "pipe"],
171
+ windowsHide: true, shell: true,
172
+ env: { ...process.env, PANROUTER_NODE: process.execPath },
173
+ });
185
174
 
186
- function showVersion() {
187
- console.log("3.6.0");
188
- }
175
+ let psOutput = "";
176
+ tray.stdout.on("data", d => psOutput += d.toString());
177
+ tray.stderr.on("data", d => psOutput += d.toString());
189
178
 
190
- // ─── 日志 ────────────────────────────────────────────────
179
+ await new Promise(rs => setTimeout(rs, 2500));
191
180
 
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" });
181
+ if (tray.exitCode !== null) {
182
+ log("!!", "托盘进程未能驻留,发生闪退!", "red");
183
+ console.log(`\n\x1b[31m=== PowerShell 启动失败原因 ===\x1b[0m\n${psOutput || "(无输出)"}\n\x1b[31m===============================\x1b[0m\n`);
196
184
  } else {
197
- log("!!", "日志文件不存在", "red");
185
+ tray.unref();
186
+ console.log(" 托盘图标已在右下角显示。双击图标即可打开原生数据控制台。");
198
187
  }
199
188
  }
200
189
 
201
- // ─── 主流程 ──────────────────────────────────────────────
190
+ function printHelp() {
191
+ console.log(`
192
+ \x1b[36m=== Pan Router v${VERSION} ===\x1b[0m
193
+
194
+ | 指令 | 功能 |
195
+ |------|------|
196
+ | \x1b[33mpanrouter\x1b[0m | 自动检测 Claude Code → 安装(如需要) → 配路由 → 启托盘 |
197
+ | \x1b[33mpanrouter --setup\x1b[0m | 只配路由(恢复配置用) |
198
+ | \x1b[33mpanrouter --status\x1b[0m | 查看运行状态 + PID |
199
+ | \x1b[33mpanrouter --stop\x1b[0m | 停止所有进程 |
200
+ | \x1b[33mpanrouter --server\x1b[0m | 前台窗口模式 |
201
+ | \x1b[33mpanrouter --logs\x1b[0m | 打开日志 |
202
+ | \x1b[33mpanrouter --version\x1b[0m | 版本号 |
203
+ | \x1b[33mpanrouter --help\x1b[0m | 帮助 |
204
+ `);
205
+ }
202
206
 
203
207
  async function main() {
204
208
  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;
234
- }
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;
209
+ const cmd = args[0];
210
+
211
+ switch (cmd) {
212
+ case "--help":
213
+ case "-h":
214
+ printHelp();
215
+ break;
216
+ case "--version":
217
+ case "-v":
218
+ console.log(`v${VERSION}`);
219
+ break;
220
+ case "--setup":
221
+ writeConfig();
222
+ break;
223
+ case "--status":
224
+ showStatus();
225
+ break;
226
+ case "--stop":
227
+ stopAll();
228
+ break;
229
+ case "--logs":
230
+ openLogs();
231
+ break;
232
+ case "--server":
233
+ await startServer();
234
+ break;
235
+ default:
236
+ console.log(`\n\x1b[36m=== Pan Router v${VERSION} ===\x1b[0m\n`);
237
+ if (!installClaudeCode()) return process.exit(1);
238
+ writeConfig();
239
+ await startTray();
240
+ break;
265
241
  }
266
-
267
- // 默认:安装 + 配置 + 托盘
268
- if (!ensureClaudeCode()) process.exit(1);
269
- writeConfig();
270
- await startTray();
271
242
  }
272
243
 
273
244
  main();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "panrouter",
3
- "version": "3.7.0",
3
+ "version": "4.0.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,58 @@ 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
+ history.sort((a,b) => b.ts - a.ts);
309
+ const recent = history.slice(0, 100);
326
310
 
327
- if (req.method === "GET" && url.pathname === "/v1/models") {
328
311
  res.writeHead(200, { "Content-Type": "application/json" });
329
- res.end(JSON.stringify({ object: "list", data: [{ id: "combo", object: "model", owned_by: "combo" }] }));
312
+ res.end(JSON.stringify({ totalReq, totalIn, totalOut, recent }));
330
313
  return;
331
314
  }
332
315
 
333
- if (req.method === "GET" && url.pathname.startsWith("/v1/models/")) {
316
+ // 基础接口
317
+ if (req.method === "GET" && url.pathname === "/health") {
334
318
  res.writeHead(200, { "Content-Type": "application/json" });
335
- res.end(JSON.stringify({ id: url.pathname.split("/").pop(), object: "model", owned_by: "combo" }));
319
+ res.end(JSON.stringify({ status: "ok", port: PORT }));
336
320
  return;
337
321
  }
338
322
 
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
- }
323
+ if (req.method === "GET" && url.pathname === "/v1/models") {
347
324
  res.writeHead(200, { "Content-Type": "application/json" });
348
- res.end(JSON.stringify({ input_tokens: Math.ceil(chars / 4) || 1 }));
325
+ res.end(JSON.stringify({ object: "list", data: [{ id: "combo", object: "model", owned_by: "combo" }] }));
349
326
  return;
350
327
  }
351
328
 
329
+ // 请求转发逻辑
352
330
  if (req.method === "POST" && url.pathname === "/v1/messages") {
353
331
  const raw = []; for await (const c of req) raw.push(c);
354
332
  const body = json(Buffer.concat(raw).toString());
@@ -368,8 +346,6 @@ const server = http.createServer(async (req, res) => {
368
346
  return;
369
347
  }
370
348
 
371
- // ─── 照抄 9Router 的流式处理 ─────────────────────────────────────────────
372
- // pipeline: 解析 SSE → openaiToClaudeResponse → formatSSE → 写出
373
349
  const state = {
374
350
  messageStartSent: false, messageId: null, model: null,
375
351
  thinkingBlockStarted: false, thinkingBlockIndex: -1,
@@ -395,7 +371,6 @@ const server = http.createServer(async (req, res) => {
395
371
  const parsed = parseSSELine(trimmed);
396
372
  if (!parsed || parsed.done) continue;
397
373
 
398
- // 用 9Router 的 openaiToClaudeResponse 处理这个 chunk
399
374
  const events = openaiToClaudeResponse(parsed, state);
400
375
  if (!events) continue;
401
376
 
@@ -406,6 +381,12 @@ const server = http.createServer(async (req, res) => {
406
381
  });
407
382
 
408
383
  upstream.stream.on("end", () => {
384
+ if (state.model) {
385
+ const inTokens = state.usage?.input_tokens || 0;
386
+ const outTokens = state.usage?.output_tokens || 0;
387
+ const record = { ts: Date.now(), m: state.model, i: inTokens, o: outTokens };
388
+ fs.appendFile(USAGE_FILE, JSON.stringify(record) + "\n", () => {});
389
+ }
409
390
  res.end();
410
391
  });
411
392
 
@@ -418,12 +399,5 @@ const server = http.createServer(async (req, res) => {
418
399
  });
419
400
 
420
401
  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
- `);
402
+ console.log(`Pan Router is running at :${PORT}`);
429
403
  });
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,112 @@ try {
82
68
  $notifyIcon.Icon = [System.Drawing.SystemIcons]::Shield
83
69
  }
84
70
 
85
- # ─── 原生菜单配置 (ContextMenu) ────────────────────────────
71
+ # ====== 原生 WinForms 数据面板 ======
72
+ $global:dashForm = $null
73
+
74
+ function Show-Dashboard {
75
+ if ($global:dashForm -ne $null -and -not $global:dashForm.IsDisposed) {
76
+ $global:dashForm.BringToFront()
77
+ return
78
+ }
79
+
80
+ $form = New-Object System.Windows.Forms.Form
81
+ $global:dashForm = $form
82
+ $form.Text = "Pan Router 本地控制台"
83
+ $form.Size = New-Object System.Drawing.Size(560, 480)
84
+ $form.StartPosition = 'CenterScreen'
85
+ $form.Font = New-Object System.Drawing.Font("Microsoft YaHei", 9)
86
+ $form.FormBorderStyle = 'FixedSingle'
87
+ $form.MaximizeBox = $false
88
+ $form.Icon = $notifyIcon.Icon
89
+
90
+ $grpSummary = New-Object System.Windows.Forms.GroupBox
91
+ $grpSummary.Text = "使用汇总"
92
+ $grpSummary.Location = New-Object System.Drawing.Point(15, 10)
93
+ $grpSummary.Size = New-Object System.Drawing.Size(515, 65)
94
+ $form.Controls.Add($grpSummary)
95
+
96
+ $lblReq = New-Object System.Windows.Forms.Label
97
+ $lblReq.Location = New-Object System.Drawing.Point(20, 28)
98
+ $lblReq.Size = New-Object System.Drawing.Size(140, 20)
99
+ $lblReq.Text = "请求总数: -"
100
+ $grpSummary.Controls.Add($lblReq)
101
+
102
+ $lblIn = New-Object System.Windows.Forms.Label
103
+ $lblIn.Location = New-Object System.Drawing.Point(160, 28)
104
+ $lblIn.Size = New-Object System.Drawing.Size(160, 20)
105
+ $lblIn.Text = "输入 Token: -"
106
+ $lblIn.ForeColor = [System.Drawing.Color]::MediumBlue
107
+ $grpSummary.Controls.Add($lblIn)
108
+
109
+ $lblOut = New-Object System.Windows.Forms.Label
110
+ $lblOut.Location = New-Object System.Drawing.Point(340, 28)
111
+ $lblOut.Size = New-Object System.Drawing.Size(160, 20)
112
+ $lblOut.Text = "输出 Token: -"
113
+ $lblOut.ForeColor = [System.Drawing.Color]::ForestGreen
114
+ $grpSummary.Controls.Add($lblOut)
115
+
116
+ $lblFilter = New-Object System.Windows.Forms.Label
117
+ $lblFilter.Text = "时间筛选:"
118
+ $lblFilter.Location = New-Object System.Drawing.Point(15, 88)
119
+ $lblFilter.AutoSize = $true
120
+ $form.Controls.Add($lblFilter)
121
+
122
+ $cmbPeriod = New-Object System.Windows.Forms.ComboBox
123
+ $cmbPeriod.Items.AddRange(@("最近 1 天", "最近 7 天", "最近 30 天", "全部时间"))
124
+ $cmbPeriod.SelectedIndex = 3
125
+ $cmbPeriod.Location = New-Object System.Drawing.Point(80, 85)
126
+ $cmbPeriod.Size = New-Object System.Drawing.Size(120, 20)
127
+ $cmbPeriod.DropDownStyle = 'DropDownList'
128
+ $form.Controls.Add($cmbPeriod)
129
+
130
+ $lv = New-Object System.Windows.Forms.ListView
131
+ $lv.Location = New-Object System.Drawing.Point(15, 115)
132
+ $lv.Size = New-Object System.Drawing.Size(515, 310)
133
+ $lv.View = 'Details'
134
+ $lv.FullRowSelect = $true
135
+ $lv.GridLines = $true
136
+ $lv.Columns.Add("时间", 135) | Out-Null
137
+ $lv.Columns.Add("模型", 185) | Out-Null
138
+ $lv.Columns.Add("输入", 85) | Out-Null
139
+ $lv.Columns.Add("输出", 85) | Out-Null
140
+ $form.Controls.Add($lv)
141
+
142
+ $updateData = {
143
+ $map = @{"最近 1 天"="1"; "最近 7 天"="7"; "最近 30 天"="30"; "全部时间"="all"}
144
+ $p = $map[$cmbPeriod.SelectedItem.ToString()]
145
+ try {
146
+ $data = Invoke-RestMethod -Uri "http://127.0.0.1:50816/api/stats?period=$p" -Method Get -ErrorAction Stop
147
+
148
+ $lblReq.Text = "请求总数: {0:N0}" -f $data.totalReq
149
+ $lblIn.Text = "输入 Token: {0:N0}" -f $data.totalIn
150
+ $lblOut.Text = "输出 Token: {0:N0}" -f $data.totalOut
151
+
152
+ $lv.Items.Clear()
153
+ if ($data.recent) {
154
+ $lv.BeginUpdate()
155
+ foreach ($r in $data.recent) {
156
+ $dt = [timezone]::CurrentTimeZone.ToLocalTime(([datetime]'1/1/1970').AddMilliseconds($r.ts))
157
+ $item = New-Object System.Windows.Forms.ListViewItem($dt.ToString("yyyy-MM-dd HH:mm:ss"))
158
+ $item.SubItems.Add($r.m) | Out-Null
159
+ $item.SubItems.Add(("{0:N0} ↑" -f $r.i)) | Out-Null
160
+ $item.SubItems.Add(("{0:N0} ↓" -f $r.o)) | Out-Null
161
+ $lv.Items.Add($item) | Out-Null
162
+ }
163
+ $lv.EndUpdate()
164
+ }
165
+ } catch {
166
+ $lblReq.Text = "无法连接统计接口"
167
+ }
168
+ }
169
+
170
+ $cmbPeriod.Add_SelectedIndexChanged($updateData)
171
+ & $updateData
172
+
173
+ $form.Show()
174
+ }
175
+
176
+ # ─── 托盘菜单配置 ──────────────────────────────────────────
86
177
  $menu = New-Object System.Windows.Forms.ContextMenu
87
178
 
88
179
  $titleItem = New-Object System.Windows.Forms.MenuItem("Pan Router | :50816")
@@ -90,6 +181,12 @@ try {
90
181
  $menu.MenuItems.Add($titleItem) | Out-Null
91
182
  $menu.MenuItems.Add("-") | Out-Null
92
183
 
184
+ $dashItem = New-Object System.Windows.Forms.MenuItem("打开控制台 (面板)")
185
+ $dashItem.Add_Click({ Show-Dashboard })
186
+ $dashItem.DefaultItem = $true
187
+ $menu.MenuItems.Add($dashItem) | Out-Null
188
+ $menu.MenuItems.Add("-") | Out-Null
189
+
93
190
  $restartItem = New-Object System.Windows.Forms.MenuItem("重启服务")
94
191
  $restartItem.Add_Click({
95
192
  Start-Backend
@@ -104,7 +201,6 @@ try {
104
201
  reg delete "HKCU\Software\Microsoft\Windows\CurrentVersion\Run" /v PanRouter /f 2>&1 | Out-Null
105
202
  $autoItem.Checked = $false
106
203
  } else {
107
- # 【修复2】:写入注册表时,务必加上 -STA 参数防止 UI 闪退,并使用刚刚存好的 $trayScriptPath
108
204
  $cmd = "powershell.exe -WindowStyle Hidden -STA -ExecutionPolicy Bypass -File `"$trayScriptPath`""
109
205
  reg add "HKCU\Software\Microsoft\Windows\CurrentVersion\Run" /v PanRouter /t REG_SZ /d $cmd /f 2>&1 | Out-Null
110
206
  $autoItem.Checked = $true
@@ -123,20 +219,17 @@ try {
123
219
 
124
220
  $notifyIcon.ContextMenu = $menu
125
221
  $notifyIcon.Visible = $true
126
- "5. 托盘图标已绑定并设置为可见" | Out-File $logFile -Append
222
+
223
+ $notifyIcon.Add_DoubleClick({ Show-Dashboard })
127
224
 
128
225
  # ─── 隐形实体主窗口 ─────────────────────────────────────────
129
226
  $form = New-Object System.Windows.Forms.Form
130
227
  $form.ShowInTaskbar = $false
131
228
  $form.WindowState = [System.Windows.Forms.FormWindowState]::Minimized
132
229
  $form.Opacity = 0
133
- $form.Add_Load({
134
- $form.Hide()
135
- "6. 实体窗口初始化完毕,正式进入消息挂起循环..." | Out-File $logFile -Append
136
- })
230
+ $form.Add_Load({ $form.Hide() })
137
231
 
138
232
  [System.Windows.Forms.Application]::Run($form)
139
233
  } catch {
140
- "【致命报错】无法完成执行:" | Out-File $logFile -Append
141
234
  $_.Exception.Message | Out-File $logFile -Append
142
235
  }