panrouter 3.6.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 +117 -59
  2. package/package.json +1 -1
  3. package/server.mjs +61 -87
  4. package/tray-daemon.ps1 +121 -28
package/cli.mjs CHANGED
@@ -10,6 +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 VERSION = "3.7.0";
13
14
 
14
15
  function log(label, msg, color = "") {
15
16
  const colors = { green: "\x1b[32m", yellow: "\x1b[33m", red: "\x1b[31m", cyan: "\x1b[36m", reset: "\x1b[0m" };
@@ -62,22 +63,66 @@ async function isPortOpen() {
62
63
  });
63
64
  }
64
65
 
65
- async function startServer() {
66
- const serverPath = path.join(__dirname, "server.mjs");
66
+ function stopAll() {
67
+ log("..", "正在停止所有 Pan Router 进程...", "yellow");
68
+ try {
69
+ execSync('taskkill /f /fi "WINDOWTITLE eq Pan Router*" >nul 2>&1', { stdio: "pipe" });
70
+ } catch {}
71
+ try {
72
+ execSync("taskkill /f /im powershell.exe >nul 2>&1", { stdio: "pipe" });
73
+ } catch {}
67
74
  try {
68
- if (process.platform === "win32") {
69
- execSync('taskkill /f /fi "WINDOWTITLE eq Pan Router*" >nul 2>&1', { stdio: "pipe" });
70
- } else {
71
- execSync("pkill -f 'node.*server.mjs' 2>/dev/null", { stdio: "pipe" });
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
+ }
72
84
  }
73
85
  } catch {}
86
+ log("OK", "已停止所有进程", "green");
87
+ }
74
88
 
75
- log("..", "正在启动代理...", "yellow");
76
- if (process.platform === "win32") {
77
- execSync(`start "Pan Router" cmd /c "node ${serverPath} & pause"`, { stdio: "pipe" });
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}"`);
78
115
  } else {
79
- spawn("node", [serverPath], { cwd: __dirname, stdio: "ignore", detached: true }).unref();
116
+ log("!!", "暂无托盘日志文件", "red");
80
117
  }
118
+ }
119
+
120
+ async function startServer() {
121
+ const serverPath = path.join(__dirname, "server.mjs");
122
+ stopAll();
123
+
124
+ log("..", "正在启动代理...", "yellow");
125
+ execSync(`start "Pan Router" cmd /c "node ${serverPath} & pause"`, { stdio: "pipe" });
81
126
 
82
127
  for (let i = 0; i < 15; i++) {
83
128
  if (await isPortOpen()) break;
@@ -89,62 +134,42 @@ async function startServer() {
89
134
  async function startTray() {
90
135
  const serverPath = path.join(__dirname, "server.mjs");
91
136
  const psPath = path.join(__dirname, "tray-daemon.ps1");
137
+
138
+ stopAll();
92
139
  log("..", "正在后台启动代理...", "yellow");
93
140
 
94
- // 【核心修复】:直接在原文件上追加 UTF-8 BOM 头,彻底修复乱码并保持原目录路径不变
141
+ // UTF-8 BOM
95
142
  try {
96
143
  const psContent = fs.readFileSync(psPath, "utf8");
97
- if (!psContent.startsWith("")) {
144
+ if (psContent.charCodeAt(0) !== 0xFEFF) {
98
145
  const bom = Buffer.from([0xEF, 0xBB, 0xBF]);
99
146
  fs.writeFileSync(psPath, Buffer.concat([bom, Buffer.from(psContent, "utf8")]));
100
147
  }
101
- } catch (e) {
102
- // 忽略权限问题
103
- }
104
-
105
- try {
106
- if (process.platform === "win32") {
107
- execSync('taskkill /f /fi "WINDOWTITLE eq Pan Router*" >nul 2>&1', { stdio: "pipe" });
108
- }
109
148
  } catch {}
110
149
 
111
150
  const srv = spawn(process.execPath, [serverPath], {
112
- cwd: __dirname,
113
- stdio: "ignore",
114
- windowsHide: true,
115
- detached: true
151
+ cwd: __dirname, stdio: "ignore", windowsHide: true, detached: true
116
152
  });
117
153
  srv.unref();
118
154
 
119
155
  let ok = false;
120
156
  for (let i = 0; i < 15; i++) {
121
- if (await isPortOpen()) {
122
- ok = true;
123
- break;
124
- }
157
+ if (await isPortOpen()) { ok = true; break; }
125
158
  await new Promise(rs => setTimeout(rs, 1000));
126
159
  }
127
160
 
128
- if (ok) {
129
- log("OK", "代理服务已就绪!(端口 50816)", "green");
130
- } else {
131
- log("!!", "服务启动超时,但仍将尝试加载托盘", "red");
132
- }
161
+ if (ok) log("OK", "代理服务已就绪!(端口 50816)", "green");
162
+ else log("!!", "服务启动超时,但仍将尝试加载托盘", "red");
133
163
 
134
164
  log("..", "正在加载系统托盘...", "yellow");
135
165
 
136
166
  const tray = spawn("powershell.exe", [
137
- "-NoProfile",
138
- "-STA",
139
- "-ExecutionPolicy", "Bypass",
140
- "-WindowStyle", "Hidden",
141
- "-File", `"${psPath}"`
167
+ "-NoProfile", "-STA", "-ExecutionPolicy", "Bypass",
168
+ "-WindowStyle", "Hidden", "-File", `"${psPath}"`,
142
169
  ], {
143
- cwd: __dirname,
144
- stdio: ['ignore', 'pipe', 'pipe'],
145
- windowsHide: true,
146
- shell: true,
147
- env: { ...process.env, PANROUTER_NODE: process.execPath }
170
+ cwd: __dirname, stdio: ["ignore", "pipe", "pipe"],
171
+ windowsHide: true, shell: true,
172
+ env: { ...process.env, PANROUTER_NODE: process.execPath },
148
173
  });
149
174
 
150
175
  let psOutput = "";
@@ -158,28 +183,61 @@ async function startTray() {
158
183
  console.log(`\n\x1b[31m=== PowerShell 启动失败原因 ===\x1b[0m\n${psOutput || "(无输出)"}\n\x1b[31m===============================\x1b[0m\n`);
159
184
  } else {
160
185
  tray.unref();
161
- console.log(" 托盘图标应该已在右下角显示。");
186
+ console.log(" 托盘图标已在右下角显示。双击图标即可打开原生数据控制台。");
162
187
  }
163
188
  }
164
189
 
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
+ }
206
+
165
207
  async function main() {
166
208
  const args = process.argv.slice(2);
167
- if (args.includes("--help") || args.includes("-h")) {
168
- console.log("用法:\n panrouter --server (带命令行窗口运行)\n panrouter --tray (后台隐藏运行 + 托盘)\n panrouter --tray-install (安装配置 + 托盘)");
169
- return;
170
- }
171
-
172
- if (args.includes("--server") || args.includes("-s")) { await startServer(); return; }
173
- if (args.includes("--tray") || args.includes("-t")) { await startTray(); return; }
209
+ const cmd = args[0];
174
210
 
175
- console.log(`\n\x1b[36m=== Pan Router - Claude Code ===\x1b[0m\n`);
176
- if (!installClaudeCode()) return process.exit(1);
177
- writeConfig();
178
-
179
- if (args.includes("--tray-install") || args.includes("-ti")) {
180
- await startTray();
181
- } else {
182
- await startServer();
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;
183
241
  }
184
242
  }
185
243
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "panrouter",
3
- "version": "3.6.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
  }