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.
- package/cli.mjs +117 -59
- package/package.json +1 -1
- package/server.mjs +61 -87
- 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
|
-
|
|
66
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
141
|
+
// UTF-8 BOM
|
|
95
142
|
try {
|
|
96
143
|
const psContent = fs.readFileSync(psPath, "utf8");
|
|
97
|
-
if (
|
|
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
|
-
|
|
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
|
-
"-
|
|
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
|
-
|
|
145
|
-
|
|
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
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
package/server.mjs
CHANGED
|
@@ -1,20 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Pan Router
|
|
3
|
-
*
|
|
4
3
|
* 将 Claude Code 的 Anthropic 格式请求转发到 OpenCode Free,
|
|
5
|
-
*
|
|
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
|
-
// ───
|
|
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
|
-
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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({
|
|
312
|
+
res.end(JSON.stringify({ totalReq, totalIn, totalOut, recent }));
|
|
330
313
|
return;
|
|
331
314
|
}
|
|
332
315
|
|
|
333
|
-
|
|
316
|
+
// 基础接口
|
|
317
|
+
if (req.method === "GET" && url.pathname === "/health") {
|
|
334
318
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
335
|
-
res.end(JSON.stringify({
|
|
319
|
+
res.end(JSON.stringify({ status: "ok", port: PORT }));
|
|
336
320
|
return;
|
|
337
321
|
}
|
|
338
322
|
|
|
339
|
-
if (req.method === "
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
}
|