panrouter 3.7.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli.mjs +150 -179
- package/package.json +1 -1
- package/server.mjs +61 -87
- package/tray-daemon.ps1 +121 -28
package/cli.mjs
CHANGED
|
@@ -10,7 +10,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
|
10
10
|
const HOME = process.env.USERPROFILE || process.env.HOME;
|
|
11
11
|
const CLAUDE_DIR = path.join(HOME, ".claude");
|
|
12
12
|
const SETTINGS_PATH = path.join(CLAUDE_DIR, "settings.json");
|
|
13
|
-
const
|
|
13
|
+
const VERSION = "3.7.0";
|
|
14
14
|
|
|
15
15
|
function log(label, msg, color = "") {
|
|
16
16
|
const colors = { green: "\x1b[32m", yellow: "\x1b[33m", red: "\x1b[31m", cyan: "\x1b[36m", reset: "\x1b[0m" };
|
|
@@ -18,13 +18,28 @@ function log(label, msg, color = "") {
|
|
|
18
18
|
console.log(`${c}[${label}]${colors.reset} ${msg}`);
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
function installClaudeCode() {
|
|
22
|
+
log("..", "正在检查 Claude Code...", "yellow");
|
|
23
|
+
try {
|
|
24
|
+
execSync("claude --version", { stdio: "pipe" });
|
|
25
|
+
log("OK", "Claude Code 已就绪", "green");
|
|
26
|
+
return true;
|
|
27
|
+
} catch {
|
|
28
|
+
log("..", "正在安装 Claude Code...", "yellow");
|
|
29
|
+
try {
|
|
30
|
+
execSync("npm install -g @anthropic-ai/claude-code", { stdio: "inherit" });
|
|
31
|
+
log("OK", "Claude Code 安装成功", "green");
|
|
32
|
+
return true;
|
|
33
|
+
} catch {
|
|
34
|
+
log("!!", "Claude Code 安装失败", "red");
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
22
39
|
|
|
23
40
|
function writeConfig() {
|
|
24
41
|
log("..", "正在配置 Claude Code 路由...", "yellow");
|
|
25
42
|
if (!fs.existsSync(CLAUDE_DIR)) fs.mkdirSync(CLAUDE_DIR, { recursive: true });
|
|
26
|
-
if (fs.existsSync(SETTINGS_PATH)) fs.copyFileSync(SETTINGS_PATH, BACKUP_PATH);
|
|
27
|
-
|
|
28
43
|
const config = {
|
|
29
44
|
env: {
|
|
30
45
|
ANTHROPIC_BASE_URL: "http://127.0.0.1:50816",
|
|
@@ -39,27 +54,6 @@ function writeConfig() {
|
|
|
39
54
|
log("OK", "配置完成", "green");
|
|
40
55
|
}
|
|
41
56
|
|
|
42
|
-
// ─── 安装 Claude Code ────────────────────────────────────
|
|
43
|
-
|
|
44
|
-
function ensureClaudeCode() {
|
|
45
|
-
try {
|
|
46
|
-
execSync("claude --version", { stdio: "pipe" });
|
|
47
|
-
return true; // 已装
|
|
48
|
-
} catch {
|
|
49
|
-
log("..", "正在安装 Claude Code...", "yellow");
|
|
50
|
-
try {
|
|
51
|
-
execSync("npm install -g @anthropic-ai/claude-code", { stdio: "inherit", timeout: 120000 });
|
|
52
|
-
log("OK", "Claude Code 安装成功", "green");
|
|
53
|
-
return true;
|
|
54
|
-
} catch {
|
|
55
|
-
log("!!", "Claude Code 安装失败", "red");
|
|
56
|
-
return false;
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// ─── 端口检测 ────────────────────────────────────────────
|
|
62
|
-
|
|
63
57
|
async function isPortOpen() {
|
|
64
58
|
return new Promise(rs => {
|
|
65
59
|
const req = http.get("http://127.0.0.1:50816/health", () => {});
|
|
@@ -69,13 +63,63 @@ async function isPortOpen() {
|
|
|
69
63
|
});
|
|
70
64
|
}
|
|
71
65
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
async function startServer() {
|
|
75
|
-
const serverPath = path.join(__dirname, "server.mjs");
|
|
66
|
+
function stopAll() {
|
|
67
|
+
log("..", "正在停止所有 Pan Router 进程...", "yellow");
|
|
76
68
|
try {
|
|
77
69
|
execSync('taskkill /f /fi "WINDOWTITLE eq Pan Router*" >nul 2>&1', { stdio: "pipe" });
|
|
78
70
|
} catch {}
|
|
71
|
+
try {
|
|
72
|
+
execSync("taskkill /f /im powershell.exe >nul 2>&1", { stdio: "pipe" });
|
|
73
|
+
} catch {}
|
|
74
|
+
try {
|
|
75
|
+
const out = execSync(
|
|
76
|
+
'wmic process where "name=\'node.exe\'" get ProcessId,CommandLine /format:csv 2>nul',
|
|
77
|
+
{ encoding: "utf8", windowsHide: true, timeout: 5000 }
|
|
78
|
+
);
|
|
79
|
+
for (const line of out.split("\n")) {
|
|
80
|
+
if (line.includes("server.mjs")) {
|
|
81
|
+
const m = line.match(/(\d+),.*?server\.mjs/);
|
|
82
|
+
if (m) try { process.kill(parseInt(m[1]), "SIGKILL"); } catch {}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
} catch {}
|
|
86
|
+
log("OK", "已停止所有进程", "green");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function showStatus() {
|
|
90
|
+
console.log(`\n\x1b[36m=== Pan Router 状态 ===\x1b[0m\n`);
|
|
91
|
+
try {
|
|
92
|
+
const out = execSync(
|
|
93
|
+
'wmic process where "name=\'node.exe\'" get ProcessId,CommandLine /format:csv 2>nul',
|
|
94
|
+
{ encoding: "utf8", timeout: 5000 }
|
|
95
|
+
);
|
|
96
|
+
const nodePids = [];
|
|
97
|
+
for (const line of out.split("\n")) {
|
|
98
|
+
if (line.includes("server.mjs")) {
|
|
99
|
+
const m = line.match(/(\d+),/);
|
|
100
|
+
if (m) nodePids.push(m[1]);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (nodePids.length > 0) log("OK", `代理服务 (Node): 运行中 [PID: ${nodePids.join(", ")}]`, "green");
|
|
104
|
+
else log("!!", "代理服务 (Node): 未运行", "red");
|
|
105
|
+
} catch {
|
|
106
|
+
log("!!", "无法获取状态", "red");
|
|
107
|
+
}
|
|
108
|
+
console.log("");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function openLogs() {
|
|
112
|
+
const logFile = path.join(process.env.TEMP, "panrouter_tray.log");
|
|
113
|
+
if (fs.existsSync(logFile)) {
|
|
114
|
+
execSync(`start notepad "${logFile}"`);
|
|
115
|
+
} else {
|
|
116
|
+
log("!!", "暂无托盘日志文件", "red");
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function startServer() {
|
|
121
|
+
const serverPath = path.join(__dirname, "server.mjs");
|
|
122
|
+
stopAll();
|
|
79
123
|
|
|
80
124
|
log("..", "正在启动代理...", "yellow");
|
|
81
125
|
execSync(`start "Pan Router" cmd /c "node ${serverPath} & pause"`, { stdio: "pipe" });
|
|
@@ -84,190 +128,117 @@ async function startServer() {
|
|
|
84
128
|
if (await isPortOpen()) break;
|
|
85
129
|
await new Promise(rs => setTimeout(rs, 1000));
|
|
86
130
|
}
|
|
87
|
-
log("OK", "Pan Router
|
|
131
|
+
log("OK", "Pan Router 运行中,可以执行 claude 命令了", "green");
|
|
88
132
|
}
|
|
89
133
|
|
|
90
|
-
// ─── 启动托盘(后台) ────────────────────────────────────
|
|
91
|
-
|
|
92
134
|
async function startTray() {
|
|
93
135
|
const serverPath = path.join(__dirname, "server.mjs");
|
|
94
136
|
const psPath = path.join(__dirname, "tray-daemon.ps1");
|
|
95
137
|
|
|
96
|
-
|
|
138
|
+
stopAll();
|
|
139
|
+
log("..", "正在后台启动代理...", "yellow");
|
|
140
|
+
|
|
141
|
+
// UTF-8 BOM
|
|
97
142
|
try {
|
|
98
|
-
const
|
|
99
|
-
if (
|
|
143
|
+
const psContent = fs.readFileSync(psPath, "utf8");
|
|
144
|
+
if (psContent.charCodeAt(0) !== 0xFEFF) {
|
|
100
145
|
const bom = Buffer.from([0xEF, 0xBB, 0xBF]);
|
|
101
|
-
fs.writeFileSync(psPath, Buffer.concat([bom, Buffer.from(
|
|
146
|
+
fs.writeFileSync(psPath, Buffer.concat([bom, Buffer.from(psContent, "utf8")]));
|
|
102
147
|
}
|
|
103
148
|
} catch {}
|
|
104
149
|
|
|
105
|
-
// 清理旧进程
|
|
106
|
-
try { execSync('taskkill /f /fi "WINDOWTITLE eq Pan Router*" >nul 2>&1', { stdio: "pipe" }); } catch {}
|
|
107
|
-
|
|
108
|
-
// 启动 server(隐藏)
|
|
109
150
|
const srv = spawn(process.execPath, [serverPath], {
|
|
110
|
-
cwd: __dirname, stdio: "ignore", windowsHide: true, detached: true
|
|
151
|
+
cwd: __dirname, stdio: "ignore", windowsHide: true, detached: true
|
|
111
152
|
});
|
|
112
153
|
srv.unref();
|
|
113
154
|
|
|
114
|
-
// 启动托盘(隐藏)
|
|
115
|
-
const tray = spawn("powershell.exe", [
|
|
116
|
-
"-NoProfile", "-STA", "-ExecutionPolicy", "Bypass",
|
|
117
|
-
"-WindowStyle", "Hidden", "-File", `"${psPath}"`,
|
|
118
|
-
], {
|
|
119
|
-
cwd: __dirname, stdio: "ignore", windowsHide: true, shell: true,
|
|
120
|
-
env: { ...process.env, PANROUTER_NODE: process.execPath },
|
|
121
|
-
});
|
|
122
|
-
tray.unref();
|
|
123
|
-
|
|
124
|
-
// 验证端口
|
|
125
155
|
let ok = false;
|
|
126
156
|
for (let i = 0; i < 15; i++) {
|
|
127
157
|
if (await isPortOpen()) { ok = true; break; }
|
|
128
158
|
await new Promise(rs => setTimeout(rs, 1000));
|
|
129
159
|
}
|
|
130
160
|
|
|
131
|
-
if (ok)
|
|
132
|
-
|
|
133
|
-
} else {
|
|
134
|
-
log("!!", "代理启动超时,请检查端口 50816", "red");
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// ─── 停止 ────────────────────────────────────────────────
|
|
139
|
-
|
|
140
|
-
function stopAll() {
|
|
141
|
-
log("..", "正在停止...", "yellow");
|
|
142
|
-
try { execSync('taskkill /f /fi "WINDOWTITLE eq Pan Router*" >nul 2>&1', { stdio: "pipe" }); } catch {}
|
|
143
|
-
try { execSync("taskkill /f /im powershell.exe >nul 2>&1", { stdio: "pipe" }); } catch {}
|
|
144
|
-
// 杀 server.mjs 相关 node 进程
|
|
145
|
-
try {
|
|
146
|
-
const out = execSync(
|
|
147
|
-
'wmic process where "name=\'node.exe\'" get ProcessId,CommandLine /format:csv 2>nul',
|
|
148
|
-
{ encoding: "utf8", windowsHide: true, timeout: 3000 }
|
|
149
|
-
);
|
|
150
|
-
for (const line of out.split("\n")) {
|
|
151
|
-
if (line.includes("server.mjs")) {
|
|
152
|
-
const m = line.match(/(\d+),.*?server\.mjs/);
|
|
153
|
-
if (m) try { process.kill(parseInt(m[1]), "SIGKILL"); } catch {}
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
} catch {}
|
|
157
|
-
log("OK", "已停止", "green");
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
// ─── 状态 ────────────────────────────────────────────────
|
|
161
|
+
if (ok) log("OK", "代理服务已就绪!(端口 50816)", "green");
|
|
162
|
+
else log("!!", "服务启动超时,但仍将尝试加载托盘", "red");
|
|
161
163
|
|
|
162
|
-
|
|
163
|
-
const online = await isPortOpen();
|
|
164
|
-
const version = "3.6.0";
|
|
165
|
-
console.log(`\n Pan Router v${version}`);
|
|
166
|
-
console.log(` 端口 50816: ${online ? "✓ 运行中" : "✗ 未启动"}`);
|
|
167
|
-
if (online) {
|
|
168
|
-
try {
|
|
169
|
-
const out = execSync(
|
|
170
|
-
'wmic process where "name=\'node.exe\'" get ProcessId,CommandLine /format:csv 2>nul',
|
|
171
|
-
{ encoding: "utf8", windowsHide: true, timeout: 3000 }
|
|
172
|
-
);
|
|
173
|
-
for (const line of out.split("\n")) {
|
|
174
|
-
if (line.includes("server.mjs")) {
|
|
175
|
-
const m = line.match(/(\d+),.*?server\.mjs/);
|
|
176
|
-
if (m) console.log(` PID: ${m[1]}`);
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
} catch {}
|
|
180
|
-
}
|
|
181
|
-
console.log("");
|
|
182
|
-
}
|
|
164
|
+
log("..", "正在加载系统托盘...", "yellow");
|
|
183
165
|
|
|
184
|
-
|
|
166
|
+
const tray = spawn("powershell.exe", [
|
|
167
|
+
"-NoProfile", "-STA", "-ExecutionPolicy", "Bypass",
|
|
168
|
+
"-WindowStyle", "Hidden", "-File", `"${psPath}"`,
|
|
169
|
+
], {
|
|
170
|
+
cwd: __dirname, stdio: ["ignore", "pipe", "pipe"],
|
|
171
|
+
windowsHide: true, shell: true,
|
|
172
|
+
env: { ...process.env, PANROUTER_NODE: process.execPath },
|
|
173
|
+
});
|
|
185
174
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
175
|
+
let psOutput = "";
|
|
176
|
+
tray.stdout.on("data", d => psOutput += d.toString());
|
|
177
|
+
tray.stderr.on("data", d => psOutput += d.toString());
|
|
189
178
|
|
|
190
|
-
|
|
179
|
+
await new Promise(rs => setTimeout(rs, 2500));
|
|
191
180
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
execSync(`notepad "${logPath}"`, { stdio: "inherit" });
|
|
181
|
+
if (tray.exitCode !== null) {
|
|
182
|
+
log("!!", "托盘进程未能驻留,发生闪退!", "red");
|
|
183
|
+
console.log(`\n\x1b[31m=== PowerShell 启动失败原因 ===\x1b[0m\n${psOutput || "(无输出)"}\n\x1b[31m===============================\x1b[0m\n`);
|
|
196
184
|
} else {
|
|
197
|
-
|
|
185
|
+
tray.unref();
|
|
186
|
+
console.log(" 托盘图标已在右下角显示。双击图标即可打开原生数据控制台。");
|
|
198
187
|
}
|
|
199
188
|
}
|
|
200
189
|
|
|
201
|
-
|
|
190
|
+
function printHelp() {
|
|
191
|
+
console.log(`
|
|
192
|
+
\x1b[36m=== Pan Router v${VERSION} ===\x1b[0m
|
|
193
|
+
|
|
194
|
+
| 指令 | 功能 |
|
|
195
|
+
|------|------|
|
|
196
|
+
| \x1b[33mpanrouter\x1b[0m | 自动检测 Claude Code → 安装(如需要) → 配路由 → 启托盘 |
|
|
197
|
+
| \x1b[33mpanrouter --setup\x1b[0m | 只配路由(恢复配置用) |
|
|
198
|
+
| \x1b[33mpanrouter --status\x1b[0m | 查看运行状态 + PID |
|
|
199
|
+
| \x1b[33mpanrouter --stop\x1b[0m | 停止所有进程 |
|
|
200
|
+
| \x1b[33mpanrouter --server\x1b[0m | 前台窗口模式 |
|
|
201
|
+
| \x1b[33mpanrouter --logs\x1b[0m | 打开日志 |
|
|
202
|
+
| \x1b[33mpanrouter --version\x1b[0m | 版本号 |
|
|
203
|
+
| \x1b[33mpanrouter --help\x1b[0m | 帮助 |
|
|
204
|
+
`);
|
|
205
|
+
}
|
|
202
206
|
|
|
203
207
|
async function main() {
|
|
204
208
|
const args = process.argv.slice(2);
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
if (args.includes("--logs")) {
|
|
238
|
-
openLogs();
|
|
239
|
-
return;
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
// --stop
|
|
243
|
-
if (args.includes("--stop")) {
|
|
244
|
-
stopAll();
|
|
245
|
-
return;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
// --setup(只配置)
|
|
249
|
-
if (args.includes("--setup")) {
|
|
250
|
-
writeConfig();
|
|
251
|
-
return;
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
// --server(前台窗口)
|
|
255
|
-
if (args.includes("--server") || args.includes("-s")) {
|
|
256
|
-
await startServer();
|
|
257
|
-
return;
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
// --tray(显式指定,兼容旧指令)
|
|
261
|
-
if (args.includes("--tray") || args.includes("-t")) {
|
|
262
|
-
writeConfig();
|
|
263
|
-
await startTray();
|
|
264
|
-
return;
|
|
209
|
+
const cmd = args[0];
|
|
210
|
+
|
|
211
|
+
switch (cmd) {
|
|
212
|
+
case "--help":
|
|
213
|
+
case "-h":
|
|
214
|
+
printHelp();
|
|
215
|
+
break;
|
|
216
|
+
case "--version":
|
|
217
|
+
case "-v":
|
|
218
|
+
console.log(`v${VERSION}`);
|
|
219
|
+
break;
|
|
220
|
+
case "--setup":
|
|
221
|
+
writeConfig();
|
|
222
|
+
break;
|
|
223
|
+
case "--status":
|
|
224
|
+
showStatus();
|
|
225
|
+
break;
|
|
226
|
+
case "--stop":
|
|
227
|
+
stopAll();
|
|
228
|
+
break;
|
|
229
|
+
case "--logs":
|
|
230
|
+
openLogs();
|
|
231
|
+
break;
|
|
232
|
+
case "--server":
|
|
233
|
+
await startServer();
|
|
234
|
+
break;
|
|
235
|
+
default:
|
|
236
|
+
console.log(`\n\x1b[36m=== Pan Router v${VERSION} ===\x1b[0m\n`);
|
|
237
|
+
if (!installClaudeCode()) return process.exit(1);
|
|
238
|
+
writeConfig();
|
|
239
|
+
await startTray();
|
|
240
|
+
break;
|
|
265
241
|
}
|
|
266
|
-
|
|
267
|
-
// 默认:安装 + 配置 + 托盘
|
|
268
|
-
if (!ensureClaudeCode()) process.exit(1);
|
|
269
|
-
writeConfig();
|
|
270
|
-
await startTray();
|
|
271
242
|
}
|
|
272
243
|
|
|
273
244
|
main();
|
package/package.json
CHANGED
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
|
}
|