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