protocol-proxy 2.8.2 → 2.9.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/lib/conversation-store.js +108 -0
- package/lib/converters/openai-to-anthropic.js +4 -0
- package/lib/proxy-server.js +46 -5
- package/package.json +1 -1
- package/public/app.js +747 -0
- package/public/index.html +103 -0
- package/public/style.css +514 -0
- package/server.js +2327 -1226
package/server.js
CHANGED
|
@@ -1,1226 +1,2327 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
const path = require('path');
|
|
3
|
-
const { exec, spawn } = require('child_process');
|
|
4
|
-
const os = require('os');
|
|
5
|
-
const fs = require('fs');
|
|
6
|
-
const logger = require('./lib/logger');
|
|
7
|
-
|
|
8
|
-
// ==================== CLI ====================
|
|
9
|
-
|
|
10
|
-
const PID_FILE = path.join(os.tmpdir(), 'protocol-proxy.pid');
|
|
11
|
-
const pkg = require('./package.json');
|
|
12
|
-
|
|
13
|
-
function writePid() {
|
|
14
|
-
try { fs.writeFileSync(PID_FILE, String(process.pid)); } catch (err) {
|
|
15
|
-
console.error('[PID] 写入失败:', err.message);
|
|
16
|
-
}
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
function readPid() {
|
|
20
|
-
try { return parseInt(fs.readFileSync(PID_FILE, 'utf8').trim()); } catch { return null; }
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function removePid() {
|
|
24
|
-
try { fs.unlinkSync(PID_FILE); } catch (err) {
|
|
25
|
-
if (err.code !== 'ENOENT') {
|
|
26
|
-
console.error('[PID] 删除失败:', err.message);
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function isProcessAlive(pid) {
|
|
32
|
-
try { process.kill(pid, 0); return true; } catch { return false; }
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function showHelp() {
|
|
36
|
-
console.log(`
|
|
37
|
-
protocol-proxy - OpenAI / Anthropic 协议转换透明代理
|
|
38
|
-
|
|
39
|
-
用法:
|
|
40
|
-
protocol-proxy 前台启动服务(Ctrl+C 停止)
|
|
41
|
-
protocol-proxy start 后台启动服务
|
|
42
|
-
protocol-proxy stop 停止后台服务
|
|
43
|
-
protocol-proxy status 查看运行状态
|
|
44
|
-
protocol-proxy help 显示帮助信息
|
|
45
|
-
protocol-proxy -v, --version 显示版本号
|
|
46
|
-
protocol-proxy update 更新到最新版本
|
|
47
|
-
`);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function startDaemon() {
|
|
51
|
-
const pid = readPid();
|
|
52
|
-
if (pid && isProcessAlive(pid)) {
|
|
53
|
-
console.log(`服务已在运行 (PID: ${pid})`);
|
|
54
|
-
return;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
const child = spawn(process.execPath, [__filename, '--daemon'], {
|
|
58
|
-
detached: true,
|
|
59
|
-
stdio: 'ignore',
|
|
60
|
-
});
|
|
61
|
-
fs.writeFileSync(PID_FILE, String(child.pid));
|
|
62
|
-
child.unref();
|
|
63
|
-
console.log(`服务已在后台启动 (PID: ${child.pid})`);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function showVersion() {
|
|
67
|
-
console.log(pkg.version);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function showStatus() {
|
|
71
|
-
const pid = readPid();
|
|
72
|
-
if (pid && isProcessAlive(pid)) {
|
|
73
|
-
console.log(`服务正在运行 (PID: ${pid})`);
|
|
74
|
-
const configStore = require('./lib/config-store');
|
|
75
|
-
const proxies = configStore.getProxies();
|
|
76
|
-
if (proxies.length > 0) {
|
|
77
|
-
console.log(`\n已配置的代理 (${proxies.length} 个):`);
|
|
78
|
-
for (const p of proxies) {
|
|
79
|
-
console.log(` - ${p.name}: 端口 ${p.port} → ${p.target?.providerUrl || '未设置'}`);
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
} else {
|
|
83
|
-
removePid();
|
|
84
|
-
console.log('服务未运行');
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function stopService() {
|
|
89
|
-
const pid = readPid();
|
|
90
|
-
if (!pid || !isProcessAlive(pid)) {
|
|
91
|
-
removePid();
|
|
92
|
-
console.log('服务未运行');
|
|
93
|
-
return;
|
|
94
|
-
}
|
|
95
|
-
try {
|
|
96
|
-
process.kill(pid, 'SIGTERM');
|
|
97
|
-
removePid();
|
|
98
|
-
console.log(`服务已停止 (PID: ${pid})`);
|
|
99
|
-
} catch (err) {
|
|
100
|
-
console.error('停止服务失败:', err.message);
|
|
101
|
-
removePid();
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function updateService() {
|
|
106
|
-
console.log('正在更新 protocol-proxy...');
|
|
107
|
-
exec('npm install -g protocol-proxy@latest', (err, stdout, stderr) => {
|
|
108
|
-
if (err) {
|
|
109
|
-
console.error('更新失败:', err.message);
|
|
110
|
-
process.exit(1);
|
|
111
|
-
}
|
|
112
|
-
if (stdout) console.log(stdout);
|
|
113
|
-
if (stderr) console.error(stderr);
|
|
114
|
-
console.log('更新完成');
|
|
115
|
-
});
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// ==================== 启动 ====================
|
|
119
|
-
|
|
120
|
-
async function init() {
|
|
121
|
-
const express = require('express');
|
|
122
|
-
const cors = require('cors');
|
|
123
|
-
const configStore = require('./lib/config-store');
|
|
124
|
-
const proxyManager = require('./lib/proxy-manager');
|
|
125
|
-
const statsStore = require('./lib/stats-store');
|
|
126
|
-
|
|
127
|
-
const app = express();
|
|
128
|
-
const PORT = process.env.ADMIN_PORT || 3000;
|
|
129
|
-
|
|
130
|
-
function openBrowser(url) {
|
|
131
|
-
const platform = os.platform();
|
|
132
|
-
let command;
|
|
133
|
-
if (platform === 'win32') {
|
|
134
|
-
command = `start "" "${url}"`;
|
|
135
|
-
} else if (platform === 'darwin') {
|
|
136
|
-
command = `open "${url}"`;
|
|
137
|
-
} else {
|
|
138
|
-
command = `xdg-open "${url}"`;
|
|
139
|
-
}
|
|
140
|
-
exec(command, (err) => {
|
|
141
|
-
if (err) logger.error('[Browser] 打开浏览器失败:', err.message);
|
|
142
|
-
});
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
app.use(cors());
|
|
146
|
-
app.use(express.json());
|
|
147
|
-
|
|
148
|
-
// 访问日志
|
|
149
|
-
app.use((req, res, next) => {
|
|
150
|
-
const start = Date.now();
|
|
151
|
-
res.on('finish', () => {
|
|
152
|
-
const duration = Date.now() - start;
|
|
153
|
-
logger.log(`[HTTP] ${req.method} ${req.path} - ${res.statusCode} (${duration}ms)`);
|
|
154
|
-
});
|
|
155
|
-
next();
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
app.use(express.static(path.join(__dirname, 'public')));
|
|
159
|
-
|
|
160
|
-
// ==================== 辅助函数 ====================
|
|
161
|
-
|
|
162
|
-
function resolveTarget(proxy) {
|
|
163
|
-
const primaryProvider = configStore.getProviderById(proxy.providerId);
|
|
164
|
-
if (!primaryProvider) return null;
|
|
165
|
-
|
|
166
|
-
const pool = [];
|
|
167
|
-
const seen = new Set();
|
|
168
|
-
|
|
169
|
-
// Primary provider (no model override)
|
|
170
|
-
const primaryKey = `${primaryProvider.id}\0`;
|
|
171
|
-
seen.add(primaryKey);
|
|
172
|
-
pool.push({
|
|
173
|
-
providerId: primaryProvider.id,
|
|
174
|
-
providerName: primaryProvider.name,
|
|
175
|
-
providerUrl: primaryProvider.url,
|
|
176
|
-
protocol: primaryProvider.protocol,
|
|
177
|
-
apiKeys: primaryProvider.apiKeys || [],
|
|
178
|
-
models: primaryProvider.models,
|
|
179
|
-
azureDeployment: primaryProvider.azureDeployment || '',
|
|
180
|
-
azureApiVersion: primaryProvider.azureApiVersion || '',
|
|
181
|
-
model: '',
|
|
182
|
-
weight: Math.max(1, parseInt(proxy.providerWeight, 10) || 1),
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
// Pool entries (may include model override)
|
|
186
|
-
for (const entry of (proxy.providerPool || [])) {
|
|
187
|
-
if (!entry || !entry.providerId) continue;
|
|
188
|
-
const model = typeof entry.model === 'string' ? entry.model.trim() : '';
|
|
189
|
-
const key = `${entry.providerId}\0${model}`;
|
|
190
|
-
if (seen.has(key)) continue;
|
|
191
|
-
seen.add(key);
|
|
192
|
-
const provider = configStore.getProviderById(entry.providerId);
|
|
193
|
-
if (!provider) continue;
|
|
194
|
-
pool.push({
|
|
195
|
-
providerId: provider.id,
|
|
196
|
-
providerName: provider.name,
|
|
197
|
-
providerUrl: provider.url,
|
|
198
|
-
protocol: provider.protocol,
|
|
199
|
-
apiKeys: provider.apiKeys || [],
|
|
200
|
-
models: provider.models,
|
|
201
|
-
azureDeployment: provider.azureDeployment || '',
|
|
202
|
-
azureApiVersion: provider.azureApiVersion || '',
|
|
203
|
-
model,
|
|
204
|
-
weight: Math.max(1, parseInt(entry.weight, 10) || 1),
|
|
205
|
-
});
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
if (pool.length === 0) return null;
|
|
209
|
-
|
|
210
|
-
return {
|
|
211
|
-
protocol: pool[0].protocol,
|
|
212
|
-
routingStrategy: proxy.routingStrategy || 'primary_fallback',
|
|
213
|
-
providerPool: pool,
|
|
214
|
-
defaultModel: proxy.defaultModel,
|
|
215
|
-
};
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
function normalizeProviderPoolInput(pool) {
|
|
219
|
-
if (!Array.isArray(pool)) return [];
|
|
220
|
-
const seen = new Set();
|
|
221
|
-
const result = [];
|
|
222
|
-
for (const item of pool) {
|
|
223
|
-
if (!item || typeof item !== 'object') continue;
|
|
224
|
-
const providerId = typeof item.providerId === 'string' ? item.providerId.trim() : '';
|
|
225
|
-
if (!providerId) continue;
|
|
226
|
-
const model = typeof item.model === 'string' ? item.model.trim() : '';
|
|
227
|
-
const key = `${providerId}\0${model}`;
|
|
228
|
-
if (seen.has(key)) continue;
|
|
229
|
-
seen.add(key);
|
|
230
|
-
result.push({
|
|
231
|
-
providerId,
|
|
232
|
-
model,
|
|
233
|
-
weight: Math.max(1, parseInt(item.weight, 10) || 1),
|
|
234
|
-
});
|
|
235
|
-
}
|
|
236
|
-
return result;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
function normalizeRoutingStrategyInput(strategy) {
|
|
240
|
-
return ['primary_fallback', 'round_robin', 'weighted', 'fastest'].includes(strategy)
|
|
241
|
-
? strategy
|
|
242
|
-
: 'primary_fallback';
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
})
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
const
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
}
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
const
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
const
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
const
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
}
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
const
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { exec, spawn } = require('child_process');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const logger = require('./lib/logger');
|
|
7
|
+
|
|
8
|
+
// ==================== CLI ====================
|
|
9
|
+
|
|
10
|
+
const PID_FILE = path.join(os.tmpdir(), 'protocol-proxy.pid');
|
|
11
|
+
const pkg = require('./package.json');
|
|
12
|
+
|
|
13
|
+
function writePid() {
|
|
14
|
+
try { fs.writeFileSync(PID_FILE, String(process.pid)); } catch (err) {
|
|
15
|
+
console.error('[PID] 写入失败:', err.message);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function readPid() {
|
|
20
|
+
try { return parseInt(fs.readFileSync(PID_FILE, 'utf8').trim()); } catch { return null; }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function removePid() {
|
|
24
|
+
try { fs.unlinkSync(PID_FILE); } catch (err) {
|
|
25
|
+
if (err.code !== 'ENOENT') {
|
|
26
|
+
console.error('[PID] 删除失败:', err.message);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isProcessAlive(pid) {
|
|
32
|
+
try { process.kill(pid, 0); return true; } catch { return false; }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function showHelp() {
|
|
36
|
+
console.log(`
|
|
37
|
+
protocol-proxy - OpenAI / Anthropic 协议转换透明代理
|
|
38
|
+
|
|
39
|
+
用法:
|
|
40
|
+
protocol-proxy 前台启动服务(Ctrl+C 停止)
|
|
41
|
+
protocol-proxy start 后台启动服务
|
|
42
|
+
protocol-proxy stop 停止后台服务
|
|
43
|
+
protocol-proxy status 查看运行状态
|
|
44
|
+
protocol-proxy help 显示帮助信息
|
|
45
|
+
protocol-proxy -v, --version 显示版本号
|
|
46
|
+
protocol-proxy update 更新到最新版本
|
|
47
|
+
`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function startDaemon() {
|
|
51
|
+
const pid = readPid();
|
|
52
|
+
if (pid && isProcessAlive(pid)) {
|
|
53
|
+
console.log(`服务已在运行 (PID: ${pid})`);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const child = spawn(process.execPath, [__filename, '--daemon'], {
|
|
58
|
+
detached: true,
|
|
59
|
+
stdio: 'ignore',
|
|
60
|
+
});
|
|
61
|
+
fs.writeFileSync(PID_FILE, String(child.pid));
|
|
62
|
+
child.unref();
|
|
63
|
+
console.log(`服务已在后台启动 (PID: ${child.pid})`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function showVersion() {
|
|
67
|
+
console.log(pkg.version);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function showStatus() {
|
|
71
|
+
const pid = readPid();
|
|
72
|
+
if (pid && isProcessAlive(pid)) {
|
|
73
|
+
console.log(`服务正在运行 (PID: ${pid})`);
|
|
74
|
+
const configStore = require('./lib/config-store');
|
|
75
|
+
const proxies = configStore.getProxies();
|
|
76
|
+
if (proxies.length > 0) {
|
|
77
|
+
console.log(`\n已配置的代理 (${proxies.length} 个):`);
|
|
78
|
+
for (const p of proxies) {
|
|
79
|
+
console.log(` - ${p.name}: 端口 ${p.port} → ${p.target?.providerUrl || '未设置'}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
} else {
|
|
83
|
+
removePid();
|
|
84
|
+
console.log('服务未运行');
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function stopService() {
|
|
89
|
+
const pid = readPid();
|
|
90
|
+
if (!pid || !isProcessAlive(pid)) {
|
|
91
|
+
removePid();
|
|
92
|
+
console.log('服务未运行');
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
process.kill(pid, 'SIGTERM');
|
|
97
|
+
removePid();
|
|
98
|
+
console.log(`服务已停止 (PID: ${pid})`);
|
|
99
|
+
} catch (err) {
|
|
100
|
+
console.error('停止服务失败:', err.message);
|
|
101
|
+
removePid();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function updateService() {
|
|
106
|
+
console.log('正在更新 protocol-proxy...');
|
|
107
|
+
exec('npm install -g protocol-proxy@latest', (err, stdout, stderr) => {
|
|
108
|
+
if (err) {
|
|
109
|
+
console.error('更新失败:', err.message);
|
|
110
|
+
process.exit(1);
|
|
111
|
+
}
|
|
112
|
+
if (stdout) console.log(stdout);
|
|
113
|
+
if (stderr) console.error(stderr);
|
|
114
|
+
console.log('更新完成');
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ==================== 启动 ====================
|
|
119
|
+
|
|
120
|
+
async function init() {
|
|
121
|
+
const express = require('express');
|
|
122
|
+
const cors = require('cors');
|
|
123
|
+
const configStore = require('./lib/config-store');
|
|
124
|
+
const proxyManager = require('./lib/proxy-manager');
|
|
125
|
+
const statsStore = require('./lib/stats-store');
|
|
126
|
+
|
|
127
|
+
const app = express();
|
|
128
|
+
const PORT = process.env.ADMIN_PORT || 3000;
|
|
129
|
+
|
|
130
|
+
function openBrowser(url) {
|
|
131
|
+
const platform = os.platform();
|
|
132
|
+
let command;
|
|
133
|
+
if (platform === 'win32') {
|
|
134
|
+
command = `start "" "${url}"`;
|
|
135
|
+
} else if (platform === 'darwin') {
|
|
136
|
+
command = `open "${url}"`;
|
|
137
|
+
} else {
|
|
138
|
+
command = `xdg-open "${url}"`;
|
|
139
|
+
}
|
|
140
|
+
exec(command, (err) => {
|
|
141
|
+
if (err) logger.error('[Browser] 打开浏览器失败:', err.message);
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
app.use(cors());
|
|
146
|
+
app.use(express.json());
|
|
147
|
+
|
|
148
|
+
// 访问日志
|
|
149
|
+
app.use((req, res, next) => {
|
|
150
|
+
const start = Date.now();
|
|
151
|
+
res.on('finish', () => {
|
|
152
|
+
const duration = Date.now() - start;
|
|
153
|
+
logger.log(`[HTTP] ${req.method} ${req.path} - ${res.statusCode} (${duration}ms)`);
|
|
154
|
+
});
|
|
155
|
+
next();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
app.use(express.static(path.join(__dirname, 'public')));
|
|
159
|
+
|
|
160
|
+
// ==================== 辅助函数 ====================
|
|
161
|
+
|
|
162
|
+
function resolveTarget(proxy) {
|
|
163
|
+
const primaryProvider = configStore.getProviderById(proxy.providerId);
|
|
164
|
+
if (!primaryProvider) return null;
|
|
165
|
+
|
|
166
|
+
const pool = [];
|
|
167
|
+
const seen = new Set();
|
|
168
|
+
|
|
169
|
+
// Primary provider (no model override)
|
|
170
|
+
const primaryKey = `${primaryProvider.id}\0`;
|
|
171
|
+
seen.add(primaryKey);
|
|
172
|
+
pool.push({
|
|
173
|
+
providerId: primaryProvider.id,
|
|
174
|
+
providerName: primaryProvider.name,
|
|
175
|
+
providerUrl: primaryProvider.url,
|
|
176
|
+
protocol: primaryProvider.protocol,
|
|
177
|
+
apiKeys: primaryProvider.apiKeys || [],
|
|
178
|
+
models: primaryProvider.models,
|
|
179
|
+
azureDeployment: primaryProvider.azureDeployment || '',
|
|
180
|
+
azureApiVersion: primaryProvider.azureApiVersion || '',
|
|
181
|
+
model: '',
|
|
182
|
+
weight: Math.max(1, parseInt(proxy.providerWeight, 10) || 1),
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// Pool entries (may include model override)
|
|
186
|
+
for (const entry of (proxy.providerPool || [])) {
|
|
187
|
+
if (!entry || !entry.providerId) continue;
|
|
188
|
+
const model = typeof entry.model === 'string' ? entry.model.trim() : '';
|
|
189
|
+
const key = `${entry.providerId}\0${model}`;
|
|
190
|
+
if (seen.has(key)) continue;
|
|
191
|
+
seen.add(key);
|
|
192
|
+
const provider = configStore.getProviderById(entry.providerId);
|
|
193
|
+
if (!provider) continue;
|
|
194
|
+
pool.push({
|
|
195
|
+
providerId: provider.id,
|
|
196
|
+
providerName: provider.name,
|
|
197
|
+
providerUrl: provider.url,
|
|
198
|
+
protocol: provider.protocol,
|
|
199
|
+
apiKeys: provider.apiKeys || [],
|
|
200
|
+
models: provider.models,
|
|
201
|
+
azureDeployment: provider.azureDeployment || '',
|
|
202
|
+
azureApiVersion: provider.azureApiVersion || '',
|
|
203
|
+
model,
|
|
204
|
+
weight: Math.max(1, parseInt(entry.weight, 10) || 1),
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (pool.length === 0) return null;
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
protocol: pool[0].protocol,
|
|
212
|
+
routingStrategy: proxy.routingStrategy || 'primary_fallback',
|
|
213
|
+
providerPool: pool,
|
|
214
|
+
defaultModel: proxy.defaultModel,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function normalizeProviderPoolInput(pool) {
|
|
219
|
+
if (!Array.isArray(pool)) return [];
|
|
220
|
+
const seen = new Set();
|
|
221
|
+
const result = [];
|
|
222
|
+
for (const item of pool) {
|
|
223
|
+
if (!item || typeof item !== 'object') continue;
|
|
224
|
+
const providerId = typeof item.providerId === 'string' ? item.providerId.trim() : '';
|
|
225
|
+
if (!providerId) continue;
|
|
226
|
+
const model = typeof item.model === 'string' ? item.model.trim() : '';
|
|
227
|
+
const key = `${providerId}\0${model}`;
|
|
228
|
+
if (seen.has(key)) continue;
|
|
229
|
+
seen.add(key);
|
|
230
|
+
result.push({
|
|
231
|
+
providerId,
|
|
232
|
+
model,
|
|
233
|
+
weight: Math.max(1, parseInt(item.weight, 10) || 1),
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
return result;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function normalizeRoutingStrategyInput(strategy) {
|
|
240
|
+
return ['primary_fallback', 'round_robin', 'weighted', 'fastest'].includes(strategy)
|
|
241
|
+
? strategy
|
|
242
|
+
: 'primary_fallback';
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ==================== Token 估算与会话压缩 ====================
|
|
246
|
+
|
|
247
|
+
function estimateMessageTokens(msg) {
|
|
248
|
+
const len = (s) => (typeof s === 'string' ? s.length : JSON.stringify(s || '').length);
|
|
249
|
+
let chars = 0;
|
|
250
|
+
if (typeof msg.content === 'string') chars += len(msg.content);
|
|
251
|
+
else if (Array.isArray(msg.content)) {
|
|
252
|
+
for (const block of msg.content) {
|
|
253
|
+
// 多模态格式:只取文本内容,不序列化整个对象
|
|
254
|
+
if (typeof block === 'string') chars += len(block);
|
|
255
|
+
else if (block?.text) chars += len(block.text);
|
|
256
|
+
else if (block?.content) chars += len(block.content);
|
|
257
|
+
else chars += len(block); // fallback
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
if (msg.reasoning_content) chars += len(msg.reasoning_content);
|
|
261
|
+
if (msg.tool_calls) {
|
|
262
|
+
for (const tc of msg.tool_calls) {
|
|
263
|
+
chars += len(tc.function?.name || '') + len(tc.function?.arguments || '');
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
// chars/2 对中文更保守(中文 ~1-2 token/字),宁可高估触发压缩也别低估撑爆上下文
|
|
267
|
+
return Math.ceil(chars / 2) + 4;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function estimateConversationTokens(messages) {
|
|
271
|
+
return messages.reduce((sum, m) => sum + estimateMessageTokens(m), 0);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function compressConversation(conv, maxContext, proxyUrl, proxyHeaders, defaultModel) {
|
|
275
|
+
const messages = conv.messages;
|
|
276
|
+
const PRESERVE_RECENT = 6;
|
|
277
|
+
|
|
278
|
+
// 提取之前的压缩摘要(存储在 conv.compressionSummary 中)
|
|
279
|
+
let existingSummary = conv.compressionSummary || '';
|
|
280
|
+
|
|
281
|
+
// 分割:旧消息(压缩)和新消息(保留)
|
|
282
|
+
let keepFrom = messages.length - PRESERVE_RECENT;
|
|
283
|
+
// 边界处理:向后扫描,不拆开 assistant(tool_calls) + tool 配对
|
|
284
|
+
while (keepFrom > 0) {
|
|
285
|
+
const msg = messages[keepFrom];
|
|
286
|
+
if (msg?.role === 'tool') {
|
|
287
|
+
let j = keepFrom - 1;
|
|
288
|
+
while (j > 0 && messages[j]?.role === 'tool') j--;
|
|
289
|
+
if (messages[j]?.role === 'assistant' && messages[j]?.tool_calls) {
|
|
290
|
+
keepFrom = j;
|
|
291
|
+
}
|
|
292
|
+
break;
|
|
293
|
+
}
|
|
294
|
+
break;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const oldMessages = messages.slice(0, keepFrom);
|
|
298
|
+
const recentMessages = messages.slice(keepFrom);
|
|
299
|
+
|
|
300
|
+
if (oldMessages.length === 0) return null;
|
|
301
|
+
|
|
302
|
+
// 构建启发式摘要信息
|
|
303
|
+
const userMsgs = oldMessages.filter(m => m.role === 'user').length;
|
|
304
|
+
const assistantMsgs = oldMessages.filter(m => m.role === 'assistant').length;
|
|
305
|
+
const toolMsgs = oldMessages.filter(m => m.role === 'tool').length;
|
|
306
|
+
const toolNames = [...new Set(
|
|
307
|
+
oldMessages.filter(m => m.tool_calls).flatMap(m => m.tool_calls.map(tc => tc.function?.name)).filter(Boolean)
|
|
308
|
+
)];
|
|
309
|
+
|
|
310
|
+
const stats = [
|
|
311
|
+
`- 范围: ${oldMessages.length} 条旧消息 (user=${userMsgs}, assistant=${assistantMsgs}, tool=${toolMsgs})`,
|
|
312
|
+
toolNames.length > 0 ? `- 使用的工具: ${toolNames.join(', ')}` : null,
|
|
313
|
+
existingSummary ? `- 之前的摘要:\n${existingSummary}` : null,
|
|
314
|
+
].filter(Boolean).join('\n');
|
|
315
|
+
|
|
316
|
+
const recentUserMsgs = oldMessages.filter(m => m.role === 'user').slice(-3)
|
|
317
|
+
.map(m => typeof m.content === 'string' ? m.content.slice(0, 200) : '').filter(Boolean);
|
|
318
|
+
|
|
319
|
+
// 调用 LLM 生成摘要
|
|
320
|
+
const compressPrompt = `请将以下对话历史压缩为简洁的摘要。保留所有关键信息:用户的问题意图、发现的问题、工具调用的关键结果、得出的结论和建议。
|
|
321
|
+
|
|
322
|
+
对话统计:
|
|
323
|
+
${stats}
|
|
324
|
+
|
|
325
|
+
最近的用户问题:
|
|
326
|
+
${recentUserMsgs.map((m, i) => `${i + 1}. ${m}`).join('\n')}
|
|
327
|
+
|
|
328
|
+
请用中文输出摘要,格式:
|
|
329
|
+
1. 用户的主要目标/问题
|
|
330
|
+
2. 已完成的调查/操作
|
|
331
|
+
3. 关键发现和结论
|
|
332
|
+
4. 未完成的工作(如有)
|
|
333
|
+
|
|
334
|
+
摘要控制在 500 字以内。`;
|
|
335
|
+
|
|
336
|
+
let summary;
|
|
337
|
+
try {
|
|
338
|
+
const res = await fetch(proxyUrl, {
|
|
339
|
+
method: 'POST',
|
|
340
|
+
headers: proxyHeaders,
|
|
341
|
+
signal: AbortSignal.timeout(60000),
|
|
342
|
+
body: JSON.stringify({
|
|
343
|
+
model: defaultModel || 'gpt-4o',
|
|
344
|
+
messages: [
|
|
345
|
+
{ role: 'system', content: '你是一个对话摘要助手。简洁准确地总结对话要点。' },
|
|
346
|
+
{ role: 'user', content: compressPrompt },
|
|
347
|
+
],
|
|
348
|
+
max_tokens: 1024,
|
|
349
|
+
stream: false,
|
|
350
|
+
}),
|
|
351
|
+
});
|
|
352
|
+
if (res.ok) {
|
|
353
|
+
const data = await res.json();
|
|
354
|
+
summary = data.choices?.[0]?.message?.content || '';
|
|
355
|
+
}
|
|
356
|
+
} catch (err) {
|
|
357
|
+
logger.log(`[compress] LLM 摘要失败: ${err.message}`);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// LLM 失败 → 启发式降级
|
|
361
|
+
if (!summary) {
|
|
362
|
+
const lastAssistant = oldMessages.filter(m => m.role === 'assistant' && m.content).pop();
|
|
363
|
+
const userQuestions = oldMessages.filter(m => m.role === 'user')
|
|
364
|
+
.map(m => typeof m.content === 'string' ? m.content.slice(0, 100) : '')
|
|
365
|
+
.filter(Boolean).slice(-3);
|
|
366
|
+
summary = stats +
|
|
367
|
+
(userQuestions.length ? '\n- 最近用户问题:\n' + userQuestions.map((q, i) => ` ${i + 1}. ${q}`).join('\n') : '') +
|
|
368
|
+
'\n- 最近内容: ' + (lastAssistant?.content || '').slice(0, 300);
|
|
369
|
+
logger.log('[compress] 使用启发式降级摘要');
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// 重建消息数组(不含 system 消息,由 buildMessages() 负责注入)
|
|
373
|
+
const newMessages = [...recentMessages];
|
|
374
|
+
const newTokens = estimateConversationTokens(newMessages);
|
|
375
|
+
return { messages: newMessages, summary, removedCount: oldMessages.length, newTokens };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// ==================== 助手工具定义与执行器 ====================
|
|
379
|
+
|
|
380
|
+
const MAX_TOOL_OUTPUT = 16384; // 16KB — 防止工具输出撑爆 LLM 上下文
|
|
381
|
+
|
|
382
|
+
function truncateOutput(obj) {
|
|
383
|
+
const str = typeof obj === 'string' ? obj : JSON.stringify(obj);
|
|
384
|
+
if (str.length <= MAX_TOOL_OUTPUT) return obj;
|
|
385
|
+
const truncated = str.slice(0, MAX_TOOL_OUTPUT);
|
|
386
|
+
return { _truncated: true, _original_bytes: str.length, _preview: truncated + '\n... [截断,原始输出 ' + str.length + ' 字符]' };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const TOOL_DEFINITIONS = [
|
|
390
|
+
{
|
|
391
|
+
type: 'function',
|
|
392
|
+
function: {
|
|
393
|
+
name: 'get_system_status',
|
|
394
|
+
description: '获取系统概览:所有代理的运行状态、供应商数量、系统运行时长。',
|
|
395
|
+
parameters: { type: 'object', properties: {}, required: [] },
|
|
396
|
+
},
|
|
397
|
+
},
|
|
398
|
+
{
|
|
399
|
+
type: 'function',
|
|
400
|
+
function: {
|
|
401
|
+
name: 'get_providers',
|
|
402
|
+
description: '获取所有供应商列表,包含协议、Key 数量和健康状态。',
|
|
403
|
+
parameters: { type: 'object', properties: {}, required: [] },
|
|
404
|
+
},
|
|
405
|
+
},
|
|
406
|
+
{
|
|
407
|
+
type: 'function',
|
|
408
|
+
function: {
|
|
409
|
+
name: 'get_provider',
|
|
410
|
+
description: '根据 ID 获取单个供应商的详细信息。',
|
|
411
|
+
parameters: {
|
|
412
|
+
type: 'object',
|
|
413
|
+
properties: { providerId: { type: 'string', description: '供应商 ID' } },
|
|
414
|
+
required: ['providerId'],
|
|
415
|
+
},
|
|
416
|
+
},
|
|
417
|
+
},
|
|
418
|
+
{
|
|
419
|
+
type: 'function',
|
|
420
|
+
function: {
|
|
421
|
+
name: 'get_proxies',
|
|
422
|
+
description: '获取所有代理列表,包含端口、运行状态、关联供应商和路由策略。',
|
|
423
|
+
parameters: { type: 'object', properties: {}, required: [] },
|
|
424
|
+
},
|
|
425
|
+
},
|
|
426
|
+
{
|
|
427
|
+
type: 'function',
|
|
428
|
+
function: {
|
|
429
|
+
name: 'get_proxy',
|
|
430
|
+
description: '根据 ID 获取单个代理的详细信息。',
|
|
431
|
+
parameters: {
|
|
432
|
+
type: 'object',
|
|
433
|
+
properties: { proxyId: { type: 'string', description: '代理 ID' } },
|
|
434
|
+
required: ['proxyId'],
|
|
435
|
+
},
|
|
436
|
+
},
|
|
437
|
+
},
|
|
438
|
+
{
|
|
439
|
+
type: 'function',
|
|
440
|
+
function: {
|
|
441
|
+
name: 'get_usage_stats',
|
|
442
|
+
description: '查询用量统计,支持按时间范围、代理筛选。返回请求数和 Token 用量。',
|
|
443
|
+
parameters: {
|
|
444
|
+
type: 'object',
|
|
445
|
+
properties: {
|
|
446
|
+
range: { type: 'string', enum: ['hourly', 'daily', 'monthly', 'yearly'], description: '统计粒度,默认 daily' },
|
|
447
|
+
startDate: { type: 'string', description: '起始日期,格式 YYYY-MM-DD' },
|
|
448
|
+
endDate: { type: 'string', description: '结束日期,格式 YYYY-MM-DD' },
|
|
449
|
+
proxyId: { type: 'string', description: '按代理 ID 筛选' },
|
|
450
|
+
},
|
|
451
|
+
required: [],
|
|
452
|
+
},
|
|
453
|
+
},
|
|
454
|
+
},
|
|
455
|
+
{
|
|
456
|
+
type: 'function',
|
|
457
|
+
function: {
|
|
458
|
+
name: 'get_recent_requests',
|
|
459
|
+
description: '获取最近的请求日志,包含状态、延迟、模型、Token 用量等。',
|
|
460
|
+
parameters: {
|
|
461
|
+
type: 'object',
|
|
462
|
+
properties: {
|
|
463
|
+
limit: { type: 'number', description: '返回条数,默认 20,最大 100' },
|
|
464
|
+
},
|
|
465
|
+
required: [],
|
|
466
|
+
},
|
|
467
|
+
},
|
|
468
|
+
},
|
|
469
|
+
{
|
|
470
|
+
type: 'function',
|
|
471
|
+
function: {
|
|
472
|
+
name: 'get_system_logs',
|
|
473
|
+
description: '获取最近的系统日志(倒序),用于排查错误和异常。',
|
|
474
|
+
parameters: {
|
|
475
|
+
type: 'object',
|
|
476
|
+
properties: {
|
|
477
|
+
limit: { type: 'number', description: '返回行数,默认 30,最大 100' },
|
|
478
|
+
},
|
|
479
|
+
required: [],
|
|
480
|
+
},
|
|
481
|
+
},
|
|
482
|
+
},
|
|
483
|
+
{
|
|
484
|
+
type: 'function',
|
|
485
|
+
function: {
|
|
486
|
+
name: 'get_key_health',
|
|
487
|
+
description: '获取所有供应商的 API Key 健康检查结果,包含每个 Key 的状态和错误信息。',
|
|
488
|
+
parameters: { type: 'object', properties: {}, required: [] },
|
|
489
|
+
},
|
|
490
|
+
},
|
|
491
|
+
{
|
|
492
|
+
type: 'function',
|
|
493
|
+
function: {
|
|
494
|
+
name: 'get_settings',
|
|
495
|
+
description: '获取系统设置项。',
|
|
496
|
+
parameters: { type: 'object', properties: {}, required: [] },
|
|
497
|
+
},
|
|
498
|
+
},
|
|
499
|
+
{
|
|
500
|
+
type: 'function',
|
|
501
|
+
function: {
|
|
502
|
+
name: 'get_config_history',
|
|
503
|
+
description: '获取配置快照历史列表,可用于了解配置变更记录。',
|
|
504
|
+
parameters: { type: 'object', properties: {}, required: [] },
|
|
505
|
+
},
|
|
506
|
+
},
|
|
507
|
+
{
|
|
508
|
+
type: 'function',
|
|
509
|
+
function: {
|
|
510
|
+
name: 'read_file',
|
|
511
|
+
description: '读取文件内容。可以读取任意文件。',
|
|
512
|
+
parameters: {
|
|
513
|
+
type: 'object',
|
|
514
|
+
properties: {
|
|
515
|
+
path: { type: 'string', description: '文件的绝对路径或相对于工作目录的路径' },
|
|
516
|
+
offset: { type: 'number', description: '从第几行开始读(从 0 开始),默认 0' },
|
|
517
|
+
limit: { type: 'number', description: '最多读取多少行,默认 500' },
|
|
518
|
+
},
|
|
519
|
+
required: ['path'],
|
|
520
|
+
},
|
|
521
|
+
},
|
|
522
|
+
},
|
|
523
|
+
{
|
|
524
|
+
type: 'function',
|
|
525
|
+
function: {
|
|
526
|
+
name: 'write_file',
|
|
527
|
+
description: '写入文件内容。如果文件不存在会创建(含父目录)。会覆盖已有内容。',
|
|
528
|
+
parameters: {
|
|
529
|
+
type: 'object',
|
|
530
|
+
properties: {
|
|
531
|
+
path: { type: 'string', description: '文件的绝对路径或相对于工作目录的路径' },
|
|
532
|
+
content: { type: 'string', description: '要写入的内容' },
|
|
533
|
+
},
|
|
534
|
+
required: ['path', 'content'],
|
|
535
|
+
},
|
|
536
|
+
},
|
|
537
|
+
},
|
|
538
|
+
{
|
|
539
|
+
type: 'function',
|
|
540
|
+
function: {
|
|
541
|
+
name: 'list_directory',
|
|
542
|
+
description: '列出目录下的文件和子目录。',
|
|
543
|
+
parameters: {
|
|
544
|
+
type: 'object',
|
|
545
|
+
properties: {
|
|
546
|
+
path: { type: 'string', description: '目录路径,默认为当前工作目录' },
|
|
547
|
+
},
|
|
548
|
+
required: [],
|
|
549
|
+
},
|
|
550
|
+
},
|
|
551
|
+
},
|
|
552
|
+
{
|
|
553
|
+
type: 'function',
|
|
554
|
+
function: {
|
|
555
|
+
name: 'search_files',
|
|
556
|
+
description: '按文件名模式搜索文件,支持通配符(如 *.js、**/*.log)。',
|
|
557
|
+
parameters: {
|
|
558
|
+
type: 'object',
|
|
559
|
+
properties: {
|
|
560
|
+
pattern: { type: 'string', description: 'glob 模式,如 "**/*.js" 或 "src/**/*.ts"' },
|
|
561
|
+
path: { type: 'string', description: '搜索根目录,默认为当前工作目录' },
|
|
562
|
+
},
|
|
563
|
+
required: ['pattern'],
|
|
564
|
+
},
|
|
565
|
+
},
|
|
566
|
+
},
|
|
567
|
+
{
|
|
568
|
+
type: 'function',
|
|
569
|
+
function: {
|
|
570
|
+
name: 'execute_command',
|
|
571
|
+
description: '执行 shell 命令并返回输出。可以执行任意命令。',
|
|
572
|
+
parameters: {
|
|
573
|
+
type: 'object',
|
|
574
|
+
properties: {
|
|
575
|
+
command: { type: 'string', description: '要执行的 shell 命令' },
|
|
576
|
+
cwd: { type: 'string', description: '工作目录,默认为当前工作目录' },
|
|
577
|
+
timeout: { type: 'number', description: '超时时间(毫秒),默认 30000' },
|
|
578
|
+
},
|
|
579
|
+
required: ['command'],
|
|
580
|
+
},
|
|
581
|
+
},
|
|
582
|
+
},
|
|
583
|
+
{
|
|
584
|
+
type: 'function',
|
|
585
|
+
function: {
|
|
586
|
+
name: 'edit_file',
|
|
587
|
+
description: '精确替换文件中的字符串。比 write_file 更安全,只替换匹配的内容,不会覆盖整个文件。',
|
|
588
|
+
parameters: {
|
|
589
|
+
type: 'object',
|
|
590
|
+
properties: {
|
|
591
|
+
path: { type: 'string', description: '文件路径' },
|
|
592
|
+
old_string: { type: 'string', description: '要被替换的原始字符串(必须精确匹配)' },
|
|
593
|
+
new_string: { type: 'string', description: '替换后的新字符串' },
|
|
594
|
+
replace_all: { type: 'boolean', description: '是否替换所有匹配项,默认 false(只替换第一个)' },
|
|
595
|
+
},
|
|
596
|
+
required: ['path', 'old_string', 'new_string'],
|
|
597
|
+
},
|
|
598
|
+
},
|
|
599
|
+
},
|
|
600
|
+
{
|
|
601
|
+
type: 'function',
|
|
602
|
+
function: {
|
|
603
|
+
name: 'grep_search',
|
|
604
|
+
description: '在文件内容中搜索正则表达式模式。用于查找代码、日志关键字等。',
|
|
605
|
+
parameters: {
|
|
606
|
+
type: 'object',
|
|
607
|
+
properties: {
|
|
608
|
+
pattern: { type: 'string', description: '正则表达式模式' },
|
|
609
|
+
path: { type: 'string', description: '搜索目录或文件路径,默认当前工作目录' },
|
|
610
|
+
glob: { type: 'string', description: '文件名过滤,如 "*.js" 或 "*.log"' },
|
|
611
|
+
max_results: { type: 'number', description: '最大返回匹配数,默认 50' },
|
|
612
|
+
},
|
|
613
|
+
required: ['pattern'],
|
|
614
|
+
},
|
|
615
|
+
},
|
|
616
|
+
},
|
|
617
|
+
];
|
|
618
|
+
|
|
619
|
+
const TOOL_HANDLERS = {
|
|
620
|
+
get_system_status: async () => {
|
|
621
|
+
const proxies = configStore.getProxies().map(p => {
|
|
622
|
+
const provider = configStore.getProviderById(p.providerId);
|
|
623
|
+
return { name: p.name, port: p.port, running: proxyManager.isRunning(p.id), providerName: provider?.name || '' };
|
|
624
|
+
});
|
|
625
|
+
return { proxies, providerCount: configStore.getProviders().length, uptime: Math.floor(process.uptime()) };
|
|
626
|
+
},
|
|
627
|
+
|
|
628
|
+
get_providers: async () => {
|
|
629
|
+
return configStore.getProviders().map(p => {
|
|
630
|
+
const h = keyHealth.get(p.id);
|
|
631
|
+
let healthStatus = '未检测';
|
|
632
|
+
if (h) {
|
|
633
|
+
const ok = h.keys?.filter(k => k.ok).length || 0;
|
|
634
|
+
const total = h.keys?.length || 0;
|
|
635
|
+
healthStatus = h.status === 'healthy' ? `健康 (${ok}/${total})` :
|
|
636
|
+
h.status === 'partial' ? `部分异常 (${ok}/${total})` :
|
|
637
|
+
h.status === 'unhealthy' ? `异常 (${ok}/${total})` : '未检测';
|
|
638
|
+
}
|
|
639
|
+
return { id: p.id, name: p.name, url: p.url, protocol: p.protocol, keyCount: (p.apiKeys || []).length, health: healthStatus };
|
|
640
|
+
});
|
|
641
|
+
},
|
|
642
|
+
|
|
643
|
+
get_provider: async (args) => {
|
|
644
|
+
const p = configStore.getProviderById(args.providerId);
|
|
645
|
+
if (!p) return { error: `供应商 ${args.providerId} 不存在` };
|
|
646
|
+
const h = keyHealth.get(p.id);
|
|
647
|
+
return { id: p.id, name: p.name, url: p.url, protocol: p.protocol, apiKeys: (p.apiKeys || []).map((k, i) => ({ index: i, alias: k.alias || '', enabled: k.enabled !== false })), health: h || null };
|
|
648
|
+
},
|
|
649
|
+
|
|
650
|
+
get_proxies: async () => {
|
|
651
|
+
return configStore.getProxies().map(p => {
|
|
652
|
+
const provider = configStore.getProviderById(p.providerId);
|
|
653
|
+
return { id: p.id, name: p.name, port: p.port, running: proxyManager.isRunning(p.id), providerName: provider?.name || '', protocol: provider?.protocol || '', defaultModel: p.defaultModel || '', routingStrategy: p.routingStrategy || 'primary_fallback' };
|
|
654
|
+
});
|
|
655
|
+
},
|
|
656
|
+
|
|
657
|
+
get_proxy: async (args) => {
|
|
658
|
+
const p = configStore.getProxyById(args.proxyId);
|
|
659
|
+
if (!p) return { error: `代理 ${args.proxyId} 不存在` };
|
|
660
|
+
const provider = configStore.getProviderById(p.providerId);
|
|
661
|
+
return { id: p.id, name: p.name, port: p.port, running: proxyManager.isRunning(p.id), providerName: provider?.name || '', protocol: provider?.protocol || '', defaultModel: p.defaultModel || '', routingStrategy: p.routingStrategy || 'primary_fallback', requireAuth: !!p.requireAuth };
|
|
662
|
+
},
|
|
663
|
+
|
|
664
|
+
get_usage_stats: async (args) => {
|
|
665
|
+
return statsStore.getStats({ range: args.range || 'daily', startDate: args.startDate, endDate: args.endDate, proxyId: args.proxyId });
|
|
666
|
+
},
|
|
667
|
+
|
|
668
|
+
get_recent_requests: async (args) => {
|
|
669
|
+
const limit = Math.min(Math.max(1, parseInt(args.limit) || 20), 100);
|
|
670
|
+
return { entries: requestLog.getAll(limit) };
|
|
671
|
+
},
|
|
672
|
+
|
|
673
|
+
get_system_logs: async (args) => {
|
|
674
|
+
const limit = Math.min(Math.max(1, parseInt(args.limit) || 30), 100);
|
|
675
|
+
try {
|
|
676
|
+
const content = await fs.promises.readFile(logger.LOG_FILE, 'utf8');
|
|
677
|
+
const allLines = content.split('\n').filter(l => l.trim());
|
|
678
|
+
return { lines: allLines.slice(-limit) };
|
|
679
|
+
} catch {
|
|
680
|
+
return { lines: [] };
|
|
681
|
+
}
|
|
682
|
+
},
|
|
683
|
+
|
|
684
|
+
get_key_health: async () => {
|
|
685
|
+
const result = {};
|
|
686
|
+
for (const [providerId, health] of keyHealth) {
|
|
687
|
+
result[providerId] = health;
|
|
688
|
+
}
|
|
689
|
+
return result;
|
|
690
|
+
},
|
|
691
|
+
|
|
692
|
+
get_settings: async () => {
|
|
693
|
+
return configStore.getSettings();
|
|
694
|
+
},
|
|
695
|
+
|
|
696
|
+
get_config_history: async () => {
|
|
697
|
+
return { snapshots: configStore.getSnapshots() };
|
|
698
|
+
},
|
|
699
|
+
|
|
700
|
+
read_file: async (args) => {
|
|
701
|
+
const filePath = path.resolve(args.path);
|
|
702
|
+
try {
|
|
703
|
+
// 二进制检测:检查前 8KB 是否含 NUL 字节
|
|
704
|
+
const stat = await fs.promises.stat(filePath);
|
|
705
|
+
const peekSize = Math.min(8192, stat.size);
|
|
706
|
+
if (peekSize > 0) {
|
|
707
|
+
const fd = await fs.promises.open(filePath, 'r');
|
|
708
|
+
try {
|
|
709
|
+
const buf = Buffer.alloc(peekSize);
|
|
710
|
+
await fd.read(buf, 0, peekSize, 0);
|
|
711
|
+
if (buf.includes(0)) {
|
|
712
|
+
return { error: `二进制文件,无法以文本方式读取 (${filePath}, ${stat.size} bytes)` };
|
|
713
|
+
}
|
|
714
|
+
} finally {
|
|
715
|
+
await fd.close();
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
const content = await fs.promises.readFile(filePath, 'utf8');
|
|
719
|
+
const lines = content.split('\n');
|
|
720
|
+
const offset = Math.max(0, parseInt(args.offset) || 0);
|
|
721
|
+
const limit = Math.min(Math.max(1, parseInt(args.limit) || 500), 2000);
|
|
722
|
+
const sliced = lines.slice(offset, offset + limit);
|
|
723
|
+
return { content: sliced.join('\n'), totalLines: lines.length, offset, returnedLines: sliced.length };
|
|
724
|
+
} catch (err) {
|
|
725
|
+
return { error: err.message };
|
|
726
|
+
}
|
|
727
|
+
},
|
|
728
|
+
|
|
729
|
+
write_file: async (args) => {
|
|
730
|
+
const filePath = path.resolve(args.path);
|
|
731
|
+
try {
|
|
732
|
+
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
|
|
733
|
+
await fs.promises.writeFile(filePath, args.content, 'utf8');
|
|
734
|
+
return { success: true, path: filePath, bytes: Buffer.byteLength(args.content, 'utf8') };
|
|
735
|
+
} catch (err) {
|
|
736
|
+
return { error: err.message };
|
|
737
|
+
}
|
|
738
|
+
},
|
|
739
|
+
|
|
740
|
+
list_directory: async (args) => {
|
|
741
|
+
const dirPath = path.resolve(args.path || '.');
|
|
742
|
+
try {
|
|
743
|
+
const entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
|
|
744
|
+
return {
|
|
745
|
+
path: dirPath,
|
|
746
|
+
entries: entries.map(e => ({
|
|
747
|
+
name: e.name,
|
|
748
|
+
type: e.isDirectory() ? 'directory' : 'file',
|
|
749
|
+
})),
|
|
750
|
+
};
|
|
751
|
+
} catch (err) {
|
|
752
|
+
return { error: err.message };
|
|
753
|
+
}
|
|
754
|
+
},
|
|
755
|
+
|
|
756
|
+
search_files: async (args) => {
|
|
757
|
+
const root = path.resolve(args.path || '.');
|
|
758
|
+
const pattern = args.pattern;
|
|
759
|
+
try {
|
|
760
|
+
const results = [];
|
|
761
|
+
const globToRegex = (g) => {
|
|
762
|
+
const r = g.replace(/\*\*/g, '§GLOBSTAR§')
|
|
763
|
+
.replace(/\*/g, '[^/]*')
|
|
764
|
+
.replace(/\?/g, '[^/]')
|
|
765
|
+
.replace(/§GLOBSTAR§/g, '.*');
|
|
766
|
+
return new RegExp('^' + r + '$');
|
|
767
|
+
};
|
|
768
|
+
const regex = globToRegex(pattern);
|
|
769
|
+
const walk = async (dir, rel) => {
|
|
770
|
+
const entries = await fs.promises.readdir(dir, { withFileTypes: true });
|
|
771
|
+
for (const e of entries) {
|
|
772
|
+
const fullPath = path.join(dir, e.name);
|
|
773
|
+
const relPath = rel ? `${rel}/${e.name}` : e.name;
|
|
774
|
+
if (e.isDirectory()) {
|
|
775
|
+
if (e.name === 'node_modules' || e.name === '.git') continue;
|
|
776
|
+
await walk(fullPath, relPath);
|
|
777
|
+
} else if (regex.test(relPath)) {
|
|
778
|
+
results.push(relPath);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
};
|
|
782
|
+
await walk(root, '');
|
|
783
|
+
return { pattern, root, matches: results.slice(0, 200), total: results.length };
|
|
784
|
+
} catch (err) {
|
|
785
|
+
return { error: err.message };
|
|
786
|
+
}
|
|
787
|
+
},
|
|
788
|
+
|
|
789
|
+
execute_command: async (args) => {
|
|
790
|
+
const timeout = Math.min(Math.max(1000, parseInt(args.timeout) || 30000), 120000);
|
|
791
|
+
return new Promise((resolve) => {
|
|
792
|
+
exec(args.command, { cwd: args.cwd || process.cwd(), timeout, maxBuffer: 1024 * 1024 }, (err, stdout, stderr) => {
|
|
793
|
+
if (err) {
|
|
794
|
+
resolve({ exitCode: err.code || 1, stdout: stdout || '', stderr: stderr || err.message });
|
|
795
|
+
} else {
|
|
796
|
+
resolve({ exitCode: 0, stdout: stdout || '', stderr: stderr || '' });
|
|
797
|
+
}
|
|
798
|
+
});
|
|
799
|
+
});
|
|
800
|
+
},
|
|
801
|
+
|
|
802
|
+
edit_file: async (args) => {
|
|
803
|
+
const filePath = path.resolve(args.path);
|
|
804
|
+
try {
|
|
805
|
+
const content = await fs.promises.readFile(filePath, 'utf8');
|
|
806
|
+
const { old_string, new_string } = args;
|
|
807
|
+
if (old_string === new_string) return { error: 'old_string 和 new_string 不能相同' };
|
|
808
|
+
if (!content.includes(old_string)) return { error: `文件中未找到匹配的字符串` };
|
|
809
|
+
const replaceAll = !!args.replace_all;
|
|
810
|
+
const newContent = replaceAll
|
|
811
|
+
? content.split(old_string).join(new_string)
|
|
812
|
+
: content.replace(old_string, new_string);
|
|
813
|
+
const count = replaceAll
|
|
814
|
+
? content.split(old_string).length - 1
|
|
815
|
+
: 1;
|
|
816
|
+
await fs.promises.writeFile(filePath, newContent, 'utf8');
|
|
817
|
+
return { success: true, path: filePath, replacements: count };
|
|
818
|
+
} catch (err) {
|
|
819
|
+
return { error: err.message };
|
|
820
|
+
}
|
|
821
|
+
},
|
|
822
|
+
|
|
823
|
+
grep_search: async (args) => {
|
|
824
|
+
const root = path.resolve(args.path || '.');
|
|
825
|
+
const pattern = args.pattern;
|
|
826
|
+
const maxResults = Math.min(Math.max(1, parseInt(args.max_results) || 50), 200);
|
|
827
|
+
const globFilter = args.glob || '';
|
|
828
|
+
try {
|
|
829
|
+
const regex = new RegExp(pattern, 'gi');
|
|
830
|
+
const results = [];
|
|
831
|
+
const walk = async (dir) => {
|
|
832
|
+
if (results.length >= maxResults) return;
|
|
833
|
+
const entries = await fs.promises.readdir(dir, { withFileTypes: true });
|
|
834
|
+
for (const e of entries) {
|
|
835
|
+
if (results.length >= maxResults) break;
|
|
836
|
+
const fullPath = path.join(dir, e.name);
|
|
837
|
+
if (e.isDirectory()) {
|
|
838
|
+
if (['node_modules', '.git', 'dist', 'build', '.next'].includes(e.name)) continue;
|
|
839
|
+
await walk(fullPath);
|
|
840
|
+
} else if (e.isFile()) {
|
|
841
|
+
if (globFilter) {
|
|
842
|
+
const ext = '.' + e.name.split('.').pop();
|
|
843
|
+
if (!globFilter.includes(ext) && !globFilter.includes(e.name) && !globFilter.includes('*')) continue;
|
|
844
|
+
}
|
|
845
|
+
try {
|
|
846
|
+
const content = await fs.promises.readFile(fullPath, 'utf8');
|
|
847
|
+
const lines = content.split('\n');
|
|
848
|
+
for (let i = 0; i < lines.length; i++) {
|
|
849
|
+
if (results.length >= maxResults) break;
|
|
850
|
+
if (regex.test(lines[i])) {
|
|
851
|
+
results.push({
|
|
852
|
+
file: path.relative(root, fullPath),
|
|
853
|
+
line: i + 1,
|
|
854
|
+
content: lines[i].trim().slice(0, 300),
|
|
855
|
+
});
|
|
856
|
+
regex.lastIndex = 0;
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
} catch {}
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
};
|
|
863
|
+
await walk(root);
|
|
864
|
+
return { pattern, matches: results, total: results.length };
|
|
865
|
+
} catch (err) {
|
|
866
|
+
return { error: err.message };
|
|
867
|
+
}
|
|
868
|
+
},
|
|
869
|
+
};
|
|
870
|
+
|
|
871
|
+
async function startProxyWithProvider(proxy) {
|
|
872
|
+
const target = resolveTarget(proxy);
|
|
873
|
+
if (!target) throw new Error(`供应商 ${proxy.providerId} 不存在`);
|
|
874
|
+
const proxyConfig = { ...proxy, target };
|
|
875
|
+
return proxyManager.startProxy(proxyConfig);
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// ==================== API Key 健康检查 ====================
|
|
879
|
+
|
|
880
|
+
const keyHealth = new Map(); // providerId -> { status, lastCheck, keys: [{index, ok, message}] }
|
|
881
|
+
let healthCheckRunning = false;
|
|
882
|
+
|
|
883
|
+
async function checkAllProviderKeys() {
|
|
884
|
+
if (healthCheckRunning) return;
|
|
885
|
+
healthCheckRunning = true;
|
|
886
|
+
try {
|
|
887
|
+
const providers = configStore.getProviders();
|
|
888
|
+
logger.log(`[Health] 开始检查 ${providers.length} 个供应商的 API Key...`);
|
|
889
|
+
for (const provider of providers) {
|
|
890
|
+
await checkProviderKeys(provider);
|
|
891
|
+
}
|
|
892
|
+
logger.log('[Health] API Key 健康检查完成');
|
|
893
|
+
} finally {
|
|
894
|
+
healthCheckRunning = false;
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
async function checkProviderKeys(provider) {
|
|
899
|
+
const keys = (provider.apiKeys || []).filter(k => k.enabled !== false);
|
|
900
|
+
if (keys.length === 0) {
|
|
901
|
+
keyHealth.set(provider.id, { status: 'unknown', lastCheck: Date.now(), keys: [] });
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
const protocol = provider.protocol || 'openai';
|
|
906
|
+
const base = provider.url.replace(/\/$/, '');
|
|
907
|
+
const hasV1Suffix = base.endsWith('/v1');
|
|
908
|
+
const isAzure = protocol === 'openai' && !!provider.azureDeployment;
|
|
909
|
+
|
|
910
|
+
const results = await Promise.all(keys.map(async (k, i) => {
|
|
911
|
+
try {
|
|
912
|
+
let testUrl, fetchOpts;
|
|
913
|
+
if (protocol === 'openai') {
|
|
914
|
+
if (isAzure) {
|
|
915
|
+
const ver = provider.azureApiVersion || '2024-02-01';
|
|
916
|
+
testUrl = `${base}/openai/deployments/${provider.azureDeployment}/models?api-version=${ver}`;
|
|
917
|
+
fetchOpts = { headers: { 'api-key': k.key } };
|
|
918
|
+
} else {
|
|
919
|
+
testUrl = hasV1Suffix ? `${base}/models` : `${base}/v1/models`;
|
|
920
|
+
fetchOpts = { headers: { 'Authorization': `Bearer ${k.key}` } };
|
|
921
|
+
}
|
|
922
|
+
} else if (protocol === 'anthropic') {
|
|
923
|
+
const testModel = (provider.models && provider.models[0]) || 'claude-3-haiku-20240307';
|
|
924
|
+
testUrl = hasV1Suffix ? `${base}/messages` : `${base}/v1/messages`;
|
|
925
|
+
fetchOpts = {
|
|
926
|
+
method: 'POST',
|
|
927
|
+
headers: { 'Content-Type': 'application/json', 'x-api-key': k.key, 'anthropic-version': '2023-06-01' },
|
|
928
|
+
body: JSON.stringify({ model: testModel, max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),
|
|
929
|
+
};
|
|
930
|
+
} else if (protocol === 'gemini') {
|
|
931
|
+
testUrl = `${base}/v1beta/models?key=${k.key}`;
|
|
932
|
+
fetchOpts = {};
|
|
933
|
+
} else {
|
|
934
|
+
return { index: i, ok: false, message: '不支持的协议' };
|
|
935
|
+
}
|
|
936
|
+
const res = await fetch(testUrl, { ...fetchOpts, signal: AbortSignal.timeout(15000) });
|
|
937
|
+
if (!res.ok) {
|
|
938
|
+
const hint = res.status === 401 || res.status === 403 ? 'Key 无效或无权限' : `HTTP ${res.status}`;
|
|
939
|
+
return { index: i, ok: false, message: hint };
|
|
940
|
+
}
|
|
941
|
+
return { index: i, ok: true };
|
|
942
|
+
} catch (err) {
|
|
943
|
+
return { index: i, ok: false, message: err.name === 'TimeoutError' ? '连接超时' : err.message };
|
|
944
|
+
}
|
|
945
|
+
}));
|
|
946
|
+
|
|
947
|
+
const allOk = results.every(r => r.ok);
|
|
948
|
+
const anyOk = results.some(r => r.ok);
|
|
949
|
+
keyHealth.set(provider.id, {
|
|
950
|
+
status: allOk ? 'healthy' : anyOk ? 'partial' : 'unhealthy',
|
|
951
|
+
lastCheck: Date.now(),
|
|
952
|
+
keys: results,
|
|
953
|
+
});
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
// 启动后延迟 5 秒执行首次检查
|
|
957
|
+
setTimeout(() => checkAllProviderKeys(), 5000);
|
|
958
|
+
// 每 24 小时检查一次
|
|
959
|
+
setInterval(() => checkAllProviderKeys(), 24 * 60 * 60 * 1000);
|
|
960
|
+
|
|
961
|
+
// ==================== 供应商 API ====================
|
|
962
|
+
|
|
963
|
+
app.get('/api/providers', (req, res) => {
|
|
964
|
+
const providers = configStore.getProviders().map(p => ({
|
|
965
|
+
...p,
|
|
966
|
+
apiKey: p.apiKey ? '***' : '',
|
|
967
|
+
apiKeys: (p.apiKeys || []).map((k, i) => ({ alias: k.alias || '', masked: true, index: i, enabled: k.enabled !== false })),
|
|
968
|
+
}));
|
|
969
|
+
res.json(providers);
|
|
970
|
+
});
|
|
971
|
+
|
|
972
|
+
app.get('/api/providers/:id', (req, res) => {
|
|
973
|
+
const provider = configStore.getProviderById(req.params.id);
|
|
974
|
+
if (!provider) return res.status(404).json({ error: 'Provider not found' });
|
|
975
|
+
res.json({ ...provider, apiKey: provider.apiKey ? '***' : '', apiKeys: (provider.apiKeys || []).map((k, i) => ({ alias: k.alias || '', masked: true, index: i, enabled: k.enabled !== false })) });
|
|
976
|
+
});
|
|
977
|
+
|
|
978
|
+
app.post('/api/providers', (req, res) => {
|
|
979
|
+
const { name, url, protocol, apiKey, apiKeys, models, azureDeployment, azureApiVersion } = req.body;
|
|
980
|
+
if (!name || !url) {
|
|
981
|
+
return res.status(400).json({ error: 'name and url are required' });
|
|
982
|
+
}
|
|
983
|
+
const provider = configStore.addProvider({
|
|
984
|
+
name, url,
|
|
985
|
+
protocol: protocol || (/anthropic/i.test(url) ? 'anthropic' : 'openai'),
|
|
986
|
+
apiKey: apiKey || '',
|
|
987
|
+
apiKeys: Array.isArray(apiKeys) ? apiKeys.filter(k => k && typeof k === 'object' && k.key && k.key.trim()) : [],
|
|
988
|
+
models: models || [],
|
|
989
|
+
azureDeployment: azureDeployment || '',
|
|
990
|
+
azureApiVersion: azureApiVersion || '',
|
|
991
|
+
});
|
|
992
|
+
res.status(201).json(provider);
|
|
993
|
+
});
|
|
994
|
+
|
|
995
|
+
app.put('/api/providers/:id', async (req, res) => {
|
|
996
|
+
const existing = configStore.getProviderById(req.params.id);
|
|
997
|
+
if (!existing) return res.status(404).json({ error: 'Provider not found' });
|
|
998
|
+
|
|
999
|
+
const updates = {};
|
|
1000
|
+
if (req.body.name !== undefined) updates.name = req.body.name;
|
|
1001
|
+
if (req.body.url !== undefined) updates.url = req.body.url;
|
|
1002
|
+
if (req.body.protocol !== undefined) updates.protocol = req.body.protocol;
|
|
1003
|
+
if (req.body.apiKey !== undefined && req.body.apiKey !== '') updates.apiKey = req.body.apiKey;
|
|
1004
|
+
if (req.body.apiKeys !== undefined) {
|
|
1005
|
+
// Map masked entries back to existing keys by index
|
|
1006
|
+
const existingKeys = existing.apiKeys || [];
|
|
1007
|
+
updates.apiKeys = req.body.apiKeys
|
|
1008
|
+
.map(k => {
|
|
1009
|
+
if (k && typeof k === 'object' && k.masked && typeof k.index === 'number') {
|
|
1010
|
+
const existing = existingKeys[k.index];
|
|
1011
|
+
if (!existing) return null;
|
|
1012
|
+
return { ...existing, alias: typeof k.alias === 'string' ? k.alias.trim() : (existing.alias || ''), enabled: k.enabled !== false };
|
|
1013
|
+
}
|
|
1014
|
+
if (k && typeof k === 'object' && typeof k.key === 'string' && k.key.trim()) {
|
|
1015
|
+
return { key: k.key.trim(), alias: typeof k.alias === 'string' ? k.alias.trim() : '', enabled: k.enabled !== false };
|
|
1016
|
+
}
|
|
1017
|
+
if (typeof k === 'string' && k.trim()) {
|
|
1018
|
+
return { key: k.trim(), alias: '' };
|
|
1019
|
+
}
|
|
1020
|
+
return null;
|
|
1021
|
+
})
|
|
1022
|
+
.filter(Boolean);
|
|
1023
|
+
}
|
|
1024
|
+
if (req.body.models !== undefined) updates.models = req.body.models;
|
|
1025
|
+
if (req.body.azureDeployment !== undefined) updates.azureDeployment = req.body.azureDeployment;
|
|
1026
|
+
if (req.body.azureApiVersion !== undefined) updates.azureApiVersion = req.body.azureApiVersion;
|
|
1027
|
+
|
|
1028
|
+
const updated = configStore.updateProvider(req.params.id, updates);
|
|
1029
|
+
|
|
1030
|
+
// 同步更新引用此供应商的运行中代理
|
|
1031
|
+
const affectedProxies = configStore.getProxies().filter(p => p.providerId === req.params.id);
|
|
1032
|
+
for (const proxy of affectedProxies) {
|
|
1033
|
+
if (!proxyManager.isRunning(proxy.id)) continue;
|
|
1034
|
+
const target = resolveTarget(proxy);
|
|
1035
|
+
if (target) proxyManager.updateProxyConfig({ ...proxy, target });
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
res.json(updated);
|
|
1039
|
+
});
|
|
1040
|
+
|
|
1041
|
+
app.post('/api/providers/:id/test', async (req, res) => {
|
|
1042
|
+
const provider = configStore.getProviderById(req.params.id);
|
|
1043
|
+
if (!provider) return res.status(404).json({ error: 'Provider not found' });
|
|
1044
|
+
|
|
1045
|
+
const existingKeys = provider.apiKeys || [];
|
|
1046
|
+
const reqKeys = Array.isArray(req.body.apiKeys) ? req.body.apiKeys : [];
|
|
1047
|
+
const resolved = reqKeys
|
|
1048
|
+
.map((k, i) => {
|
|
1049
|
+
if (k && typeof k === 'object' && k.masked && typeof k.index === 'number') {
|
|
1050
|
+
const ex = existingKeys[k.index];
|
|
1051
|
+
return ex ? { key: ex.key, alias: k.alias || ex.alias || '', domIndex: i } : null;
|
|
1052
|
+
}
|
|
1053
|
+
if (k && typeof k === 'object' && typeof k.key === 'string' && k.key.trim()) {
|
|
1054
|
+
return { key: k.key.trim(), alias: k.alias || '', domIndex: i };
|
|
1055
|
+
}
|
|
1056
|
+
if (typeof k === 'string' && k.trim()) return { key: k.trim(), alias: '', domIndex: i };
|
|
1057
|
+
return null;
|
|
1058
|
+
})
|
|
1059
|
+
.filter(Boolean);
|
|
1060
|
+
|
|
1061
|
+
if (resolved.length === 0) {
|
|
1062
|
+
return res.json({ ok: false, message: '没有可用的 API Key', results: [] });
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
const protocol = req.body.protocol || provider.protocol || 'openai';
|
|
1066
|
+
const base = provider.url.replace(/\/$/, '');
|
|
1067
|
+
const hasV1Suffix = base.endsWith('/v1');
|
|
1068
|
+
const isAzure = protocol === 'openai' && !!provider.azureDeployment;
|
|
1069
|
+
|
|
1070
|
+
function buildTestOpts(key) {
|
|
1071
|
+
if (protocol === 'openai') {
|
|
1072
|
+
if (isAzure) {
|
|
1073
|
+
const ver = provider.azureApiVersion || '2024-02-01';
|
|
1074
|
+
return {
|
|
1075
|
+
url: `${base}/openai/deployments/${provider.azureDeployment}/models?api-version=${ver}`,
|
|
1076
|
+
opts: { headers: { 'api-key': key } },
|
|
1077
|
+
};
|
|
1078
|
+
}
|
|
1079
|
+
return {
|
|
1080
|
+
url: hasV1Suffix ? `${base}/models` : `${base}/v1/models`,
|
|
1081
|
+
opts: { headers: { 'Authorization': `Bearer ${key}` } },
|
|
1082
|
+
};
|
|
1083
|
+
}
|
|
1084
|
+
if (protocol === 'anthropic') {
|
|
1085
|
+
const testModel = req.body.model || (provider.models && provider.models[0]) || 'claude-3-haiku-20240307';
|
|
1086
|
+
return {
|
|
1087
|
+
url: hasV1Suffix ? `${base}/messages` : `${base}/v1/messages`,
|
|
1088
|
+
opts: {
|
|
1089
|
+
method: 'POST',
|
|
1090
|
+
headers: { 'Content-Type': 'application/json', 'x-api-key': key, 'anthropic-version': '2023-06-01' },
|
|
1091
|
+
body: JSON.stringify({ model: testModel, max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),
|
|
1092
|
+
},
|
|
1093
|
+
};
|
|
1094
|
+
}
|
|
1095
|
+
if (protocol === 'gemini') {
|
|
1096
|
+
return { url: `${base}/v1beta/models?key=${key}`, opts: {} };
|
|
1097
|
+
}
|
|
1098
|
+
return null;
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
if (protocol !== 'openai' && protocol !== 'anthropic' && protocol !== 'gemini') {
|
|
1102
|
+
return res.json({ ok: false, message: `不支持的协议: ${protocol}`, results: [] });
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
const results = await Promise.all(resolved.map(async entry => {
|
|
1106
|
+
const { url: testUrl, opts: fetchOpts } = buildTestOpts(entry.key);
|
|
1107
|
+
try {
|
|
1108
|
+
const startedAt = Date.now();
|
|
1109
|
+
const fetchRes = await fetch(testUrl, { ...fetchOpts, signal: AbortSignal.timeout(15000) });
|
|
1110
|
+
const latencyMs = Date.now() - startedAt;
|
|
1111
|
+
if (!fetchRes.ok) {
|
|
1112
|
+
const errText = await fetchRes.text().catch(() => '');
|
|
1113
|
+
const hint = fetchRes.status === 401 || fetchRes.status === 403
|
|
1114
|
+
? 'API Key 无效或无权限'
|
|
1115
|
+
: `HTTP ${fetchRes.status}: ${errText.slice(0, 200) || '未知错误'}`;
|
|
1116
|
+
return { ok: false, alias: entry.alias, index: entry.domIndex, message: hint, latencyMs };
|
|
1117
|
+
}
|
|
1118
|
+
return { ok: true, alias: entry.alias, index: entry.domIndex, latencyMs };
|
|
1119
|
+
} catch (err) {
|
|
1120
|
+
const msg = err.name === 'TimeoutError' ? '连接超时 (15s)' : `连接失败: ${err.message}`;
|
|
1121
|
+
return { ok: false, alias: entry.alias, index: entry.domIndex, message: msg };
|
|
1122
|
+
}
|
|
1123
|
+
}));
|
|
1124
|
+
|
|
1125
|
+
const passed = results.filter(r => r.ok).length;
|
|
1126
|
+
const failed = results.length - passed;
|
|
1127
|
+
res.json({ ok: failed === 0, passed, failed, total: results.length, results });
|
|
1128
|
+
});
|
|
1129
|
+
|
|
1130
|
+
app.post('/api/test-connection', async (req, res) => {
|
|
1131
|
+
const { url, protocol, apiKeys, models, azureDeployment, azureApiVersion } = req.body || {};
|
|
1132
|
+
if (!url || !protocol) return res.json({ ok: false, message: '缺少 url 或 protocol', results: [] });
|
|
1133
|
+
if (!Array.isArray(apiKeys) || apiKeys.length === 0) {
|
|
1134
|
+
return res.json({ ok: false, message: '没有可用的 API Key', results: [] });
|
|
1135
|
+
}
|
|
1136
|
+
const keys = apiKeys.filter(k => k && k.key);
|
|
1137
|
+
if (keys.length === 0) return res.json({ ok: false, message: '没有可用的 API Key', results: [] });
|
|
1138
|
+
const base = url.replace(/\/$/, '');
|
|
1139
|
+
const hasV1Suffix = base.endsWith('/v1');
|
|
1140
|
+
const isAzure = protocol === 'openai' && !!azureDeployment;
|
|
1141
|
+
|
|
1142
|
+
function buildTestOpts(key) {
|
|
1143
|
+
if (protocol === 'openai') {
|
|
1144
|
+
if (isAzure) {
|
|
1145
|
+
const ver = azureApiVersion || '2024-02-01';
|
|
1146
|
+
return { url: `${base}/openai/deployments/${azureDeployment}/models?api-version=${ver}`, opts: { headers: { 'api-key': key } } };
|
|
1147
|
+
}
|
|
1148
|
+
return { url: hasV1Suffix ? `${base}/models` : `${base}/v1/models`, opts: { headers: { 'Authorization': `Bearer ${key}` } } };
|
|
1149
|
+
}
|
|
1150
|
+
if (protocol === 'anthropic') {
|
|
1151
|
+
const testModel = (Array.isArray(models) && models[0]) || 'claude-3-haiku-20240307';
|
|
1152
|
+
return {
|
|
1153
|
+
url: hasV1Suffix ? `${base}/messages` : `${base}/v1/messages`,
|
|
1154
|
+
opts: { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-api-key': key, 'anthropic-version': '2023-06-01' }, body: JSON.stringify({ model: testModel, max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }) },
|
|
1155
|
+
};
|
|
1156
|
+
}
|
|
1157
|
+
if (protocol === 'gemini') return { url: `${base}/v1beta/models?key=${key}`, opts: {} };
|
|
1158
|
+
return null;
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
const results = await Promise.all(keys.map(async (k) => {
|
|
1162
|
+
const built = buildTestOpts(k.key);
|
|
1163
|
+
if (!built) return { ok: false, alias: k.alias || '', message: '不支持的协议' };
|
|
1164
|
+
try {
|
|
1165
|
+
const started = Date.now();
|
|
1166
|
+
const fetchRes = await fetch(built.url, { ...built.opts, signal: AbortSignal.timeout(15000) });
|
|
1167
|
+
const latency = Date.now() - started;
|
|
1168
|
+
if (!fetchRes.ok) {
|
|
1169
|
+
const hint = fetchRes.status === 401 || fetchRes.status === 403 ? 'API Key 无效或无权限' : `HTTP ${fetchRes.status}`;
|
|
1170
|
+
return { ok: false, alias: k.alias || '', message: hint, latency };
|
|
1171
|
+
}
|
|
1172
|
+
return { ok: true, alias: k.alias || '', latency };
|
|
1173
|
+
} catch (err) {
|
|
1174
|
+
return { ok: false, alias: k.alias || '', message: err.name === 'TimeoutError' ? '连接超时' : err.message };
|
|
1175
|
+
}
|
|
1176
|
+
}));
|
|
1177
|
+
|
|
1178
|
+
const passed = results.filter(r => r.ok).length;
|
|
1179
|
+
res.json({ ok: passed === keys.length, passed, failed: keys.length - passed, results });
|
|
1180
|
+
});
|
|
1181
|
+
|
|
1182
|
+
app.post('/api/providers/available-models', async (req, res) => {
|
|
1183
|
+
const { url, protocol, apiKey, azureDeployment, azureApiVersion } = req.body || {};
|
|
1184
|
+
if (!url || !protocol) return res.json({ models: [], message: '缺少 url 或 protocol 参数' });
|
|
1185
|
+
const key = apiKey || '';
|
|
1186
|
+
const base = url.replace(/\/$/, '');
|
|
1187
|
+
const hasV1Suffix = base.endsWith('/v1');
|
|
1188
|
+
const isAzure = protocol === 'openai' && !!azureDeployment;
|
|
1189
|
+
try {
|
|
1190
|
+
let fetchUrl, fetchOpts;
|
|
1191
|
+
if (protocol === 'openai') {
|
|
1192
|
+
if (isAzure) {
|
|
1193
|
+
const ver = azureApiVersion || '2024-02-01';
|
|
1194
|
+
fetchUrl = `${base}/openai/deployments/${azureDeployment}/models?api-version=${ver}`;
|
|
1195
|
+
fetchOpts = { headers: { 'api-key': key } };
|
|
1196
|
+
} else {
|
|
1197
|
+
fetchUrl = hasV1Suffix ? `${base}/models` : `${base}/v1/models`;
|
|
1198
|
+
fetchOpts = key ? { headers: { 'Authorization': `Bearer ${key}` } } : {};
|
|
1199
|
+
}
|
|
1200
|
+
} else if (protocol === 'gemini') {
|
|
1201
|
+
fetchUrl = `${base}/v1beta/models?key=${key}`;
|
|
1202
|
+
fetchOpts = {};
|
|
1203
|
+
} else if (protocol === 'anthropic') {
|
|
1204
|
+
fetchUrl = hasV1Suffix ? `${base}/models` : `${base}/v1/models`;
|
|
1205
|
+
fetchOpts = key ? { headers: { 'x-api-key': key, 'anthropic-version': '2023-06-01' } } : {};
|
|
1206
|
+
} else {
|
|
1207
|
+
return res.json({ models: [], message: `不支持的协议: ${protocol}` });
|
|
1208
|
+
}
|
|
1209
|
+
const fetchRes = await fetch(fetchUrl, { ...fetchOpts, signal: AbortSignal.timeout(15000) });
|
|
1210
|
+
if (!fetchRes.ok) {
|
|
1211
|
+
const hint = fetchRes.status === 404 ? '该供应商不支持模型列表接口' : `获取失败: HTTP ${fetchRes.status}`;
|
|
1212
|
+
return res.json({ models: [], message: hint });
|
|
1213
|
+
}
|
|
1214
|
+
const data = await fetchRes.json().catch(() => null);
|
|
1215
|
+
let models = [];
|
|
1216
|
+
if (Array.isArray(data?.data)) {
|
|
1217
|
+
models = data.data.map(m => m.id || m.name).filter(Boolean).sort();
|
|
1218
|
+
} else if (Array.isArray(data?.models)) {
|
|
1219
|
+
models = data.models.map(m => (m.name || m.id)?.replace('models/', '')).filter(Boolean).sort();
|
|
1220
|
+
}
|
|
1221
|
+
res.json({ models });
|
|
1222
|
+
} catch (err) {
|
|
1223
|
+
res.json({ models: [], message: `获取失败: ${err.message}` });
|
|
1224
|
+
}
|
|
1225
|
+
});
|
|
1226
|
+
|
|
1227
|
+
app.post('/api/providers/:id/available-models', async (req, res) => {
|
|
1228
|
+
const provider = configStore.getProviderById(req.params.id);
|
|
1229
|
+
if (!provider) return res.status(404).json({ error: 'Provider not found' });
|
|
1230
|
+
|
|
1231
|
+
// Support unsaved API keys from form
|
|
1232
|
+
let keys;
|
|
1233
|
+
const reqKeys = Array.isArray(req.body?.apiKeys) ? req.body.apiKeys : [];
|
|
1234
|
+
if (reqKeys.length > 0) {
|
|
1235
|
+
const existingKeys = provider.apiKeys || [];
|
|
1236
|
+
keys = reqKeys
|
|
1237
|
+
.map(k => {
|
|
1238
|
+
if (k && typeof k === 'object' && k.masked && typeof k.index === 'number') {
|
|
1239
|
+
return existingKeys[k.index]?.key || null;
|
|
1240
|
+
}
|
|
1241
|
+
if (k && typeof k === 'object' && typeof k.key === 'string' && k.key.trim()) {
|
|
1242
|
+
return k.key.trim();
|
|
1243
|
+
}
|
|
1244
|
+
return null;
|
|
1245
|
+
})
|
|
1246
|
+
.filter(Boolean);
|
|
1247
|
+
} else {
|
|
1248
|
+
keys = (provider.apiKeys || []).map(k => k.key).filter(Boolean);
|
|
1249
|
+
}
|
|
1250
|
+
if (keys.length === 0) return res.json({ models: [], message: '没有可用的 API Key' });
|
|
1251
|
+
|
|
1252
|
+
const protocol = provider.protocol || 'openai';
|
|
1253
|
+
const base = provider.url.replace(/\/$/, '');
|
|
1254
|
+
const hasV1Suffix = base.endsWith('/v1');
|
|
1255
|
+
const key = keys[0];
|
|
1256
|
+
const isAzure = protocol === 'openai' && !!provider.azureDeployment;
|
|
1257
|
+
|
|
1258
|
+
try {
|
|
1259
|
+
let fetchUrl, fetchOpts;
|
|
1260
|
+
if (protocol === 'openai') {
|
|
1261
|
+
if (isAzure) {
|
|
1262
|
+
const ver = provider.azureApiVersion || '2024-02-01';
|
|
1263
|
+
fetchUrl = `${base}/openai/deployments/${provider.azureDeployment}/models?api-version=${ver}`;
|
|
1264
|
+
fetchOpts = { headers: { 'api-key': key } };
|
|
1265
|
+
} else {
|
|
1266
|
+
fetchUrl = hasV1Suffix ? `${base}/models` : `${base}/v1/models`;
|
|
1267
|
+
fetchOpts = { headers: { 'Authorization': `Bearer ${key}` } };
|
|
1268
|
+
}
|
|
1269
|
+
} else if (protocol === 'gemini') {
|
|
1270
|
+
fetchUrl = `${base}/v1beta/models?key=${key}`;
|
|
1271
|
+
fetchOpts = {};
|
|
1272
|
+
} else if (protocol === 'anthropic') {
|
|
1273
|
+
fetchUrl = hasV1Suffix ? `${base}/models` : `${base}/v1/models`;
|
|
1274
|
+
fetchOpts = { headers: { 'x-api-key': key, 'anthropic-version': '2023-06-01' } };
|
|
1275
|
+
} else {
|
|
1276
|
+
return res.json({ models: [], message: `不支持的协议: ${protocol}` });
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
const fetchRes = await fetch(fetchUrl, { ...fetchOpts, signal: AbortSignal.timeout(15000) });
|
|
1280
|
+
if (!fetchRes.ok) {
|
|
1281
|
+
const hint = fetchRes.status === 404 ? '该供应商不支持模型列表接口' : `获取失败: HTTP ${fetchRes.status}`;
|
|
1282
|
+
return res.json({ models: [], message: hint });
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
const data = await fetchRes.json().catch(() => null);
|
|
1286
|
+
let models = [];
|
|
1287
|
+
if (Array.isArray(data?.data)) {
|
|
1288
|
+
// OpenAI 格式(含第三方 Anthropic 兼容供应商)
|
|
1289
|
+
models = data.data.map(m => m.id || m.name).filter(Boolean).sort();
|
|
1290
|
+
} else if (Array.isArray(data?.models)) {
|
|
1291
|
+
// Gemini 格式
|
|
1292
|
+
models = data.models.map(m => (m.name || m.id)?.replace('models/', '')).filter(Boolean).sort();
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
res.json({ models });
|
|
1296
|
+
} catch (err) {
|
|
1297
|
+
res.json({ models: [], message: `获取失败: ${err.message}` });
|
|
1298
|
+
}
|
|
1299
|
+
});
|
|
1300
|
+
|
|
1301
|
+
app.delete('/api/providers/:id', (req, res) => {
|
|
1302
|
+
const existing = configStore.getProviderById(req.params.id);
|
|
1303
|
+
if (!existing) return res.status(404).json({ error: 'Provider not found' });
|
|
1304
|
+
|
|
1305
|
+
// 检查是否有代理在使用此供应商
|
|
1306
|
+
const inUse = configStore.getProxies().some(p => p.providerId === req.params.id);
|
|
1307
|
+
if (inUse) {
|
|
1308
|
+
return res.status(409).json({ error: '该供应商正在被代理使用,无法删除' });
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
configStore.removeProvider(req.params.id);
|
|
1312
|
+
res.json({ success: true });
|
|
1313
|
+
});
|
|
1314
|
+
|
|
1315
|
+
// ==================== 代理 API ====================
|
|
1316
|
+
|
|
1317
|
+
// 获取所有代理配置
|
|
1318
|
+
app.get('/api/proxies', (req, res) => {
|
|
1319
|
+
const proxies = configStore.getProxies().map(p => {
|
|
1320
|
+
const provider = configStore.getProviderById(p.providerId);
|
|
1321
|
+
return {
|
|
1322
|
+
id: p.id,
|
|
1323
|
+
name: p.name,
|
|
1324
|
+
port: p.port,
|
|
1325
|
+
requireAuth: p.requireAuth,
|
|
1326
|
+
authToken: p.authToken,
|
|
1327
|
+
providerId: p.providerId,
|
|
1328
|
+
providerName: provider?.name || '',
|
|
1329
|
+
providerUrl: provider?.url || '',
|
|
1330
|
+
protocol: provider?.protocol || '',
|
|
1331
|
+
defaultModel: p.defaultModel || '',
|
|
1332
|
+
providerWeight: Math.max(1, parseInt(p.providerWeight, 10) || 1),
|
|
1333
|
+
routingStrategy: p.routingStrategy || 'primary_fallback',
|
|
1334
|
+
providerPool: Array.isArray(p.providerPool) ? p.providerPool : [],
|
|
1335
|
+
hasApiKey: !!(provider?.apiKey || (provider?.apiKeys && provider.apiKeys.length > 0)),
|
|
1336
|
+
running: proxyManager.isRunning(p.id),
|
|
1337
|
+
};
|
|
1338
|
+
});
|
|
1339
|
+
res.json(proxies);
|
|
1340
|
+
});
|
|
1341
|
+
|
|
1342
|
+
// 获取单个代理配置
|
|
1343
|
+
app.get('/api/proxies/:id', (req, res) => {
|
|
1344
|
+
const proxy = configStore.getProxyById(req.params.id);
|
|
1345
|
+
if (!proxy) return res.status(404).json({ error: 'Proxy not found' });
|
|
1346
|
+
const provider = configStore.getProviderById(proxy.providerId);
|
|
1347
|
+
res.json({
|
|
1348
|
+
...proxy,
|
|
1349
|
+
providerName: provider?.name || '',
|
|
1350
|
+
providerUrl: provider?.url || '',
|
|
1351
|
+
protocol: provider?.protocol || '',
|
|
1352
|
+
routingStrategy: proxy.routingStrategy || 'primary_fallback',
|
|
1353
|
+
providerPool: Array.isArray(proxy.providerPool) ? proxy.providerPool : [],
|
|
1354
|
+
hasApiKey: !!(provider?.apiKey || (provider?.apiKeys && provider.apiKeys.length > 0)),
|
|
1355
|
+
});
|
|
1356
|
+
});
|
|
1357
|
+
|
|
1358
|
+
// 创建代理
|
|
1359
|
+
app.post('/api/proxies', async (req, res) => {
|
|
1360
|
+
configStore.saveSnapshot('create-proxy');
|
|
1361
|
+
const { name, port, requireAuth, authToken, providerId, defaultModel, routingStrategy, providerPool, providerWeight } = req.body;
|
|
1362
|
+
|
|
1363
|
+
if (!name || !port || !providerId) {
|
|
1364
|
+
return res.status(400).json({ error: 'name, port and providerId are required' });
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
const provider = configStore.getProviderById(providerId);
|
|
1368
|
+
if (!provider) return res.status(400).json({ error: '供应商不存在' });
|
|
1369
|
+
|
|
1370
|
+
const parsedPort = parseInt(port);
|
|
1371
|
+
|
|
1372
|
+
const existing = configStore.getProxies().find(p => p.port === parsedPort);
|
|
1373
|
+
if (existing) {
|
|
1374
|
+
return res.status(409).json({
|
|
1375
|
+
error: `端口 ${parsedPort} 已被代理「${existing.name}」占用,请更换端口`,
|
|
1376
|
+
});
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
const proxy = configStore.addProxy({
|
|
1380
|
+
name,
|
|
1381
|
+
port: parsedPort,
|
|
1382
|
+
requireAuth: !!requireAuth,
|
|
1383
|
+
authToken: authToken || null,
|
|
1384
|
+
providerId,
|
|
1385
|
+
defaultModel: defaultModel || '',
|
|
1386
|
+
providerWeight: Math.max(1, parseInt(providerWeight, 10) || 1),
|
|
1387
|
+
routingStrategy: normalizeRoutingStrategyInput(routingStrategy),
|
|
1388
|
+
providerPool: normalizeProviderPoolInput(providerPool),
|
|
1389
|
+
});
|
|
1390
|
+
|
|
1391
|
+
try {
|
|
1392
|
+
await startProxyWithProvider(proxy);
|
|
1393
|
+
res.status(201).json({ ...proxy, running: true });
|
|
1394
|
+
} catch (err) {
|
|
1395
|
+
configStore.removeProxy(proxy.id);
|
|
1396
|
+
res.status(500).json({ error: `代理启动失败: ${err.message}` });
|
|
1397
|
+
}
|
|
1398
|
+
});
|
|
1399
|
+
|
|
1400
|
+
// 更新代理
|
|
1401
|
+
app.put('/api/proxies/:id', async (req, res) => {
|
|
1402
|
+
configStore.saveSnapshot('update-proxy');
|
|
1403
|
+
const existing = configStore.getProxyById(req.params.id);
|
|
1404
|
+
if (!existing) return res.status(404).json({ error: 'Proxy not found' });
|
|
1405
|
+
|
|
1406
|
+
const updates = {};
|
|
1407
|
+
if (req.body.name !== undefined) updates.name = req.body.name;
|
|
1408
|
+
if (req.body.port !== undefined) updates.port = parseInt(req.body.port);
|
|
1409
|
+
if (req.body.requireAuth !== undefined) updates.requireAuth = !!req.body.requireAuth;
|
|
1410
|
+
if (req.body.authToken !== undefined) updates.authToken = req.body.authToken || null;
|
|
1411
|
+
if (req.body.providerId !== undefined) {
|
|
1412
|
+
if (!configStore.getProviderById(req.body.providerId)) {
|
|
1413
|
+
return res.status(400).json({ error: '供应商不存在' });
|
|
1414
|
+
}
|
|
1415
|
+
updates.providerId = req.body.providerId;
|
|
1416
|
+
}
|
|
1417
|
+
if (req.body.defaultModel !== undefined) updates.defaultModel = req.body.defaultModel;
|
|
1418
|
+
if (req.body.providerWeight !== undefined) updates.providerWeight = Math.max(1, parseInt(req.body.providerWeight, 10) || 1);
|
|
1419
|
+
if (req.body.routingStrategy !== undefined) updates.routingStrategy = normalizeRoutingStrategyInput(req.body.routingStrategy);
|
|
1420
|
+
if (req.body.providerPool !== undefined) updates.providerPool = normalizeProviderPoolInput(req.body.providerPool);
|
|
1421
|
+
|
|
1422
|
+
const needRestart = updates.port !== undefined && updates.port !== existing.port;
|
|
1423
|
+
if (needRestart) {
|
|
1424
|
+
const conflict = configStore.getProxies().find(p => p.id !== req.params.id && p.port === updates.port);
|
|
1425
|
+
if (conflict) {
|
|
1426
|
+
return res.status(409).json({
|
|
1427
|
+
error: `端口 ${updates.port} 已被代理「${conflict.name}」占用,请更换端口`,
|
|
1428
|
+
});
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
const updated = configStore.updateProxy(req.params.id, updates);
|
|
1433
|
+
|
|
1434
|
+
if (needRestart) {
|
|
1435
|
+
try {
|
|
1436
|
+
await startProxyWithProvider(updated);
|
|
1437
|
+
} catch (err) {
|
|
1438
|
+
return res.status(500).json({ error: `代理重启失败: ${err.message}` });
|
|
1439
|
+
}
|
|
1440
|
+
} else {
|
|
1441
|
+
// 更新供应商配置引用
|
|
1442
|
+
const target = resolveTarget(updated);
|
|
1443
|
+
if (target) proxyManager.updateProxyConfig({ ...updated, target });
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
res.json({ ...updated, running: proxyManager.isRunning(updated.id) });
|
|
1447
|
+
});
|
|
1448
|
+
|
|
1449
|
+
// 删除代理
|
|
1450
|
+
app.delete('/api/proxies/:id', async (req, res) => {
|
|
1451
|
+
configStore.saveSnapshot('delete-proxy');
|
|
1452
|
+
const existing = configStore.getProxyById(req.params.id);
|
|
1453
|
+
if (!existing) return res.status(404).json({ error: 'Proxy not found' });
|
|
1454
|
+
|
|
1455
|
+
await proxyManager.stopProxy(req.params.id);
|
|
1456
|
+
configStore.removeProxy(req.params.id);
|
|
1457
|
+
res.json({ success: true });
|
|
1458
|
+
});
|
|
1459
|
+
|
|
1460
|
+
// 启动/停止代理
|
|
1461
|
+
app.post('/api/proxies/:id/start', async (req, res) => {
|
|
1462
|
+
const proxy = configStore.getProxyById(req.params.id);
|
|
1463
|
+
if (!proxy) return res.status(404).json({ error: 'Proxy not found' });
|
|
1464
|
+
|
|
1465
|
+
try {
|
|
1466
|
+
await startProxyWithProvider(proxy);
|
|
1467
|
+
res.json({ success: true, running: true });
|
|
1468
|
+
} catch (err) {
|
|
1469
|
+
res.status(500).json({ error: 'Failed to start proxy', message: err.message });
|
|
1470
|
+
}
|
|
1471
|
+
});
|
|
1472
|
+
|
|
1473
|
+
app.post('/api/proxies/:id/stop', async (req, res) => {
|
|
1474
|
+
await proxyManager.stopProxy(req.params.id);
|
|
1475
|
+
res.json({ success: true, running: false });
|
|
1476
|
+
});
|
|
1477
|
+
|
|
1478
|
+
// 批量启动所有代理
|
|
1479
|
+
app.post('/api/proxies/start-all', async (req, res) => {
|
|
1480
|
+
const proxies = configStore.getProxies();
|
|
1481
|
+
const results = [];
|
|
1482
|
+
for (const proxy of proxies) {
|
|
1483
|
+
if (proxyManager.isRunning(proxy.id)) {
|
|
1484
|
+
results.push({ id: proxy.id, name: proxy.name, skipped: true });
|
|
1485
|
+
continue;
|
|
1486
|
+
}
|
|
1487
|
+
try {
|
|
1488
|
+
await startProxyWithProvider(proxy);
|
|
1489
|
+
results.push({ id: proxy.id, name: proxy.name, success: true });
|
|
1490
|
+
} catch (err) {
|
|
1491
|
+
results.push({ id: proxy.id, name: proxy.name, success: false, error: err.message });
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
res.json({ results });
|
|
1495
|
+
});
|
|
1496
|
+
|
|
1497
|
+
// 批量停止所有代理
|
|
1498
|
+
app.post('/api/proxies/stop-all', async (req, res) => {
|
|
1499
|
+
const running = proxyManager.getRunningPorts();
|
|
1500
|
+
const results = [];
|
|
1501
|
+
for (const r of running) {
|
|
1502
|
+
await proxyManager.stopProxy(r.id);
|
|
1503
|
+
results.push({ id: r.id, name: r.name, success: true });
|
|
1504
|
+
}
|
|
1505
|
+
res.json({ results });
|
|
1506
|
+
});
|
|
1507
|
+
|
|
1508
|
+
// 获取运行状态
|
|
1509
|
+
app.get('/api/status', (req, res) => {
|
|
1510
|
+
res.json({
|
|
1511
|
+
running: proxyManager.getRunningPorts(),
|
|
1512
|
+
total: configStore.getProxies().length,
|
|
1513
|
+
});
|
|
1514
|
+
});
|
|
1515
|
+
|
|
1516
|
+
// 健康检查
|
|
1517
|
+
app.get('/api/health', (req, res) => {
|
|
1518
|
+
res.json({
|
|
1519
|
+
status: 'ok',
|
|
1520
|
+
version: pkg.version,
|
|
1521
|
+
uptime: process.uptime(),
|
|
1522
|
+
proxies: {
|
|
1523
|
+
total: configStore.getProxies().length,
|
|
1524
|
+
running: proxyManager.getRunningPorts().length,
|
|
1525
|
+
},
|
|
1526
|
+
});
|
|
1527
|
+
});
|
|
1528
|
+
|
|
1529
|
+
// API Key 健康状态
|
|
1530
|
+
app.get('/api/key-health', (req, res) => {
|
|
1531
|
+
const result = {};
|
|
1532
|
+
for (const [providerId, health] of keyHealth) {
|
|
1533
|
+
result[providerId] = health;
|
|
1534
|
+
}
|
|
1535
|
+
res.json(result);
|
|
1536
|
+
});
|
|
1537
|
+
|
|
1538
|
+
// 手动触发健康检查
|
|
1539
|
+
app.post('/api/key-health/check', async (req, res) => {
|
|
1540
|
+
await checkAllProviderKeys();
|
|
1541
|
+
res.json({ success: true });
|
|
1542
|
+
});
|
|
1543
|
+
|
|
1544
|
+
// 设置
|
|
1545
|
+
app.get('/api/settings', (req, res) => {
|
|
1546
|
+
res.json(configStore.getSettings());
|
|
1547
|
+
});
|
|
1548
|
+
|
|
1549
|
+
app.put('/api/settings', (req, res) => {
|
|
1550
|
+
const settings = req.body;
|
|
1551
|
+
if (!settings || typeof settings !== 'object') {
|
|
1552
|
+
return res.status(400).json({ error: '需要 settings 对象' });
|
|
1553
|
+
}
|
|
1554
|
+
for (const [key, value] of Object.entries(settings)) {
|
|
1555
|
+
configStore.setSetting(key, value);
|
|
1556
|
+
}
|
|
1557
|
+
res.json(configStore.getSettings());
|
|
1558
|
+
});
|
|
1559
|
+
|
|
1560
|
+
// Token 用量统计
|
|
1561
|
+
app.get('/api/stats', (req, res) => {
|
|
1562
|
+
const { range, startDate, endDate, proxyId } = req.query;
|
|
1563
|
+
const stats = statsStore.getStats({
|
|
1564
|
+
range: range || 'daily',
|
|
1565
|
+
startDate: startDate || undefined,
|
|
1566
|
+
endDate: endDate || undefined,
|
|
1567
|
+
proxyId: proxyId || undefined,
|
|
1568
|
+
});
|
|
1569
|
+
const proxies = configStore.getProxies().map(p => ({
|
|
1570
|
+
id: p.id,
|
|
1571
|
+
name: p.name,
|
|
1572
|
+
providerName: configStore.getProviderById(p.providerId)?.name || '',
|
|
1573
|
+
}));
|
|
1574
|
+
res.json({ ...stats, proxies });
|
|
1575
|
+
});
|
|
1576
|
+
|
|
1577
|
+
// 日志查看
|
|
1578
|
+
app.get('/api/logs', (req, res) => {
|
|
1579
|
+
const lines = Math.min(parseInt(req.query.lines) || 200, 2000);
|
|
1580
|
+
try {
|
|
1581
|
+
if (!fs.existsSync(logger.LOG_FILE)) {
|
|
1582
|
+
return res.json({ lines: [] });
|
|
1583
|
+
}
|
|
1584
|
+
const content = fs.readFileSync(logger.LOG_FILE, 'utf8');
|
|
1585
|
+
const allLines = content.split('\n').filter(l => l.trim());
|
|
1586
|
+
const tail = allLines.slice(-lines);
|
|
1587
|
+
res.json({ lines: tail, total: allLines.length });
|
|
1588
|
+
} catch (err) {
|
|
1589
|
+
res.json({ lines: [], error: err.message });
|
|
1590
|
+
}
|
|
1591
|
+
});
|
|
1592
|
+
|
|
1593
|
+
// 实时请求日志
|
|
1594
|
+
const requestLog = require('./lib/request-log');
|
|
1595
|
+
app.get('/api/request-logs', (req, res) => {
|
|
1596
|
+
const limit = Math.min(parseInt(req.query.limit) || 200, 2000);
|
|
1597
|
+
res.json({ entries: requestLog.getAll(limit), total: requestLog.getCount() });
|
|
1598
|
+
});
|
|
1599
|
+
|
|
1600
|
+
// ==================== 智控助手上下文 API ====================
|
|
1601
|
+
|
|
1602
|
+
app.get('/api/assistant/context', async (req, res) => {
|
|
1603
|
+
const proxyList = configStore.getProxies().map(p => {
|
|
1604
|
+
const provider = configStore.getProviderById(p.providerId);
|
|
1605
|
+
return {
|
|
1606
|
+
id: p.id,
|
|
1607
|
+
name: p.name,
|
|
1608
|
+
port: p.port,
|
|
1609
|
+
running: proxyManager.isRunning(p.id),
|
|
1610
|
+
providerId: p.providerId,
|
|
1611
|
+
providerName: provider?.name || '',
|
|
1612
|
+
protocol: provider?.protocol || '',
|
|
1613
|
+
defaultModel: p.defaultModel || '',
|
|
1614
|
+
routingStrategy: p.routingStrategy || 'primary_fallback',
|
|
1615
|
+
};
|
|
1616
|
+
});
|
|
1617
|
+
|
|
1618
|
+
const providerList = configStore.getProviders().map(p => ({
|
|
1619
|
+
id: p.id,
|
|
1620
|
+
name: p.name,
|
|
1621
|
+
url: p.url,
|
|
1622
|
+
protocol: p.protocol,
|
|
1623
|
+
apiKeys: (p.apiKeys || []).map((k, i) => ({ alias: k.alias || '', index: i, enabled: k.enabled !== false })),
|
|
1624
|
+
}));
|
|
1625
|
+
|
|
1626
|
+
const healthData = {};
|
|
1627
|
+
for (const [providerId, health] of keyHealth) {
|
|
1628
|
+
healthData[providerId] = health;
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
const stats = statsStore.getStats({ range: 'daily' });
|
|
1632
|
+
|
|
1633
|
+
let recentLogs = [];
|
|
1634
|
+
try {
|
|
1635
|
+
const content = await fs.promises.readFile(logger.LOG_FILE, 'utf8');
|
|
1636
|
+
const allLines = content.split('\n').filter(l => l.trim());
|
|
1637
|
+
recentLogs = allLines.slice(-30);
|
|
1638
|
+
} catch {}
|
|
1639
|
+
|
|
1640
|
+
const recentRequests = requestLog.getAll(20);
|
|
1641
|
+
|
|
1642
|
+
res.json({
|
|
1643
|
+
proxies: proxyList,
|
|
1644
|
+
providers: providerList,
|
|
1645
|
+
health: healthData,
|
|
1646
|
+
stats,
|
|
1647
|
+
recentLogs,
|
|
1648
|
+
recentRequests,
|
|
1649
|
+
});
|
|
1650
|
+
});
|
|
1651
|
+
|
|
1652
|
+
// ==================== 智控助手 Tool Calling API ====================
|
|
1653
|
+
|
|
1654
|
+
const conversationStore = require('./lib/conversation-store');
|
|
1655
|
+
conversationStore.init();
|
|
1656
|
+
|
|
1657
|
+
// 会话并发锁:convId → true 表示正在 streaming
|
|
1658
|
+
const activeStreams = new Set();
|
|
1659
|
+
|
|
1660
|
+
function sendSSE(res, event, data) {
|
|
1661
|
+
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
// 会话管理 API
|
|
1665
|
+
app.get('/api/assistant/conversations', (req, res) => {
|
|
1666
|
+
res.json({ conversations: conversationStore.list() });
|
|
1667
|
+
});
|
|
1668
|
+
|
|
1669
|
+
app.delete('/api/assistant/conversations/:id', (req, res) => {
|
|
1670
|
+
const conv = conversationStore.get(req.params.id);
|
|
1671
|
+
if (!conv) return res.status(404).json({ error: '会话不存在' });
|
|
1672
|
+
conversationStore.remove(req.params.id);
|
|
1673
|
+
res.json({ success: true });
|
|
1674
|
+
});
|
|
1675
|
+
|
|
1676
|
+
// 获取单个会话的消息历史(用于恢复会话显示)
|
|
1677
|
+
app.get('/api/assistant/conversations/:id/messages', (req, res) => {
|
|
1678
|
+
const conv = conversationStore.get(req.params.id);
|
|
1679
|
+
if (!conv) return res.status(404).json({ error: '会话不存在' });
|
|
1680
|
+
// 返回消息历史(过滤掉 system 消息,前端不需要显示)
|
|
1681
|
+
const messages = (conv.messages || []).filter(m => m.role !== 'system');
|
|
1682
|
+
const compressionSummary = conv.compressionSummary || null;
|
|
1683
|
+
res.json({ id: conv.id, proxyId: conv.proxyId, messages, compressionSummary });
|
|
1684
|
+
});
|
|
1685
|
+
|
|
1686
|
+
// 获取代理的候选供应商及其模型列表(供前端级联选择)
|
|
1687
|
+
app.get('/api/assistant/proxy-providers/:proxyId', (req, res) => {
|
|
1688
|
+
const proxy = configStore.getProxyById(req.params.proxyId);
|
|
1689
|
+
if (!proxy) return res.status(404).json({ error: '代理不存在' });
|
|
1690
|
+
const providers = configStore.getProviders().map(p => ({
|
|
1691
|
+
id: p.id,
|
|
1692
|
+
name: p.name,
|
|
1693
|
+
protocol: p.protocol,
|
|
1694
|
+
models: p.models || [],
|
|
1695
|
+
}));
|
|
1696
|
+
res.json({ providers, defaultModel: proxy.defaultModel || '' });
|
|
1697
|
+
});
|
|
1698
|
+
|
|
1699
|
+
function buildSystemPrompt() {
|
|
1700
|
+
const now = new Date().toLocaleString('zh-CN', { hour12: false });
|
|
1701
|
+
return `你是 Protocol Proxy 的智能助手,专门帮助管理员监控和排障。当前时间:${now}
|
|
1702
|
+
|
|
1703
|
+
你有以下工具可以调用:
|
|
1704
|
+
|
|
1705
|
+
系统查询:
|
|
1706
|
+
- get_system_status: 获取系统概览(代理运行状态、供应商数量、运行时长)
|
|
1707
|
+
- get_providers / get_provider: 获取供应商列表或详情
|
|
1708
|
+
- get_proxies / get_proxy: 获取代理列表或详情
|
|
1709
|
+
- get_usage_stats: 查询用量统计(支持按时间范围、代理筛选)
|
|
1710
|
+
- get_recent_requests: 获取最近请求日志
|
|
1711
|
+
- get_system_logs: 获取系统日志
|
|
1712
|
+
- get_key_health: 获取 API Key 健康检查结果
|
|
1713
|
+
- get_settings: 获取系统设置项
|
|
1714
|
+
- get_config_history: 获取配置快照历史
|
|
1715
|
+
|
|
1716
|
+
文件与命令:
|
|
1717
|
+
- read_file: 读取任意文件内容(支持指定行范围,自动检测二进制文件)
|
|
1718
|
+
- write_file: 写入文件(会覆盖已有内容)
|
|
1719
|
+
- edit_file: 精确替换文件中的字符串(比 write_file 更安全,只替换匹配内容)
|
|
1720
|
+
- list_directory: 列出目录内容
|
|
1721
|
+
- search_files: 按文件名 glob 模式搜索文件
|
|
1722
|
+
- grep_search: 按正则表达式搜索文件内容(用于查找代码、日志关键字等)
|
|
1723
|
+
- execute_command: 执行 shell 命令
|
|
1724
|
+
|
|
1725
|
+
规则:
|
|
1726
|
+
- 当用户询问系统状态、代理、供应商、日志、用量等运维相关问题时,调用工具获取实时数据后再回答
|
|
1727
|
+
- 当用户需要查看或修改文件、执行命令时,使用对应的文件和命令工具
|
|
1728
|
+
- 当用户只是打招呼、闲聊、或询问与系统无关的问题时,直接回答,不要调用工具
|
|
1729
|
+
- 不要凭空猜测系统状态,需要数据时必须调用工具
|
|
1730
|
+
- 执行写操作或危险命令前,先告知用户将要做什么
|
|
1731
|
+
|
|
1732
|
+
你的职责:
|
|
1733
|
+
1. 回答关于代理配置和运行状态的问题
|
|
1734
|
+
2. 分析日志,指出异常和可能原因
|
|
1735
|
+
3. 根据数据给出优化建议(负载均衡、模型选择、故障切换策略)
|
|
1736
|
+
4. 用自然语言解释技术问题
|
|
1737
|
+
5. 如果发现问题,给出具体的修复步骤
|
|
1738
|
+
|
|
1739
|
+
请用中文回答,保持专业且易懂。`;
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
app.post('/api/assistant/chat', async (req, res) => {
|
|
1743
|
+
const { proxyId, conversationId, message, compress, providerId, model } = req.body;
|
|
1744
|
+
if (!proxyId || (!compress && !message)) {
|
|
1745
|
+
return res.status(400).json({ error: '需要 proxyId 和 message' });
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
const proxy = configStore.getProxyById(proxyId);
|
|
1749
|
+
if (!proxy) return res.status(404).json({ error: '代理不存在' });
|
|
1750
|
+
if (!resolveTarget(proxy)) return res.status(500).json({ error: '代理目标未配置' });
|
|
1751
|
+
|
|
1752
|
+
// 查找或创建对话
|
|
1753
|
+
const settings = configStore.getSettings();
|
|
1754
|
+
let convId = conversationId;
|
|
1755
|
+
let conv;
|
|
1756
|
+
if (convId) {
|
|
1757
|
+
conv = conversationStore.get(convId);
|
|
1758
|
+
}
|
|
1759
|
+
if (!conv && compress) {
|
|
1760
|
+
return res.status(404).json({ error: '会话不存在,无法压缩' });
|
|
1761
|
+
}
|
|
1762
|
+
if (!conv) {
|
|
1763
|
+
const maxConvs = parseInt(settings.maxConversations) || 0;
|
|
1764
|
+
conv = conversationStore.create(proxyId, maxConvs);
|
|
1765
|
+
convId = conv.id;
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
// 并发锁:同一会话正在 streaming 时拒绝新请求
|
|
1769
|
+
if (activeStreams.has(convId)) {
|
|
1770
|
+
return res.status(429).json({ error: '该会话正在处理中,请稍后再试' });
|
|
1771
|
+
}
|
|
1772
|
+
activeStreams.add(convId);
|
|
1773
|
+
conversationStore.touch(conv);
|
|
1774
|
+
|
|
1775
|
+
// 追加用户消息到对话历史(压缩请求不追加空消息)
|
|
1776
|
+
if (!compress && message) {
|
|
1777
|
+
conv.messages.push({ role: 'user', content: message });
|
|
1778
|
+
conversationStore.touch(conv);
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
const proxyUrl = `http://localhost:${proxy.port}/v1/chat/completions`;
|
|
1782
|
+
const proxyHeaders = { 'Content-Type': 'application/json' };
|
|
1783
|
+
if (proxy.requireAuth && proxy.authToken) {
|
|
1784
|
+
proxyHeaders['Authorization'] = `Bearer ${proxy.authToken}`;
|
|
1785
|
+
}
|
|
1786
|
+
if (providerId) proxyHeaders['x-pp-provider-id'] = providerId;
|
|
1787
|
+
if (model) proxyHeaders['x-pp-model'] = model;
|
|
1788
|
+
// 若供应商不在代理候选池中,传递完整供应商配置供代理动态构建临时候选
|
|
1789
|
+
if (providerId) {
|
|
1790
|
+
const target = resolveTarget(proxy);
|
|
1791
|
+
const inPool = target?.providerPool?.some(c => c.providerId === providerId);
|
|
1792
|
+
if (!inPool) {
|
|
1793
|
+
const provider = configStore.getProviderById(providerId);
|
|
1794
|
+
if (provider) {
|
|
1795
|
+
proxyHeaders['x-pp-provider-url'] = provider.url;
|
|
1796
|
+
proxyHeaders['x-pp-provider-protocol'] = provider.protocol;
|
|
1797
|
+
const enabledKeys = (provider.apiKeys || []).filter(k => k.enabled !== false).map(k => k.key);
|
|
1798
|
+
if (enabledKeys.length > 0) proxyHeaders['x-pp-provider-keys'] = JSON.stringify(enabledKeys);
|
|
1799
|
+
}
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
// SSE 响应头
|
|
1804
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
1805
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
1806
|
+
res.setHeader('Connection', 'keep-alive');
|
|
1807
|
+
|
|
1808
|
+
function safeSSE(event, data) {
|
|
1809
|
+
try { sendSSE(res, event, data); } catch {}
|
|
1810
|
+
}
|
|
1811
|
+
const MAX_CONTEXT = Math.max(10000, parseInt(settings.maxContext) || 200000);
|
|
1812
|
+
const MAX_TOOL_ROUNDS = Math.max(1, Math.min(100, parseInt(settings.maxRounds) || 10));
|
|
1813
|
+
|
|
1814
|
+
// 手动压缩请求
|
|
1815
|
+
if (compress) {
|
|
1816
|
+
logger.log(`[assistant] 压缩请求 — ${conv.messages.length} messages`);
|
|
1817
|
+
safeSSE('compressing', {});
|
|
1818
|
+
const result = await compressConversation(conv, MAX_CONTEXT, proxyUrl, proxyHeaders, proxy.defaultModel);
|
|
1819
|
+
if (result) {
|
|
1820
|
+
conv.messages = result.messages;
|
|
1821
|
+
conv.compressionSummary = result.summary;
|
|
1822
|
+
conversationStore.touch(conv);
|
|
1823
|
+
safeSSE('compressed', { summary: result.summary, removedCount: result.removedCount, tokens: result.newTokens, maxTokens: MAX_CONTEXT, messages: conv.messages.length });
|
|
1824
|
+
logger.log(`[assistant] 压缩完成 — 移除 ${result.removedCount} 条`);
|
|
1825
|
+
} else {
|
|
1826
|
+
safeSSE('compressed', { summary: null, removedCount: 0, tokens: estimateConversationTokens(conv.messages), maxTokens: MAX_CONTEXT, messages: conv.messages.length });
|
|
1827
|
+
}
|
|
1828
|
+
safeSSE('done', {});
|
|
1829
|
+
res.end();
|
|
1830
|
+
return;
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
// 发送 conversationId 给前端
|
|
1834
|
+
safeSSE('conversation', { id: convId });
|
|
1835
|
+
|
|
1836
|
+
try {
|
|
1837
|
+
// 请求级别缓存 system prompt(避免每轮重建导致 prompt cache 失效)
|
|
1838
|
+
const systemPrompt = buildSystemPrompt();
|
|
1839
|
+
const buildMessages = () => {
|
|
1840
|
+
const msgs = [{ role: 'system', content: systemPrompt }];
|
|
1841
|
+
if (conv.compressionSummary) {
|
|
1842
|
+
msgs.push({ role: 'system', content: `[压缩摘要]\n${conv.compressionSummary}\n\n---\n以上是之前对话的压缩摘要。最近的消息保留原文。请继续对话,不要复述摘要内容。` });
|
|
1843
|
+
}
|
|
1844
|
+
msgs.push(...conv.messages);
|
|
1845
|
+
return msgs;
|
|
1846
|
+
};
|
|
1847
|
+
|
|
1848
|
+
let currentTokens = estimateConversationTokens(buildMessages());
|
|
1849
|
+
const sendContext = () => {
|
|
1850
|
+
const pct = Math.round(currentTokens / MAX_CONTEXT * 1000) / 10;
|
|
1851
|
+
safeSSE('context', { tokens: currentTokens, maxTokens: MAX_CONTEXT, percent: pct, messages: conv.messages.length });
|
|
1852
|
+
};
|
|
1853
|
+
sendContext();
|
|
1854
|
+
|
|
1855
|
+
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
|
|
1856
|
+
const messages = buildMessages();
|
|
1857
|
+
logger.log(`[assistant] round ${round} — ${messages.length} messages, ~${currentTokens} tokens`);
|
|
1858
|
+
|
|
1859
|
+
let fetchRes;
|
|
1860
|
+
try {
|
|
1861
|
+
fetchRes = await fetch(proxyUrl, {
|
|
1862
|
+
method: 'POST',
|
|
1863
|
+
headers: proxyHeaders,
|
|
1864
|
+
signal: AbortSignal.timeout(300000),
|
|
1865
|
+
body: JSON.stringify({
|
|
1866
|
+
model: proxy.defaultModel || 'gpt-4o',
|
|
1867
|
+
messages,
|
|
1868
|
+
stream: true,
|
|
1869
|
+
tools: TOOL_DEFINITIONS,
|
|
1870
|
+
tool_choice: 'auto',
|
|
1871
|
+
}),
|
|
1872
|
+
});
|
|
1873
|
+
} catch (fetchErr) {
|
|
1874
|
+
logger.log(`[assistant] round ${round} fetch error: ${fetchErr.message}`);
|
|
1875
|
+
safeSSE('error', { message: `代理请求失败: ${fetchErr.message}` });
|
|
1876
|
+
break;
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
if (!fetchRes.ok) {
|
|
1880
|
+
const text = await fetchRes.text();
|
|
1881
|
+
logger.log(`[assistant] round ${round} HTTP ${fetchRes.status}: ${text.slice(0, 200)}`);
|
|
1882
|
+
safeSSE('error', { message: `代理请求失败: HTTP ${fetchRes.status} - ${text}` });
|
|
1883
|
+
break;
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
// 解析 SSE 流
|
|
1887
|
+
const reader = fetchRes.body.getReader();
|
|
1888
|
+
const decoder = new TextDecoder();
|
|
1889
|
+
let buffer = '';
|
|
1890
|
+
let fullContent = '';
|
|
1891
|
+
let reasoningContent = '';
|
|
1892
|
+
const toolCallAccumulator = {};
|
|
1893
|
+
|
|
1894
|
+
while (true) {
|
|
1895
|
+
const { done, value } = await reader.read();
|
|
1896
|
+
if (done) break;
|
|
1897
|
+
buffer += decoder.decode(value, { stream: true });
|
|
1898
|
+
const lines = buffer.split('\n');
|
|
1899
|
+
buffer = lines.pop();
|
|
1900
|
+
for (const line of lines) {
|
|
1901
|
+
const trimmed = line.trim();
|
|
1902
|
+
if (!trimmed || !trimmed.startsWith('data: ')) continue;
|
|
1903
|
+
const payload = trimmed.slice(6);
|
|
1904
|
+
if (payload === '[DONE]') continue;
|
|
1905
|
+
try {
|
|
1906
|
+
const data = JSON.parse(payload);
|
|
1907
|
+
const delta = data.choices?.[0]?.delta;
|
|
1908
|
+
if (!delta) continue;
|
|
1909
|
+
if (delta.content) { fullContent += delta.content; safeSSE('content', { delta: delta.content }); }
|
|
1910
|
+
if (delta.reasoning_content) reasoningContent += delta.reasoning_content;
|
|
1911
|
+
if (delta.tool_calls) {
|
|
1912
|
+
for (const tc of delta.tool_calls) {
|
|
1913
|
+
const idx = tc.index;
|
|
1914
|
+
if (!toolCallAccumulator[idx]) toolCallAccumulator[idx] = { id: '', name: '', arguments: '' };
|
|
1915
|
+
if (tc.id) toolCallAccumulator[idx].id = tc.id;
|
|
1916
|
+
if (tc.function?.name) toolCallAccumulator[idx].name = tc.function.name;
|
|
1917
|
+
if (tc.function?.arguments) toolCallAccumulator[idx].arguments += tc.function.arguments;
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
} catch {}
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
const toolCalls = Object.values(toolCallAccumulator).filter(tc => tc.id && tc.name);
|
|
1925
|
+
logger.log(`[assistant] round ${round} done — ${fullContent.length} chars, ${toolCalls.length} tool calls`);
|
|
1926
|
+
|
|
1927
|
+
if (toolCalls.length === 0) {
|
|
1928
|
+
// 最终回复,追加到对话历史(跳过空响应避免 null content 污染历史)
|
|
1929
|
+
if (fullContent || reasoningContent) {
|
|
1930
|
+
const assistantMsg = { role: 'assistant', content: fullContent || null };
|
|
1931
|
+
if (reasoningContent) assistantMsg.reasoning_content = reasoningContent;
|
|
1932
|
+
conv.messages.push(assistantMsg);
|
|
1933
|
+
}
|
|
1934
|
+
currentTokens = estimateConversationTokens(buildMessages());
|
|
1935
|
+
sendContext();
|
|
1936
|
+
safeSSE('done', { reasoning_content: reasoningContent || undefined });
|
|
1937
|
+
break;
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
// 通知前端
|
|
1941
|
+
safeSSE('tool_calls', {
|
|
1942
|
+
reasoning_content: reasoningContent || undefined,
|
|
1943
|
+
calls: toolCalls.map(tc => {
|
|
1944
|
+
let args = {};
|
|
1945
|
+
try { args = JSON.parse(tc.arguments); } catch (e) {
|
|
1946
|
+
logger.log(`[assistant] tool_calls args parse error (${tc.name}): ${e.message}, raw: ${(tc.arguments || '').slice(0, 200)}`);
|
|
1947
|
+
args = { _raw: tc.arguments, _parseError: true };
|
|
1948
|
+
}
|
|
1949
|
+
return { id: tc.id, name: tc.name, arguments: args };
|
|
1950
|
+
}),
|
|
1951
|
+
});
|
|
1952
|
+
|
|
1953
|
+
// 追加 assistant(tool_calls) 到对话历史
|
|
1954
|
+
const assistantMsg = {
|
|
1955
|
+
role: 'assistant',
|
|
1956
|
+
content: fullContent || null,
|
|
1957
|
+
tool_calls: toolCalls.map(tc => ({ id: tc.id, type: 'function', function: { name: tc.name, arguments: tc.arguments } })),
|
|
1958
|
+
};
|
|
1959
|
+
if (reasoningContent) assistantMsg.reasoning_content = reasoningContent;
|
|
1960
|
+
conv.messages.push(assistantMsg);
|
|
1961
|
+
|
|
1962
|
+
// 执行工具
|
|
1963
|
+
for (const tc of toolCalls) {
|
|
1964
|
+
let args = {};
|
|
1965
|
+
let argsParseError = false;
|
|
1966
|
+
try { args = JSON.parse(tc.arguments); } catch (e) {
|
|
1967
|
+
logger.log(`[assistant] tool args parse error (${tc.name}): ${e.message}`);
|
|
1968
|
+
argsParseError = true;
|
|
1969
|
+
}
|
|
1970
|
+
logger.log(`[assistant] EXEC tool: ${tc.name}`);
|
|
1971
|
+
let result;
|
|
1972
|
+
let isError = false;
|
|
1973
|
+
if (argsParseError) {
|
|
1974
|
+
result = { error: `工具 ${tc.name} 的参数 JSON 解析失败,原始内容: ${(tc.arguments || '').slice(0, 200)}` };
|
|
1975
|
+
isError = true;
|
|
1976
|
+
} else try {
|
|
1977
|
+
result = await TOOL_HANDLERS[tc.name]?.(args) || { error: `未知工具: ${tc.name}` };
|
|
1978
|
+
if (result && result.error) isError = true;
|
|
1979
|
+
} catch (err) {
|
|
1980
|
+
logger.log(`[assistant] tool ${tc.name} error: ${err.message}`);
|
|
1981
|
+
result = { error: err.message };
|
|
1982
|
+
isError = true;
|
|
1983
|
+
}
|
|
1984
|
+
result = truncateOutput(result);
|
|
1985
|
+
const resultStr = JSON.stringify(result);
|
|
1986
|
+
logger.log(`[assistant] tool ${tc.name} done: ${resultStr.length} chars${isError ? ' (error)' : ''}`);
|
|
1987
|
+
safeSSE('tool_result', { tool_call_id: tc.id, name: tc.name, result, is_error: isError });
|
|
1988
|
+
conv.messages.push({ role: 'tool', tool_call_id: tc.id, content: isError ? `[ERROR] ${resultStr}` : resultStr });
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
// token 检查 + 压缩
|
|
1992
|
+
currentTokens = estimateConversationTokens(buildMessages());
|
|
1993
|
+
sendContext();
|
|
1994
|
+
if (currentTokens >= MAX_CONTEXT * 0.8) {
|
|
1995
|
+
logger.log(`[assistant] 上下文 ${Math.round(currentTokens / MAX_CONTEXT * 100)}%,自动压缩`);
|
|
1996
|
+
safeSSE('compressing', {});
|
|
1997
|
+
const compResult = await compressConversation(conv, MAX_CONTEXT, proxyUrl, proxyHeaders, proxy.defaultModel);
|
|
1998
|
+
if (compResult) {
|
|
1999
|
+
conv.messages = compResult.messages;
|
|
2000
|
+
conv.compressionSummary = compResult.summary;
|
|
2001
|
+
conversationStore.touch(conv);
|
|
2002
|
+
currentTokens = compResult.newTokens;
|
|
2003
|
+
safeSSE('compressed', { summary: compResult.summary, removedCount: compResult.removedCount, tokens: currentTokens, maxTokens: MAX_CONTEXT, messages: conv.messages.length });
|
|
2004
|
+
sendContext();
|
|
2005
|
+
logger.log(`[assistant] 压缩完成 — 移除 ${compResult.removedCount} 条`);
|
|
2006
|
+
}
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
// 达到最大轮次 → 总结回复
|
|
2011
|
+
logger.log(`[assistant] max rounds reached, requesting summary`);
|
|
2012
|
+
try {
|
|
2013
|
+
const summaryRes = await fetch(proxyUrl, {
|
|
2014
|
+
method: 'POST',
|
|
2015
|
+
headers: proxyHeaders,
|
|
2016
|
+
signal: AbortSignal.timeout(120000),
|
|
2017
|
+
body: JSON.stringify({
|
|
2018
|
+
model: proxy.defaultModel || 'gpt-4o',
|
|
2019
|
+
messages: [
|
|
2020
|
+
...buildMessages(),
|
|
2021
|
+
{ role: 'system', content: '你已达到最大工具调用轮次限制(' + MAX_TOOL_ROUNDS + ' 轮),无法继续调用工具。请基于已获取的信息给出回复,并明确告知用户:由于达到工具调用轮次上限,信息获取可能不完整或操作被迫中断。如果还有未完成的工作,请说明并建议用户重新提问以继续。' },
|
|
2022
|
+
],
|
|
2023
|
+
stream: true,
|
|
2024
|
+
}),
|
|
2025
|
+
});
|
|
2026
|
+
if (summaryRes.ok) {
|
|
2027
|
+
const sr = summaryRes.body.getReader();
|
|
2028
|
+
const sd = new TextDecoder();
|
|
2029
|
+
let sb = '';
|
|
2030
|
+
let summaryContent = '';
|
|
2031
|
+
let summaryReasoning = '';
|
|
2032
|
+
while (true) {
|
|
2033
|
+
const { done: finished, value: v } = await sr.read();
|
|
2034
|
+
if (finished) break;
|
|
2035
|
+
sb += sd.decode(v, { stream: true });
|
|
2036
|
+
const lines = sb.split('\n');
|
|
2037
|
+
sb = lines.pop();
|
|
2038
|
+
for (const line of lines) {
|
|
2039
|
+
const t = line.trim();
|
|
2040
|
+
if (!t || !t.startsWith('data: ') || t === 'data: [DONE]') continue;
|
|
2041
|
+
try {
|
|
2042
|
+
const chunk = JSON.parse(t.slice(6));
|
|
2043
|
+
const delta = chunk.choices?.[0]?.delta;
|
|
2044
|
+
if (!delta) continue;
|
|
2045
|
+
if (delta.content) { summaryContent += delta.content; safeSSE('content', { delta: delta.content }); }
|
|
2046
|
+
if (delta.reasoning_content) summaryReasoning += delta.reasoning_content;
|
|
2047
|
+
} catch {}
|
|
2048
|
+
}
|
|
2049
|
+
}
|
|
2050
|
+
// 追加总结到对话历史
|
|
2051
|
+
const summaryMsg = { role: 'assistant', content: summaryContent || null };
|
|
2052
|
+
if (summaryReasoning) summaryMsg.reasoning_content = summaryReasoning;
|
|
2053
|
+
conv.messages.push(summaryMsg);
|
|
2054
|
+
safeSSE('done', { reasoning_content: summaryReasoning || undefined });
|
|
2055
|
+
} else {
|
|
2056
|
+
safeSSE('done', {});
|
|
2057
|
+
}
|
|
2058
|
+
} catch {
|
|
2059
|
+
safeSSE('done', {});
|
|
2060
|
+
}
|
|
2061
|
+
} catch (err) {
|
|
2062
|
+
logger.log(`[assistant] error: ${err.message}`);
|
|
2063
|
+
if (!res.headersSent) {
|
|
2064
|
+
res.status(502).json({ error: `助手请求失败: ${err.message}` });
|
|
2065
|
+
} else {
|
|
2066
|
+
safeSSE('error', { message: err.message });
|
|
2067
|
+
}
|
|
2068
|
+
} finally {
|
|
2069
|
+
activeStreams.delete(convId);
|
|
2070
|
+
conversationStore.touch(conv); // 保存最终对话状态
|
|
2071
|
+
res.end();
|
|
2072
|
+
}
|
|
2073
|
+
});
|
|
2074
|
+
|
|
2075
|
+
// ==================== 配置导入/导出 ====================
|
|
2076
|
+
|
|
2077
|
+
app.get('/api/config/export', (req, res) => {
|
|
2078
|
+
const providers = configStore.getProviders();
|
|
2079
|
+
const proxies = configStore.getProxies().map(p => {
|
|
2080
|
+
const provider = configStore.getProviderById(p.providerId);
|
|
2081
|
+
return {
|
|
2082
|
+
id: p.id,
|
|
2083
|
+
name: p.name,
|
|
2084
|
+
port: p.port,
|
|
2085
|
+
requireAuth: p.requireAuth,
|
|
2086
|
+
authToken: p.authToken,
|
|
2087
|
+
providerId: p.providerId,
|
|
2088
|
+
defaultModel: p.defaultModel || '',
|
|
2089
|
+
routingStrategy: p.routingStrategy || 'primary_fallback',
|
|
2090
|
+
providerPool: Array.isArray(p.providerPool) ? p.providerPool : [],
|
|
2091
|
+
providerName: provider?.name || '',
|
|
2092
|
+
};
|
|
2093
|
+
});
|
|
2094
|
+
res.json({ providers, proxies, exportedAt: new Date().toISOString() });
|
|
2095
|
+
});
|
|
2096
|
+
|
|
2097
|
+
app.post('/api/config/import', async (req, res) => {
|
|
2098
|
+
const { config, mode } = req.body;
|
|
2099
|
+
|
|
2100
|
+
if (!config || !mode || !['overwrite', 'merge'].includes(mode)) {
|
|
2101
|
+
return res.status(400).json({ error: '需要 config 和 mode(overwrite/merge)' });
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2104
|
+
configStore.saveSnapshot('import-' + mode);
|
|
2105
|
+
|
|
2106
|
+
// 校验结构
|
|
2107
|
+
if (!Array.isArray(config.providers) || !Array.isArray(config.proxies)) {
|
|
2108
|
+
return res.status(400).json({ error: '配置格式错误:需要 providers 和 proxies 数组' });
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
for (const p of config.providers) {
|
|
2112
|
+
if (!p.name || !p.url || !p.protocol) {
|
|
2113
|
+
return res.status(400).json({ error: `供应商 "${p.name || '?'}" 缺少必要字段(name/url/protocol)` });
|
|
2114
|
+
}
|
|
2115
|
+
}
|
|
2116
|
+
|
|
2117
|
+
for (const p of config.proxies) {
|
|
2118
|
+
if (!p.name || !p.port || !p.providerId) {
|
|
2119
|
+
return res.status(400).json({ error: `代理 "${p.name || '?'}" 缺少必要字段(name/port/providerId)` });
|
|
2120
|
+
}
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
if (mode === 'overwrite') {
|
|
2124
|
+
// 覆盖模式:直接替换整个配置
|
|
2125
|
+
const newConfig = {
|
|
2126
|
+
providers: config.providers.map(p => ({
|
|
2127
|
+
id: p.id,
|
|
2128
|
+
name: p.name,
|
|
2129
|
+
url: p.url,
|
|
2130
|
+
protocol: p.protocol,
|
|
2131
|
+
apiKey: p.apiKey || '',
|
|
2132
|
+
models: Array.isArray(p.models) ? p.models : [],
|
|
2133
|
+
})),
|
|
2134
|
+
proxies: config.proxies.map(p => ({
|
|
2135
|
+
id: p.id,
|
|
2136
|
+
name: p.name,
|
|
2137
|
+
port: p.port,
|
|
2138
|
+
requireAuth: !!p.requireAuth,
|
|
2139
|
+
authToken: p.authToken || null,
|
|
2140
|
+
providerId: p.providerId,
|
|
2141
|
+
defaultModel: p.defaultModel || '',
|
|
2142
|
+
routingStrategy: normalizeRoutingStrategyInput(p.routingStrategy),
|
|
2143
|
+
providerPool: normalizeProviderPoolInput(p.providerPool),
|
|
2144
|
+
})),
|
|
2145
|
+
};
|
|
2146
|
+
configStore.saveConfig(newConfig);
|
|
2147
|
+
return res.json({ success: true, mode, providers: newConfig.providers.length, proxies: newConfig.proxies.length });
|
|
2148
|
+
}
|
|
2149
|
+
|
|
2150
|
+
// 合并模式:按 ID 去重
|
|
2151
|
+
const existingProviders = configStore.getProviders();
|
|
2152
|
+
const existingProxies = configStore.getProxies();
|
|
2153
|
+
|
|
2154
|
+
const providerMap = new Map(existingProviders.map(p => [p.id, p]));
|
|
2155
|
+
for (const p of config.providers) {
|
|
2156
|
+
providerMap.set(p.id, {
|
|
2157
|
+
id: p.id,
|
|
2158
|
+
name: p.name,
|
|
2159
|
+
url: p.url,
|
|
2160
|
+
protocol: p.protocol,
|
|
2161
|
+
apiKey: p.apiKey || '',
|
|
2162
|
+
models: Array.isArray(p.models) ? p.models : [],
|
|
2163
|
+
routingStrategy: normalizeRoutingStrategyInput(p.routingStrategy),
|
|
2164
|
+
providerPool: normalizeProviderPoolInput(p.providerPool),
|
|
2165
|
+
});
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
const proxyMap = new Map(existingProxies.map(p => [p.id, p]));
|
|
2169
|
+
for (const p of config.proxies) {
|
|
2170
|
+
// 检查端口冲突:导入的代理端口不能和现有代理或其他导入代理重复
|
|
2171
|
+
const conflict = proxyMap.get(p.id)
|
|
2172
|
+
? null // 同 ID 是覆盖,不算冲突
|
|
2173
|
+
: Array.from(proxyMap.values()).find(ep => ep.port === p.port);
|
|
2174
|
+
if (conflict) {
|
|
2175
|
+
return res.status(409).json({
|
|
2176
|
+
error: `端口 ${p.port} 已被代理「${conflict.name}」占用,无法导入代理「${p.name}」`,
|
|
2177
|
+
});
|
|
2178
|
+
}
|
|
2179
|
+
proxyMap.set(p.id, {
|
|
2180
|
+
id: p.id,
|
|
2181
|
+
name: p.name,
|
|
2182
|
+
port: p.port,
|
|
2183
|
+
requireAuth: !!p.requireAuth,
|
|
2184
|
+
authToken: p.authToken || null,
|
|
2185
|
+
providerId: p.providerId,
|
|
2186
|
+
defaultModel: p.defaultModel || '',
|
|
2187
|
+
routingStrategy: normalizeRoutingStrategyInput(p.routingStrategy),
|
|
2188
|
+
providerPool: normalizeProviderPoolInput(p.providerPool),
|
|
2189
|
+
});
|
|
2190
|
+
}
|
|
2191
|
+
|
|
2192
|
+
const merged = {
|
|
2193
|
+
providers: Array.from(providerMap.values()),
|
|
2194
|
+
proxies: Array.from(proxyMap.values()),
|
|
2195
|
+
};
|
|
2196
|
+
configStore.saveConfig(merged);
|
|
2197
|
+
|
|
2198
|
+
res.json({
|
|
2199
|
+
success: true,
|
|
2200
|
+
mode,
|
|
2201
|
+
providers: merged.providers.length,
|
|
2202
|
+
proxies: merged.proxies.length,
|
|
2203
|
+
added: {
|
|
2204
|
+
providers: merged.providers.length - existingProviders.length,
|
|
2205
|
+
proxies: merged.proxies.length - existingProxies.length,
|
|
2206
|
+
},
|
|
2207
|
+
});
|
|
2208
|
+
});
|
|
2209
|
+
|
|
2210
|
+
// ==================== 配置版本历史 ====================
|
|
2211
|
+
|
|
2212
|
+
app.get('/api/config/history', (req, res) => {
|
|
2213
|
+
const snapshots = configStore.getSnapshots();
|
|
2214
|
+
res.json({ snapshots });
|
|
2215
|
+
});
|
|
2216
|
+
|
|
2217
|
+
app.post('/api/config/rollback', async (req, res) => {
|
|
2218
|
+
const { file } = req.body;
|
|
2219
|
+
if (!file) return res.status(400).json({ error: '需要指定快照文件' });
|
|
2220
|
+
const result = configStore.restoreSnapshot(file);
|
|
2221
|
+
if (result.error) return res.status(400).json({ error: result.error });
|
|
2222
|
+
res.json({ success: true });
|
|
2223
|
+
});
|
|
2224
|
+
|
|
2225
|
+
// 前端首页
|
|
2226
|
+
app.get('/', (req, res) => {
|
|
2227
|
+
res.sendFile(path.join(__dirname, 'public', 'index.html'));
|
|
2228
|
+
});
|
|
2229
|
+
|
|
2230
|
+
// 启动
|
|
2231
|
+
logger.init();
|
|
2232
|
+
writePid();
|
|
2233
|
+
|
|
2234
|
+
// 启动所有已配置的代理
|
|
2235
|
+
const proxies = configStore.getProxies();
|
|
2236
|
+
await Promise.all(proxies.map(async (proxy) => {
|
|
2237
|
+
try {
|
|
2238
|
+
await startProxyWithProvider(proxy);
|
|
2239
|
+
} catch (err) {
|
|
2240
|
+
logger.error(`[Init] Failed to start proxy ${proxy.name}:`, err.message);
|
|
2241
|
+
}
|
|
2242
|
+
}));
|
|
2243
|
+
|
|
2244
|
+
const http = require('http');
|
|
2245
|
+
const server = app.listen(PORT, () => {
|
|
2246
|
+
const adminUrl = `http://localhost:${PORT}`;
|
|
2247
|
+
logger.log(`[Admin] Management server running on ${adminUrl}`);
|
|
2248
|
+
logger.log(`[Admin] ${proxies.length} proxy config(s) loaded`);
|
|
2249
|
+
logger.log(`[Admin] 日志文件: ${logger.LOG_FILE}`);
|
|
2250
|
+
|
|
2251
|
+
// 初始化 WebSocket 实时日志
|
|
2252
|
+
const wsServer = require('./lib/ws-server');
|
|
2253
|
+
wsServer.init(server);
|
|
2254
|
+
requestLog.onEntry((entry) => wsServer.broadcast(entry));
|
|
2255
|
+
logger.log(`[Admin] WebSocket 已附加 (ws://localhost:${PORT})`);
|
|
2256
|
+
|
|
2257
|
+
openBrowser(adminUrl);
|
|
2258
|
+
});
|
|
2259
|
+
}
|
|
2260
|
+
|
|
2261
|
+
// 优雅关闭
|
|
2262
|
+
process.on('SIGINT', async () => {
|
|
2263
|
+
logger.log('[Shutdown] Shutting down...');
|
|
2264
|
+
removePid();
|
|
2265
|
+
try {
|
|
2266
|
+
const wsServer = require('./lib/ws-server');
|
|
2267
|
+
wsServer.close();
|
|
2268
|
+
const proxyManager = require('./lib/proxy-manager');
|
|
2269
|
+
const statsStore = require('./lib/stats-store');
|
|
2270
|
+
statsStore.flush();
|
|
2271
|
+
await proxyManager.stopAll();
|
|
2272
|
+
} catch (err) {
|
|
2273
|
+
logger.error('[Shutdown] stopAll error:', err.message);
|
|
2274
|
+
}
|
|
2275
|
+
process.exit(0);
|
|
2276
|
+
});
|
|
2277
|
+
|
|
2278
|
+
process.on('SIGTERM', async () => {
|
|
2279
|
+
removePid();
|
|
2280
|
+
try {
|
|
2281
|
+
const wsServer = require('./lib/ws-server');
|
|
2282
|
+
wsServer.close();
|
|
2283
|
+
const proxyManager = require('./lib/proxy-manager');
|
|
2284
|
+
const statsStore = require('./lib/stats-store');
|
|
2285
|
+
statsStore.flush();
|
|
2286
|
+
await proxyManager.stopAll();
|
|
2287
|
+
} catch (err) {
|
|
2288
|
+
logger.error('[Shutdown] stopAll error:', err.message);
|
|
2289
|
+
}
|
|
2290
|
+
process.exit(0);
|
|
2291
|
+
});
|
|
2292
|
+
|
|
2293
|
+
// ==================== CLI Dispatch ====================
|
|
2294
|
+
|
|
2295
|
+
const cmd = process.argv[2];
|
|
2296
|
+
|
|
2297
|
+
switch (cmd) {
|
|
2298
|
+
case 'help':
|
|
2299
|
+
showHelp();
|
|
2300
|
+
break;
|
|
2301
|
+
case '-v':
|
|
2302
|
+
case '--version':
|
|
2303
|
+
showVersion();
|
|
2304
|
+
break;
|
|
2305
|
+
case 'update':
|
|
2306
|
+
updateService();
|
|
2307
|
+
break;
|
|
2308
|
+
case 'stop':
|
|
2309
|
+
stopService();
|
|
2310
|
+
break;
|
|
2311
|
+
case 'status':
|
|
2312
|
+
showStatus();
|
|
2313
|
+
break;
|
|
2314
|
+
case 'start':
|
|
2315
|
+
startDaemon();
|
|
2316
|
+
break;
|
|
2317
|
+
case '--daemon':
|
|
2318
|
+
init();
|
|
2319
|
+
break;
|
|
2320
|
+
case undefined:
|
|
2321
|
+
init();
|
|
2322
|
+
break;
|
|
2323
|
+
default:
|
|
2324
|
+
console.error(`未知命令: ${cmd}`);
|
|
2325
|
+
showHelp();
|
|
2326
|
+
process.exit(1);
|
|
2327
|
+
}
|