protocol-proxy 2.8.3 → 2.10.1
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/config-store.js +45 -2
- package/lib/conversation-store.js +108 -0
- package/lib/mcp-client.js +423 -0
- package/lib/prompt-builder.js +94 -0
- package/lib/proxy-server.js +44 -5
- package/lib/skill-store.js +150 -0
- package/package.json +2 -1
- package/public/app.js +1102 -106
- package/public/index.html +250 -8
- package/public/style.css +458 -2
- package/server.js +1774 -191
package/server.js
CHANGED
|
@@ -123,6 +123,7 @@ async function init() {
|
|
|
123
123
|
const configStore = require('./lib/config-store');
|
|
124
124
|
const proxyManager = require('./lib/proxy-manager');
|
|
125
125
|
const statsStore = require('./lib/stats-store');
|
|
126
|
+
const mcpClient = require('./lib/mcp-client');
|
|
126
127
|
|
|
127
128
|
const app = express();
|
|
128
129
|
const PORT = process.env.ADMIN_PORT || 3000;
|
|
@@ -143,7 +144,7 @@ async function init() {
|
|
|
143
144
|
}
|
|
144
145
|
|
|
145
146
|
app.use(cors());
|
|
146
|
-
app.use(express.json());
|
|
147
|
+
app.use(express.json({ limit: '10mb' }));
|
|
147
148
|
|
|
148
149
|
// 访问日志
|
|
149
150
|
app.use((req, res, next) => {
|
|
@@ -242,8 +243,150 @@ async function init() {
|
|
|
242
243
|
: 'primary_fallback';
|
|
243
244
|
}
|
|
244
245
|
|
|
246
|
+
// ==================== Token 估算与会话压缩 ====================
|
|
247
|
+
|
|
248
|
+
function estimateMessageTokens(msg) {
|
|
249
|
+
const len = (s) => (typeof s === 'string' ? s.length : JSON.stringify(s || '').length);
|
|
250
|
+
let chars = 0;
|
|
251
|
+
if (typeof msg.content === 'string') chars += len(msg.content);
|
|
252
|
+
else if (Array.isArray(msg.content)) {
|
|
253
|
+
for (const block of msg.content) {
|
|
254
|
+
// 多模态格式:只取文本内容,不序列化整个对象
|
|
255
|
+
if (typeof block === 'string') chars += len(block);
|
|
256
|
+
else if (block?.text) chars += len(block.text);
|
|
257
|
+
else if (block?.content) chars += len(block.content);
|
|
258
|
+
else chars += len(block); // fallback
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
if (msg.reasoning_content) chars += len(msg.reasoning_content);
|
|
262
|
+
if (msg.tool_calls) {
|
|
263
|
+
for (const tc of msg.tool_calls) {
|
|
264
|
+
chars += len(tc.function?.name || '') + len(tc.function?.arguments || '');
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
// chars/2 对中文更保守(中文 ~1-2 token/字),宁可高估触发压缩也别低估撑爆上下文
|
|
268
|
+
return Math.ceil(chars / 2) + 4;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function estimateConversationTokens(messages) {
|
|
272
|
+
return messages.reduce((sum, m) => sum + estimateMessageTokens(m), 0);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function compressConversation(conv, maxContext, proxyUrl, proxyHeaders, defaultModel) {
|
|
276
|
+
const messages = conv.messages;
|
|
277
|
+
const PRESERVE_RECENT = 6;
|
|
278
|
+
|
|
279
|
+
// 提取之前的压缩摘要(存储在 conv.compressionSummary 中)
|
|
280
|
+
let existingSummary = conv.compressionSummary || '';
|
|
281
|
+
|
|
282
|
+
// 分割:旧消息(压缩)和新消息(保留)
|
|
283
|
+
let keepFrom = messages.length - PRESERVE_RECENT;
|
|
284
|
+
// 边界处理:向后扫描,不拆开 assistant(tool_calls) + tool 配对
|
|
285
|
+
while (keepFrom > 0) {
|
|
286
|
+
const msg = messages[keepFrom];
|
|
287
|
+
if (msg?.role === 'tool') {
|
|
288
|
+
let j = keepFrom - 1;
|
|
289
|
+
while (j > 0 && messages[j]?.role === 'tool') j--;
|
|
290
|
+
if (messages[j]?.role === 'assistant' && messages[j]?.tool_calls) {
|
|
291
|
+
keepFrom = j;
|
|
292
|
+
}
|
|
293
|
+
break;
|
|
294
|
+
}
|
|
295
|
+
break;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const oldMessages = messages.slice(0, keepFrom);
|
|
299
|
+
const recentMessages = messages.slice(keepFrom);
|
|
300
|
+
|
|
301
|
+
if (oldMessages.length === 0) return null;
|
|
302
|
+
|
|
303
|
+
// 构建启发式摘要信息
|
|
304
|
+
const userMsgs = oldMessages.filter(m => m.role === 'user').length;
|
|
305
|
+
const assistantMsgs = oldMessages.filter(m => m.role === 'assistant').length;
|
|
306
|
+
const toolMsgs = oldMessages.filter(m => m.role === 'tool').length;
|
|
307
|
+
const toolNames = [...new Set(
|
|
308
|
+
oldMessages.filter(m => m.tool_calls).flatMap(m => m.tool_calls.map(tc => tc.function?.name)).filter(Boolean)
|
|
309
|
+
)];
|
|
310
|
+
|
|
311
|
+
const stats = [
|
|
312
|
+
`- 范围: ${oldMessages.length} 条旧消息 (user=${userMsgs}, assistant=${assistantMsgs}, tool=${toolMsgs})`,
|
|
313
|
+
toolNames.length > 0 ? `- 使用的工具: ${toolNames.join(', ')}` : null,
|
|
314
|
+
existingSummary ? `- 之前的摘要:\n${existingSummary}` : null,
|
|
315
|
+
].filter(Boolean).join('\n');
|
|
316
|
+
|
|
317
|
+
const recentUserMsgs = oldMessages.filter(m => m.role === 'user').slice(-3)
|
|
318
|
+
.map(m => typeof m.content === 'string' ? m.content.slice(0, 200) : '').filter(Boolean);
|
|
319
|
+
|
|
320
|
+
// 调用 LLM 生成摘要
|
|
321
|
+
const compressPrompt = `请将以下对话历史压缩为简洁的摘要。保留所有关键信息:用户的问题意图、发现的问题、工具调用的关键结果、得出的结论和建议。
|
|
322
|
+
|
|
323
|
+
对话统计:
|
|
324
|
+
${stats}
|
|
325
|
+
|
|
326
|
+
最近的用户问题:
|
|
327
|
+
${recentUserMsgs.map((m, i) => `${i + 1}. ${m}`).join('\n')}
|
|
328
|
+
|
|
329
|
+
请用中文输出摘要,格式:
|
|
330
|
+
1. 用户的主要目标/问题
|
|
331
|
+
2. 已完成的调查/操作
|
|
332
|
+
3. 关键发现和结论
|
|
333
|
+
4. 未完成的工作(如有)
|
|
334
|
+
|
|
335
|
+
摘要控制在 500 字以内。`;
|
|
336
|
+
|
|
337
|
+
let summary;
|
|
338
|
+
try {
|
|
339
|
+
const res = await fetch(proxyUrl, {
|
|
340
|
+
method: 'POST',
|
|
341
|
+
headers: proxyHeaders,
|
|
342
|
+
signal: AbortSignal.timeout(60000),
|
|
343
|
+
body: JSON.stringify({
|
|
344
|
+
model: defaultModel || 'gpt-4o',
|
|
345
|
+
messages: [
|
|
346
|
+
{ role: 'system', content: '你是一个对话摘要助手。简洁准确地总结对话要点。' },
|
|
347
|
+
{ role: 'user', content: compressPrompt },
|
|
348
|
+
],
|
|
349
|
+
max_tokens: 1024,
|
|
350
|
+
stream: false,
|
|
351
|
+
}),
|
|
352
|
+
});
|
|
353
|
+
if (res.ok) {
|
|
354
|
+
const data = await res.json();
|
|
355
|
+
summary = data.choices?.[0]?.message?.content || '';
|
|
356
|
+
}
|
|
357
|
+
} catch (err) {
|
|
358
|
+
logger.log(`[compress] LLM 摘要失败: ${err.message}`);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// LLM 失败 → 启发式降级
|
|
362
|
+
if (!summary) {
|
|
363
|
+
const lastAssistant = oldMessages.filter(m => m.role === 'assistant' && m.content).pop();
|
|
364
|
+
const userQuestions = oldMessages.filter(m => m.role === 'user')
|
|
365
|
+
.map(m => typeof m.content === 'string' ? m.content.slice(0, 100) : '')
|
|
366
|
+
.filter(Boolean).slice(-3);
|
|
367
|
+
summary = stats +
|
|
368
|
+
(userQuestions.length ? '\n- 最近用户问题:\n' + userQuestions.map((q, i) => ` ${i + 1}. ${q}`).join('\n') : '') +
|
|
369
|
+
'\n- 最近内容: ' + (lastAssistant?.content || '').slice(0, 300);
|
|
370
|
+
logger.log('[compress] 使用启发式降级摘要');
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// 重建消息数组(不含 system 消息,由 buildMessages() 负责注入)
|
|
374
|
+
const newMessages = [...recentMessages];
|
|
375
|
+
const newTokens = estimateConversationTokens(newMessages);
|
|
376
|
+
return { messages: newMessages, summary, removedCount: oldMessages.length, newTokens };
|
|
377
|
+
}
|
|
378
|
+
|
|
245
379
|
// ==================== 助手工具定义与执行器 ====================
|
|
246
380
|
|
|
381
|
+
const MAX_TOOL_OUTPUT = 16384; // 16KB — 防止工具输出撑爆 LLM 上下文
|
|
382
|
+
|
|
383
|
+
function truncateOutput(obj) {
|
|
384
|
+
const str = typeof obj === 'string' ? obj : JSON.stringify(obj);
|
|
385
|
+
if (str.length <= MAX_TOOL_OUTPUT) return obj;
|
|
386
|
+
const truncated = str.slice(0, MAX_TOOL_OUTPUT);
|
|
387
|
+
return { _truncated: true, _original_bytes: str.length, _preview: truncated + '\n... [截断,原始输出 ' + str.length + ' 字符]' };
|
|
388
|
+
}
|
|
389
|
+
|
|
247
390
|
const TOOL_DEFINITIONS = [
|
|
248
391
|
{
|
|
249
392
|
type: 'function',
|
|
@@ -438,174 +581,1184 @@ async function init() {
|
|
|
438
581
|
},
|
|
439
582
|
},
|
|
440
583
|
},
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
584
|
+
{
|
|
585
|
+
type: 'function',
|
|
586
|
+
function: {
|
|
587
|
+
name: 'edit_file',
|
|
588
|
+
description: '精确替换文件中的字符串。比 write_file 更安全,只替换匹配的内容,不会覆盖整个文件。',
|
|
589
|
+
parameters: {
|
|
590
|
+
type: 'object',
|
|
591
|
+
properties: {
|
|
592
|
+
path: { type: 'string', description: '文件路径' },
|
|
593
|
+
old_string: { type: 'string', description: '要被替换的原始字符串(必须精确匹配)' },
|
|
594
|
+
new_string: { type: 'string', description: '替换后的新字符串' },
|
|
595
|
+
replace_all: { type: 'boolean', description: '是否替换所有匹配项,默认 false(只替换第一个)' },
|
|
596
|
+
},
|
|
597
|
+
required: ['path', 'old_string', 'new_string'],
|
|
598
|
+
},
|
|
599
|
+
},
|
|
450
600
|
},
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
601
|
+
{
|
|
602
|
+
type: 'function',
|
|
603
|
+
function: {
|
|
604
|
+
name: 'grep_search',
|
|
605
|
+
description: '在文件内容中搜索正则表达式模式。用于查找代码、日志关键字等。',
|
|
606
|
+
parameters: {
|
|
607
|
+
type: 'object',
|
|
608
|
+
properties: {
|
|
609
|
+
pattern: { type: 'string', description: '正则表达式模式' },
|
|
610
|
+
path: { type: 'string', description: '搜索目录或文件路径,默认当前工作目录' },
|
|
611
|
+
glob: { type: 'string', description: '文件名过滤,如 "*.js" 或 "*.log"' },
|
|
612
|
+
max_results: { type: 'number', description: '最大返回匹配数,默认 50' },
|
|
613
|
+
},
|
|
614
|
+
required: ['pattern'],
|
|
615
|
+
},
|
|
616
|
+
},
|
|
465
617
|
},
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
618
|
+
{
|
|
619
|
+
type: 'function',
|
|
620
|
+
function: {
|
|
621
|
+
name: 'invoke_skill',
|
|
622
|
+
description: '调用指定的技能,获取其指令内容。当用户输入 /技能名 或需要执行预定义流程时使用。',
|
|
623
|
+
parameters: {
|
|
624
|
+
type: 'object',
|
|
625
|
+
properties: {
|
|
626
|
+
name: { type: 'string', description: '技能名称' },
|
|
627
|
+
},
|
|
628
|
+
required: ['name'],
|
|
629
|
+
},
|
|
630
|
+
},
|
|
472
631
|
},
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
632
|
+
// --- 供应商管理 ---
|
|
633
|
+
{
|
|
634
|
+
type: 'function',
|
|
635
|
+
function: {
|
|
636
|
+
name: 'create_provider',
|
|
637
|
+
description: '创建新的供应商。需提供名称、URL 和协议(默认自动检测)。',
|
|
638
|
+
parameters: {
|
|
639
|
+
type: 'object',
|
|
640
|
+
properties: {
|
|
641
|
+
name: { type: 'string', description: '供应商名称' },
|
|
642
|
+
url: { type: 'string', description: '供应商 API 地址' },
|
|
643
|
+
protocol: { type: 'string', enum: ['openai', 'anthropic', 'gemini'], description: '协议类型,默认自动检测' },
|
|
644
|
+
apiKey: { type: 'string', description: 'API Key(单个)' },
|
|
645
|
+
apiKeys: { type: 'array', items: { type: 'object', properties: { key: { type: 'string' }, alias: { type: 'string' } } }, description: '多个 API Key 数组' },
|
|
646
|
+
models: { type: 'array', items: { type: 'string' }, description: '可用模型列表' },
|
|
647
|
+
},
|
|
648
|
+
required: ['name', 'url'],
|
|
649
|
+
},
|
|
650
|
+
},
|
|
479
651
|
},
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
652
|
+
{
|
|
653
|
+
type: 'function',
|
|
654
|
+
function: {
|
|
655
|
+
name: 'update_provider',
|
|
656
|
+
description: '更新供应商配置。只传需要修改的字段即可。',
|
|
657
|
+
parameters: {
|
|
658
|
+
type: 'object',
|
|
659
|
+
properties: {
|
|
660
|
+
providerId: { type: 'string', description: '供应商 ID' },
|
|
661
|
+
name: { type: 'string', description: '新的名称' },
|
|
662
|
+
url: { type: 'string', description: '新的 URL' },
|
|
663
|
+
protocol: { type: 'string', enum: ['openai', 'anthropic', 'gemini'], description: '新的协议' },
|
|
664
|
+
apiKey: { type: 'string', description: '新的 API Key' },
|
|
665
|
+
models: { type: 'array', items: { type: 'string' }, description: '新的模型列表' },
|
|
666
|
+
},
|
|
667
|
+
required: ['providerId'],
|
|
668
|
+
},
|
|
669
|
+
},
|
|
670
|
+
},
|
|
671
|
+
{
|
|
672
|
+
type: 'function',
|
|
673
|
+
function: {
|
|
674
|
+
name: 'delete_provider',
|
|
675
|
+
description: '删除供应商。如果有代理正在使用该供应商则无法删除。',
|
|
676
|
+
parameters: {
|
|
677
|
+
type: 'object',
|
|
678
|
+
properties: {
|
|
679
|
+
providerId: { type: 'string', description: '供应商 ID' },
|
|
680
|
+
},
|
|
681
|
+
required: ['providerId'],
|
|
682
|
+
},
|
|
683
|
+
},
|
|
684
|
+
},
|
|
685
|
+
{
|
|
686
|
+
type: 'function',
|
|
687
|
+
function: {
|
|
688
|
+
name: 'test_provider_keys',
|
|
689
|
+
description: '测试供应商的 API Key 是否可用,返回每个 Key 的连通状态和延迟。',
|
|
690
|
+
parameters: {
|
|
691
|
+
type: 'object',
|
|
692
|
+
properties: {
|
|
693
|
+
providerId: { type: 'string', description: '供应商 ID' },
|
|
694
|
+
},
|
|
695
|
+
required: ['providerId'],
|
|
696
|
+
},
|
|
697
|
+
},
|
|
698
|
+
},
|
|
699
|
+
{
|
|
700
|
+
type: 'function',
|
|
701
|
+
function: {
|
|
702
|
+
name: 'get_provider_models',
|
|
703
|
+
description: '从供应商 API 拉取实际可用的模型列表。',
|
|
704
|
+
parameters: {
|
|
705
|
+
type: 'object',
|
|
706
|
+
properties: {
|
|
707
|
+
providerId: { type: 'string', description: '供应商 ID' },
|
|
708
|
+
},
|
|
709
|
+
required: ['providerId'],
|
|
710
|
+
},
|
|
711
|
+
},
|
|
712
|
+
},
|
|
713
|
+
// --- 代理管理 ---
|
|
714
|
+
{
|
|
715
|
+
type: 'function',
|
|
716
|
+
function: {
|
|
717
|
+
name: 'create_proxy',
|
|
718
|
+
description: '创建新代理并自动启动。需要指定名称、端口和关联的供应商 ID。',
|
|
719
|
+
parameters: {
|
|
720
|
+
type: 'object',
|
|
721
|
+
properties: {
|
|
722
|
+
name: { type: 'string', description: '代理名称' },
|
|
723
|
+
port: { type: 'number', description: '监听端口(不能与已有代理冲突)' },
|
|
724
|
+
providerId: { type: 'string', description: '关联的供应商 ID' },
|
|
725
|
+
defaultModel: { type: 'string', description: '默认模型名' },
|
|
726
|
+
routingStrategy: { type: 'string', enum: ['primary_fallback', 'round_robin', 'weighted', 'fastest'], description: '路由策略' },
|
|
727
|
+
},
|
|
728
|
+
required: ['name', 'port', 'providerId'],
|
|
729
|
+
},
|
|
730
|
+
},
|
|
731
|
+
},
|
|
732
|
+
{
|
|
733
|
+
type: 'function',
|
|
734
|
+
function: {
|
|
735
|
+
name: 'update_proxy',
|
|
736
|
+
description: '更新代理配置。只传需要修改的字段即可。修改端口会自动重启代理。',
|
|
737
|
+
parameters: {
|
|
738
|
+
type: 'object',
|
|
739
|
+
properties: {
|
|
740
|
+
proxyId: { type: 'string', description: '代理 ID' },
|
|
741
|
+
name: { type: 'string', description: '新名称' },
|
|
742
|
+
port: { type: 'number', description: '新端口' },
|
|
743
|
+
providerId: { type: 'string', description: '新的供应商 ID' },
|
|
744
|
+
defaultModel: { type: 'string', description: '新的默认模型' },
|
|
745
|
+
routingStrategy: { type: 'string', enum: ['primary_fallback', 'round_robin', 'weighted', 'fastest'], description: '新的路由策略' },
|
|
746
|
+
},
|
|
747
|
+
required: ['proxyId'],
|
|
748
|
+
},
|
|
749
|
+
},
|
|
750
|
+
},
|
|
751
|
+
{
|
|
752
|
+
type: 'function',
|
|
753
|
+
function: {
|
|
754
|
+
name: 'delete_proxy',
|
|
755
|
+
description: '删除代理,会先停止其运行。',
|
|
756
|
+
parameters: {
|
|
757
|
+
type: 'object',
|
|
758
|
+
properties: {
|
|
759
|
+
proxyId: { type: 'string', description: '代理 ID' },
|
|
760
|
+
},
|
|
761
|
+
required: ['proxyId'],
|
|
762
|
+
},
|
|
763
|
+
},
|
|
764
|
+
},
|
|
765
|
+
{
|
|
766
|
+
type: 'function',
|
|
767
|
+
function: {
|
|
768
|
+
name: 'start_proxy',
|
|
769
|
+
description: '启动指定代理。',
|
|
770
|
+
parameters: {
|
|
771
|
+
type: 'object',
|
|
772
|
+
properties: {
|
|
773
|
+
proxyId: { type: 'string', description: '代理 ID' },
|
|
774
|
+
},
|
|
775
|
+
required: ['proxyId'],
|
|
776
|
+
},
|
|
777
|
+
},
|
|
778
|
+
},
|
|
779
|
+
{
|
|
780
|
+
type: 'function',
|
|
781
|
+
function: {
|
|
782
|
+
name: 'stop_proxy',
|
|
783
|
+
description: '停止指定代理。',
|
|
784
|
+
parameters: {
|
|
785
|
+
type: 'object',
|
|
786
|
+
properties: {
|
|
787
|
+
proxyId: { type: 'string', description: '代理 ID' },
|
|
788
|
+
},
|
|
789
|
+
required: ['proxyId'],
|
|
790
|
+
},
|
|
791
|
+
},
|
|
792
|
+
},
|
|
793
|
+
{
|
|
794
|
+
type: 'function',
|
|
795
|
+
function: {
|
|
796
|
+
name: 'start_all_proxies',
|
|
797
|
+
description: '批量启动所有代理。已在运行中的会跳过。',
|
|
798
|
+
parameters: { type: 'object', properties: {}, required: [] },
|
|
799
|
+
},
|
|
800
|
+
},
|
|
801
|
+
{
|
|
802
|
+
type: 'function',
|
|
803
|
+
function: {
|
|
804
|
+
name: 'stop_all_proxies',
|
|
805
|
+
description: '批量停止所有运行中的代理。',
|
|
806
|
+
parameters: { type: 'object', properties: {}, required: [] },
|
|
807
|
+
},
|
|
808
|
+
},
|
|
809
|
+
// --- MCP 服务器管理 ---
|
|
810
|
+
{
|
|
811
|
+
type: 'function',
|
|
812
|
+
function: {
|
|
813
|
+
name: 'get_mcp_servers',
|
|
814
|
+
description: '获取所有 MCP 服务器列表及运行状态。',
|
|
815
|
+
parameters: { type: 'object', properties: {}, required: [] },
|
|
816
|
+
},
|
|
817
|
+
},
|
|
818
|
+
{
|
|
819
|
+
type: 'function',
|
|
820
|
+
function: {
|
|
821
|
+
name: 'add_mcp_server',
|
|
822
|
+
description: '添加新的 MCP 服务器。本地进程用 command,远程服务用 url。',
|
|
823
|
+
parameters: {
|
|
824
|
+
type: 'object',
|
|
825
|
+
properties: {
|
|
826
|
+
name: { type: 'string', description: '服务名称' },
|
|
827
|
+
command: { type: 'string', description: '本地进程启动命令(如 npx、uvx)' },
|
|
828
|
+
args: { type: 'array', items: { type: 'string' }, description: '命令参数' },
|
|
829
|
+
env: { type: 'object', description: '环境变量' },
|
|
830
|
+
url: { type: 'string', description: '远程 MCP 服务 URL' },
|
|
831
|
+
headers: { type: 'object', description: 'HTTP 请求头' },
|
|
832
|
+
},
|
|
833
|
+
required: ['name'],
|
|
834
|
+
},
|
|
835
|
+
},
|
|
836
|
+
},
|
|
837
|
+
{
|
|
838
|
+
type: 'function',
|
|
839
|
+
function: {
|
|
840
|
+
name: 'update_mcp_server',
|
|
841
|
+
description: '更新 MCP 服务器配置。',
|
|
842
|
+
parameters: {
|
|
843
|
+
type: 'object',
|
|
844
|
+
properties: {
|
|
845
|
+
name: { type: 'string', description: '服务名称' },
|
|
846
|
+
command: { type: 'string', description: '新的启动命令' },
|
|
847
|
+
args: { type: 'array', items: { type: 'string' }, description: '新的参数' },
|
|
848
|
+
env: { type: 'object', description: '新的环境变量' },
|
|
849
|
+
url: { type: 'string', description: '新的 URL' },
|
|
850
|
+
enabled: { type: 'boolean', description: '是否启用' },
|
|
851
|
+
},
|
|
852
|
+
required: ['name'],
|
|
853
|
+
},
|
|
854
|
+
},
|
|
855
|
+
},
|
|
856
|
+
{
|
|
857
|
+
type: 'function',
|
|
858
|
+
function: {
|
|
859
|
+
name: 'delete_mcp_server',
|
|
860
|
+
description: '删除 MCP 服务器,会先断开连接。',
|
|
861
|
+
parameters: {
|
|
862
|
+
type: 'object',
|
|
863
|
+
properties: {
|
|
864
|
+
name: { type: 'string', description: '服务名称' },
|
|
865
|
+
},
|
|
866
|
+
required: ['name'],
|
|
867
|
+
},
|
|
868
|
+
},
|
|
869
|
+
},
|
|
870
|
+
{
|
|
871
|
+
type: 'function',
|
|
872
|
+
function: {
|
|
873
|
+
name: 'connect_mcp_server',
|
|
874
|
+
description: '连接指定的 MCP 服务器。',
|
|
875
|
+
parameters: {
|
|
876
|
+
type: 'object',
|
|
877
|
+
properties: {
|
|
878
|
+
name: { type: 'string', description: '服务名称' },
|
|
879
|
+
},
|
|
880
|
+
required: ['name'],
|
|
881
|
+
},
|
|
882
|
+
},
|
|
883
|
+
},
|
|
884
|
+
{
|
|
885
|
+
type: 'function',
|
|
886
|
+
function: {
|
|
887
|
+
name: 'disconnect_mcp_server',
|
|
888
|
+
description: '断开指定的 MCP 服务器。',
|
|
889
|
+
parameters: {
|
|
890
|
+
type: 'object',
|
|
891
|
+
properties: {
|
|
892
|
+
name: { type: 'string', description: '服务名称' },
|
|
893
|
+
},
|
|
894
|
+
required: ['name'],
|
|
895
|
+
},
|
|
896
|
+
},
|
|
897
|
+
},
|
|
898
|
+
{
|
|
899
|
+
type: 'function',
|
|
900
|
+
function: {
|
|
901
|
+
name: 'get_mcp_tools',
|
|
902
|
+
description: '获取所有已连接 MCP 服务器提供的工具列表。',
|
|
903
|
+
parameters: { type: 'object', properties: {}, required: [] },
|
|
904
|
+
},
|
|
905
|
+
},
|
|
906
|
+
// --- 技能管理 ---
|
|
907
|
+
{
|
|
908
|
+
type: 'function',
|
|
909
|
+
function: {
|
|
910
|
+
name: 'get_skills',
|
|
911
|
+
description: '获取所有已创建的技能列表。',
|
|
912
|
+
parameters: { type: 'object', properties: {}, required: [] },
|
|
913
|
+
},
|
|
914
|
+
},
|
|
915
|
+
{
|
|
916
|
+
type: 'function',
|
|
917
|
+
function: {
|
|
918
|
+
name: 'create_skill',
|
|
919
|
+
description: '创建新技能。技能是预定义的指令模板,用户可通过 /技能名 触发。',
|
|
920
|
+
parameters: {
|
|
921
|
+
type: 'object',
|
|
922
|
+
properties: {
|
|
923
|
+
name: { type: 'string', description: '技能名称(英文、数字、下划线、连字符)' },
|
|
924
|
+
description: { type: 'string', description: '技能描述' },
|
|
925
|
+
content: { type: 'string', description: '技能指令内容(Markdown 格式)' },
|
|
926
|
+
},
|
|
927
|
+
required: ['name', 'content'],
|
|
928
|
+
},
|
|
929
|
+
},
|
|
930
|
+
},
|
|
931
|
+
{
|
|
932
|
+
type: 'function',
|
|
933
|
+
function: {
|
|
934
|
+
name: 'update_skill',
|
|
935
|
+
description: '更新现有技能的描述或指令内容。',
|
|
936
|
+
parameters: {
|
|
937
|
+
type: 'object',
|
|
938
|
+
properties: {
|
|
939
|
+
name: { type: 'string', description: '技能名称' },
|
|
940
|
+
description: { type: 'string', description: '新的描述' },
|
|
941
|
+
content: { type: 'string', description: '新的指令内容' },
|
|
942
|
+
},
|
|
943
|
+
required: ['name'],
|
|
944
|
+
},
|
|
945
|
+
},
|
|
946
|
+
},
|
|
947
|
+
{
|
|
948
|
+
type: 'function',
|
|
949
|
+
function: {
|
|
950
|
+
name: 'delete_skill',
|
|
951
|
+
description: '删除技能。系统级技能不可删除。',
|
|
952
|
+
parameters: {
|
|
953
|
+
type: 'object',
|
|
954
|
+
properties: {
|
|
955
|
+
name: { type: 'string', description: '技能名称' },
|
|
956
|
+
},
|
|
957
|
+
required: ['name'],
|
|
958
|
+
},
|
|
959
|
+
},
|
|
960
|
+
},
|
|
961
|
+
// --- 配置管理 ---
|
|
962
|
+
{
|
|
963
|
+
type: 'function',
|
|
964
|
+
function: {
|
|
965
|
+
name: 'export_config',
|
|
966
|
+
description: '导出当前系统配置(供应商和代理),可用于备份或迁移。',
|
|
967
|
+
parameters: { type: 'object', properties: {}, required: [] },
|
|
968
|
+
},
|
|
969
|
+
},
|
|
970
|
+
{
|
|
971
|
+
type: 'function',
|
|
972
|
+
function: {
|
|
973
|
+
name: 'import_config',
|
|
974
|
+
description: '导入配置。overwrite 模式替换全部,merge 模式按 ID 合并。',
|
|
975
|
+
parameters: {
|
|
976
|
+
type: 'object',
|
|
977
|
+
properties: {
|
|
978
|
+
config: {
|
|
979
|
+
type: 'object',
|
|
980
|
+
description: '配置对象,包含 providers 和 proxies 数组',
|
|
981
|
+
properties: {
|
|
982
|
+
providers: { type: 'array', description: '供应商数组' },
|
|
983
|
+
proxies: { type: 'array', description: '代理数组' },
|
|
984
|
+
},
|
|
985
|
+
},
|
|
986
|
+
mode: { type: 'string', enum: ['overwrite', 'merge'], description: '导入模式' },
|
|
987
|
+
},
|
|
988
|
+
required: ['config', 'mode'],
|
|
989
|
+
},
|
|
990
|
+
},
|
|
991
|
+
},
|
|
992
|
+
{
|
|
993
|
+
type: 'function',
|
|
994
|
+
function: {
|
|
995
|
+
name: 'rollback_config',
|
|
996
|
+
description: '回滚到指定的配置快照。',
|
|
997
|
+
parameters: {
|
|
998
|
+
type: 'object',
|
|
999
|
+
properties: {
|
|
1000
|
+
file: { type: 'string', description: '快照文件名(从 get_config_history 获取)' },
|
|
1001
|
+
},
|
|
1002
|
+
required: ['file'],
|
|
1003
|
+
},
|
|
1004
|
+
},
|
|
1005
|
+
},
|
|
1006
|
+
// --- 系统操作 ---
|
|
1007
|
+
{
|
|
1008
|
+
type: 'function',
|
|
1009
|
+
function: {
|
|
1010
|
+
name: 'update_settings',
|
|
1011
|
+
description: '更新系统设置。传入需要修改的键值对即可。',
|
|
1012
|
+
parameters: {
|
|
1013
|
+
type: 'object',
|
|
1014
|
+
properties: {
|
|
1015
|
+
settings: { type: 'object', description: '要更新的设置键值对' },
|
|
1016
|
+
},
|
|
1017
|
+
required: ['settings'],
|
|
1018
|
+
},
|
|
1019
|
+
},
|
|
1020
|
+
},
|
|
1021
|
+
{
|
|
1022
|
+
type: 'function',
|
|
1023
|
+
function: {
|
|
1024
|
+
name: 'trigger_key_health_check',
|
|
1025
|
+
description: '手动触发所有供应商的 API Key 健康检查。',
|
|
1026
|
+
parameters: { type: 'object', properties: {}, required: [] },
|
|
1027
|
+
},
|
|
1028
|
+
},
|
|
1029
|
+
{
|
|
1030
|
+
type: 'function',
|
|
1031
|
+
function: {
|
|
1032
|
+
name: 'check_health',
|
|
1033
|
+
description: '系统健康检查,返回版本、运行时长、代理状态。',
|
|
1034
|
+
parameters: { type: 'object', properties: {}, required: [] },
|
|
1035
|
+
},
|
|
1036
|
+
},
|
|
1037
|
+
];
|
|
1038
|
+
|
|
1039
|
+
const TOOL_HANDLERS = {
|
|
1040
|
+
get_system_status: async () => {
|
|
1041
|
+
const proxies = configStore.getProxies().map(p => {
|
|
1042
|
+
const provider = configStore.getProviderById(p.providerId);
|
|
1043
|
+
return { name: p.name, port: p.port, running: proxyManager.isRunning(p.id), providerName: provider?.name || '' };
|
|
1044
|
+
});
|
|
1045
|
+
return { proxies, providerCount: configStore.getProviders().length, uptime: Math.floor(process.uptime()) };
|
|
1046
|
+
},
|
|
1047
|
+
|
|
1048
|
+
get_providers: async () => {
|
|
1049
|
+
return configStore.getProviders().map(p => {
|
|
1050
|
+
const h = keyHealth.get(p.id);
|
|
1051
|
+
let healthStatus = '未检测';
|
|
1052
|
+
if (h) {
|
|
1053
|
+
const ok = h.keys?.filter(k => k.ok).length || 0;
|
|
1054
|
+
const total = h.keys?.length || 0;
|
|
1055
|
+
healthStatus = h.status === 'healthy' ? `健康 (${ok}/${total})` :
|
|
1056
|
+
h.status === 'partial' ? `部分异常 (${ok}/${total})` :
|
|
1057
|
+
h.status === 'unhealthy' ? `异常 (${ok}/${total})` : '未检测';
|
|
1058
|
+
}
|
|
1059
|
+
return { id: p.id, name: p.name, url: p.url, protocol: p.protocol, keyCount: (p.apiKeys || []).length, health: healthStatus };
|
|
1060
|
+
});
|
|
1061
|
+
},
|
|
1062
|
+
|
|
1063
|
+
get_provider: async (args) => {
|
|
1064
|
+
const p = configStore.getProviderById(args.providerId);
|
|
1065
|
+
if (!p) return { error: `供应商 ${args.providerId} 不存在` };
|
|
1066
|
+
const h = keyHealth.get(p.id);
|
|
1067
|
+
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 };
|
|
1068
|
+
},
|
|
1069
|
+
|
|
1070
|
+
get_proxies: async () => {
|
|
1071
|
+
return configStore.getProxies().map(p => {
|
|
1072
|
+
const provider = configStore.getProviderById(p.providerId);
|
|
1073
|
+
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' };
|
|
1074
|
+
});
|
|
1075
|
+
},
|
|
1076
|
+
|
|
1077
|
+
get_proxy: async (args) => {
|
|
1078
|
+
const p = configStore.getProxyById(args.proxyId);
|
|
1079
|
+
if (!p) return { error: `代理 ${args.proxyId} 不存在` };
|
|
1080
|
+
const provider = configStore.getProviderById(p.providerId);
|
|
1081
|
+
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 };
|
|
1082
|
+
},
|
|
1083
|
+
|
|
1084
|
+
get_usage_stats: async (args) => {
|
|
1085
|
+
return statsStore.getStats({ range: args.range || 'daily', startDate: args.startDate, endDate: args.endDate, proxyId: args.proxyId });
|
|
1086
|
+
},
|
|
1087
|
+
|
|
1088
|
+
get_recent_requests: async (args) => {
|
|
1089
|
+
const limit = Math.min(Math.max(1, parseInt(args.limit) || 20), 100);
|
|
1090
|
+
return { entries: requestLog.getAll(limit) };
|
|
1091
|
+
},
|
|
1092
|
+
|
|
1093
|
+
get_system_logs: async (args) => {
|
|
1094
|
+
const limit = Math.min(Math.max(1, parseInt(args.limit) || 30), 100);
|
|
1095
|
+
try {
|
|
1096
|
+
const content = await fs.promises.readFile(logger.LOG_FILE, 'utf8');
|
|
1097
|
+
const allLines = content.split('\n').filter(l => l.trim());
|
|
1098
|
+
return { lines: allLines.slice(-limit) };
|
|
1099
|
+
} catch {
|
|
1100
|
+
return { lines: [] };
|
|
1101
|
+
}
|
|
1102
|
+
},
|
|
1103
|
+
|
|
1104
|
+
get_key_health: async () => {
|
|
1105
|
+
const result = {};
|
|
1106
|
+
for (const [providerId, health] of keyHealth) {
|
|
1107
|
+
result[providerId] = health;
|
|
1108
|
+
}
|
|
1109
|
+
return result;
|
|
1110
|
+
},
|
|
1111
|
+
|
|
1112
|
+
get_settings: async () => {
|
|
1113
|
+
return configStore.getSettings();
|
|
1114
|
+
},
|
|
1115
|
+
|
|
1116
|
+
get_config_history: async () => {
|
|
1117
|
+
return { snapshots: configStore.getSnapshots() };
|
|
1118
|
+
},
|
|
1119
|
+
|
|
1120
|
+
read_file: async (args) => {
|
|
1121
|
+
const filePath = path.resolve(args.path);
|
|
1122
|
+
try {
|
|
1123
|
+
// 二进制检测:检查前 8KB 是否含 NUL 字节
|
|
1124
|
+
const stat = await fs.promises.stat(filePath);
|
|
1125
|
+
const peekSize = Math.min(8192, stat.size);
|
|
1126
|
+
if (peekSize > 0) {
|
|
1127
|
+
const fd = await fs.promises.open(filePath, 'r');
|
|
1128
|
+
try {
|
|
1129
|
+
const buf = Buffer.alloc(peekSize);
|
|
1130
|
+
await fd.read(buf, 0, peekSize, 0);
|
|
1131
|
+
if (buf.includes(0)) {
|
|
1132
|
+
return { error: `二进制文件,无法以文本方式读取 (${filePath}, ${stat.size} bytes)` };
|
|
1133
|
+
}
|
|
1134
|
+
} finally {
|
|
1135
|
+
await fd.close();
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
const content = await fs.promises.readFile(filePath, 'utf8');
|
|
1139
|
+
const lines = content.split('\n');
|
|
1140
|
+
const offset = Math.max(0, parseInt(args.offset) || 0);
|
|
1141
|
+
const limit = Math.min(Math.max(1, parseInt(args.limit) || 500), 2000);
|
|
1142
|
+
const sliced = lines.slice(offset, offset + limit);
|
|
1143
|
+
return { content: sliced.join('\n'), totalLines: lines.length, offset, returnedLines: sliced.length };
|
|
1144
|
+
} catch (err) {
|
|
1145
|
+
return { error: err.message };
|
|
1146
|
+
}
|
|
1147
|
+
},
|
|
1148
|
+
|
|
1149
|
+
write_file: async (args) => {
|
|
1150
|
+
const filePath = path.resolve(args.path);
|
|
1151
|
+
try {
|
|
1152
|
+
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
|
|
1153
|
+
await fs.promises.writeFile(filePath, args.content, 'utf8');
|
|
1154
|
+
return { success: true, path: filePath, bytes: Buffer.byteLength(args.content, 'utf8') };
|
|
1155
|
+
} catch (err) {
|
|
1156
|
+
return { error: err.message };
|
|
1157
|
+
}
|
|
1158
|
+
},
|
|
1159
|
+
|
|
1160
|
+
list_directory: async (args) => {
|
|
1161
|
+
const dirPath = path.resolve(args.path || '.');
|
|
1162
|
+
try {
|
|
1163
|
+
const entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
|
|
1164
|
+
return {
|
|
1165
|
+
path: dirPath,
|
|
1166
|
+
entries: entries.map(e => ({
|
|
1167
|
+
name: e.name,
|
|
1168
|
+
type: e.isDirectory() ? 'directory' : 'file',
|
|
1169
|
+
})),
|
|
1170
|
+
};
|
|
1171
|
+
} catch (err) {
|
|
1172
|
+
return { error: err.message };
|
|
1173
|
+
}
|
|
1174
|
+
},
|
|
1175
|
+
|
|
1176
|
+
search_files: async (args) => {
|
|
1177
|
+
const root = path.resolve(args.path || '.');
|
|
1178
|
+
const pattern = args.pattern;
|
|
1179
|
+
try {
|
|
1180
|
+
const results = [];
|
|
1181
|
+
const globToRegex = (g) => {
|
|
1182
|
+
const r = g.replace(/\*\*/g, '§GLOBSTAR§')
|
|
1183
|
+
.replace(/\*/g, '[^/]*')
|
|
1184
|
+
.replace(/\?/g, '[^/]')
|
|
1185
|
+
.replace(/§GLOBSTAR§/g, '.*');
|
|
1186
|
+
return new RegExp('^' + r + '$');
|
|
1187
|
+
};
|
|
1188
|
+
const regex = globToRegex(pattern);
|
|
1189
|
+
const walk = async (dir, rel) => {
|
|
1190
|
+
const entries = await fs.promises.readdir(dir, { withFileTypes: true });
|
|
1191
|
+
for (const e of entries) {
|
|
1192
|
+
const fullPath = path.join(dir, e.name);
|
|
1193
|
+
const relPath = rel ? `${rel}/${e.name}` : e.name;
|
|
1194
|
+
if (e.isDirectory()) {
|
|
1195
|
+
if (e.name === 'node_modules' || e.name === '.git') continue;
|
|
1196
|
+
await walk(fullPath, relPath);
|
|
1197
|
+
} else if (regex.test(relPath)) {
|
|
1198
|
+
results.push(relPath);
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
};
|
|
1202
|
+
await walk(root, '');
|
|
1203
|
+
return { pattern, root, matches: results.slice(0, 200), total: results.length };
|
|
1204
|
+
} catch (err) {
|
|
1205
|
+
return { error: err.message };
|
|
1206
|
+
}
|
|
1207
|
+
},
|
|
1208
|
+
|
|
1209
|
+
execute_command: async (args) => {
|
|
1210
|
+
const timeout = Math.min(Math.max(1000, parseInt(args.timeout) || 30000), 120000);
|
|
1211
|
+
return new Promise((resolve) => {
|
|
1212
|
+
exec(args.command, { cwd: args.cwd || process.cwd(), timeout, maxBuffer: 1024 * 1024 }, (err, stdout, stderr) => {
|
|
1213
|
+
if (err) {
|
|
1214
|
+
resolve({ exitCode: err.code || 1, stdout: stdout || '', stderr: stderr || err.message });
|
|
1215
|
+
} else {
|
|
1216
|
+
resolve({ exitCode: 0, stdout: stdout || '', stderr: stderr || '' });
|
|
1217
|
+
}
|
|
1218
|
+
});
|
|
1219
|
+
});
|
|
1220
|
+
},
|
|
1221
|
+
|
|
1222
|
+
edit_file: async (args) => {
|
|
1223
|
+
const filePath = path.resolve(args.path);
|
|
1224
|
+
try {
|
|
1225
|
+
const content = await fs.promises.readFile(filePath, 'utf8');
|
|
1226
|
+
const { old_string, new_string } = args;
|
|
1227
|
+
if (old_string === new_string) return { error: 'old_string 和 new_string 不能相同' };
|
|
1228
|
+
if (!content.includes(old_string)) return { error: `文件中未找到匹配的字符串` };
|
|
1229
|
+
const replaceAll = !!args.replace_all;
|
|
1230
|
+
const newContent = replaceAll
|
|
1231
|
+
? content.split(old_string).join(new_string)
|
|
1232
|
+
: content.replace(old_string, new_string);
|
|
1233
|
+
const count = replaceAll
|
|
1234
|
+
? content.split(old_string).length - 1
|
|
1235
|
+
: 1;
|
|
1236
|
+
await fs.promises.writeFile(filePath, newContent, 'utf8');
|
|
1237
|
+
return { success: true, path: filePath, replacements: count };
|
|
1238
|
+
} catch (err) {
|
|
1239
|
+
return { error: err.message };
|
|
1240
|
+
}
|
|
1241
|
+
},
|
|
1242
|
+
|
|
1243
|
+
grep_search: async (args) => {
|
|
1244
|
+
const root = path.resolve(args.path || '.');
|
|
1245
|
+
const pattern = args.pattern;
|
|
1246
|
+
const maxResults = Math.min(Math.max(1, parseInt(args.max_results) || 50), 200);
|
|
1247
|
+
const globFilter = args.glob || '';
|
|
1248
|
+
try {
|
|
1249
|
+
const regex = new RegExp(pattern, 'gi');
|
|
1250
|
+
const results = [];
|
|
1251
|
+
const walk = async (dir) => {
|
|
1252
|
+
if (results.length >= maxResults) return;
|
|
1253
|
+
const entries = await fs.promises.readdir(dir, { withFileTypes: true });
|
|
1254
|
+
for (const e of entries) {
|
|
1255
|
+
if (results.length >= maxResults) break;
|
|
1256
|
+
const fullPath = path.join(dir, e.name);
|
|
1257
|
+
if (e.isDirectory()) {
|
|
1258
|
+
if (['node_modules', '.git', 'dist', 'build', '.next'].includes(e.name)) continue;
|
|
1259
|
+
await walk(fullPath);
|
|
1260
|
+
} else if (e.isFile()) {
|
|
1261
|
+
if (globFilter) {
|
|
1262
|
+
const ext = '.' + e.name.split('.').pop();
|
|
1263
|
+
if (!globFilter.includes(ext) && !globFilter.includes(e.name) && !globFilter.includes('*')) continue;
|
|
1264
|
+
}
|
|
1265
|
+
try {
|
|
1266
|
+
const content = await fs.promises.readFile(fullPath, 'utf8');
|
|
1267
|
+
const lines = content.split('\n');
|
|
1268
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1269
|
+
if (results.length >= maxResults) break;
|
|
1270
|
+
if (regex.test(lines[i])) {
|
|
1271
|
+
results.push({
|
|
1272
|
+
file: path.relative(root, fullPath),
|
|
1273
|
+
line: i + 1,
|
|
1274
|
+
content: lines[i].trim().slice(0, 300),
|
|
1275
|
+
});
|
|
1276
|
+
regex.lastIndex = 0;
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
} catch {}
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
};
|
|
1283
|
+
await walk(root);
|
|
1284
|
+
return { pattern, matches: results, total: results.length };
|
|
1285
|
+
} catch (err) {
|
|
1286
|
+
return { error: err.message };
|
|
1287
|
+
}
|
|
1288
|
+
},
|
|
1289
|
+
invoke_skill: async (args) => {
|
|
1290
|
+
const skill = skillStore.get(args.name);
|
|
1291
|
+
if (!skill) return { error: `技能 "${args.name}" 不存在` };
|
|
1292
|
+
const result = { name: skill.name, description: skill.description, content: skill.content, dirPath: skill.dirPath };
|
|
1293
|
+
if (skill.scripts.length > 0) result.scripts = skill.scripts.map(f => `scripts/${f}`);
|
|
1294
|
+
if (skill.references.length > 0) result.references = skill.references.map(f => `reference/${f}`);
|
|
1295
|
+
// 读取 reference 文件内容(文本文件)
|
|
1296
|
+
for (const ref of skill.references) {
|
|
1297
|
+
try {
|
|
1298
|
+
const refPath = path.join(skill.dirPath, 'reference', ref);
|
|
1299
|
+
const stat = fs.statSync(refPath);
|
|
1300
|
+
if (stat.size < 50000) { // 只读小于 50KB 的文本文件
|
|
1301
|
+
result[`reference:${ref}`] = fs.readFileSync(refPath, 'utf8');
|
|
1302
|
+
}
|
|
1303
|
+
} catch {}
|
|
1304
|
+
}
|
|
1305
|
+
return result;
|
|
1306
|
+
},
|
|
1307
|
+
|
|
1308
|
+
// --- 供应商管理 ---
|
|
1309
|
+
create_provider: async (args) => {
|
|
1310
|
+
if (!args.name || !args.url) return { error: 'name 和 url 是必填项' };
|
|
1311
|
+
const provider = configStore.addProvider({
|
|
1312
|
+
name: args.name,
|
|
1313
|
+
url: args.url,
|
|
1314
|
+
protocol: args.protocol || (/anthropic/i.test(args.url) ? 'anthropic' : 'openai'),
|
|
1315
|
+
apiKey: args.apiKey || '',
|
|
1316
|
+
apiKeys: Array.isArray(args.apiKeys) ? args.apiKeys.filter(k => k && k.key && k.key.trim()) : [],
|
|
1317
|
+
models: args.models || [],
|
|
1318
|
+
});
|
|
1319
|
+
return { success: true, id: provider.id, name: provider.name };
|
|
1320
|
+
},
|
|
1321
|
+
|
|
1322
|
+
update_provider: async (args) => {
|
|
1323
|
+
const existing = configStore.getProviderById(args.providerId);
|
|
1324
|
+
if (!existing) return { error: `供应商 ${args.providerId} 不存在` };
|
|
1325
|
+
const updates = {};
|
|
1326
|
+
if (args.name !== undefined) updates.name = args.name;
|
|
1327
|
+
if (args.url !== undefined) updates.url = args.url;
|
|
1328
|
+
if (args.protocol !== undefined) updates.protocol = args.protocol;
|
|
1329
|
+
if (args.apiKey !== undefined && args.apiKey !== '') updates.apiKey = args.apiKey;
|
|
1330
|
+
if (args.apiKeys !== undefined) {
|
|
1331
|
+
updates.apiKeys = Array.isArray(args.apiKeys) ? args.apiKeys.filter(k => k && k.key && k.key.trim()) : [];
|
|
1332
|
+
}
|
|
1333
|
+
if (args.models !== undefined) updates.models = args.models;
|
|
1334
|
+
const updated = configStore.updateProvider(args.providerId, updates);
|
|
1335
|
+
// 同步更新引用此供应商的运行中代理
|
|
1336
|
+
const affectedProxies = configStore.getProxies().filter(p => p.providerId === args.providerId);
|
|
1337
|
+
for (const proxy of affectedProxies) {
|
|
1338
|
+
if (!proxyManager.isRunning(proxy.id)) continue;
|
|
1339
|
+
const target = resolveTarget(proxy);
|
|
1340
|
+
if (target) proxyManager.updateProxyConfig({ ...proxy, target });
|
|
1341
|
+
}
|
|
1342
|
+
return { success: true, id: updated.id, name: updated.name };
|
|
1343
|
+
},
|
|
1344
|
+
|
|
1345
|
+
delete_provider: async (args) => {
|
|
1346
|
+
const existing = configStore.getProviderById(args.providerId);
|
|
1347
|
+
if (!existing) return { error: `供应商 ${args.providerId} 不存在` };
|
|
1348
|
+
const inUse = configStore.getProxies().some(p => p.providerId === args.providerId);
|
|
1349
|
+
if (inUse) return { error: '该供应商正在被代理使用,无法删除' };
|
|
1350
|
+
configStore.removeProvider(args.providerId);
|
|
1351
|
+
return { success: true };
|
|
1352
|
+
},
|
|
1353
|
+
|
|
1354
|
+
test_provider_keys: async (args) => {
|
|
1355
|
+
const provider = configStore.getProviderById(args.providerId);
|
|
1356
|
+
if (!provider) return { error: `供应商 ${args.providerId} 不存在` };
|
|
1357
|
+
const existingKeys = provider.apiKeys || [];
|
|
1358
|
+
if (existingKeys.length === 0) return { ok: false, message: '没有可用的 API Key', results: [] };
|
|
1359
|
+
const protocol = provider.protocol || 'openai';
|
|
1360
|
+
const base = provider.url.replace(/\/$/, '');
|
|
1361
|
+
const hasV1Suffix = base.endsWith('/v1');
|
|
1362
|
+
const isAzure = protocol === 'openai' && !!provider.azureDeployment;
|
|
1363
|
+
|
|
1364
|
+
function buildTestUrl(key) {
|
|
1365
|
+
if (protocol === 'openai') {
|
|
1366
|
+
if (isAzure) {
|
|
1367
|
+
const ver = provider.azureApiVersion || '2024-02-01';
|
|
1368
|
+
return { url: `${base}/openai/deployments/${provider.azureDeployment}/models?api-version=${ver}`, opts: { headers: { 'api-key': key } } };
|
|
1369
|
+
}
|
|
1370
|
+
return { url: hasV1Suffix ? `${base}/models` : `${base}/v1/models`, opts: { headers: { 'Authorization': `Bearer ${key}` } } };
|
|
1371
|
+
}
|
|
1372
|
+
if (protocol === 'anthropic') {
|
|
1373
|
+
const testModel = (provider.models && provider.models[0]) || 'claude-3-haiku-20240307';
|
|
1374
|
+
return { url: hasV1Suffix ? `${base}/messages` : `${base}/v1/messages`, 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' }] }) } };
|
|
1375
|
+
}
|
|
1376
|
+
if (protocol === 'gemini') return { url: `${base}/v1beta/models?key=${key}`, opts: {} };
|
|
1377
|
+
return null;
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
const results = await Promise.all(existingKeys.map(async (k, i) => {
|
|
1381
|
+
const { url: testUrl, opts: fetchOpts } = buildTestUrl(k.key);
|
|
1382
|
+
if (!testUrl) return { ok: false, index: i, message: `不支持的协议: ${protocol}` };
|
|
1383
|
+
try {
|
|
1384
|
+
const startedAt = Date.now();
|
|
1385
|
+
const fetchRes = await fetch(testUrl, { ...fetchOpts, signal: AbortSignal.timeout(15000) });
|
|
1386
|
+
const latencyMs = Date.now() - startedAt;
|
|
1387
|
+
if (!fetchRes.ok) {
|
|
1388
|
+
const errText = await fetchRes.text().catch(() => '');
|
|
1389
|
+
const hint = fetchRes.status === 401 || fetchRes.status === 403 ? 'API Key 无效或无权限' : `HTTP ${fetchRes.status}`;
|
|
1390
|
+
return { ok: false, alias: k.alias || '', index: i, message: hint, latencyMs };
|
|
1391
|
+
}
|
|
1392
|
+
return { ok: true, alias: k.alias || '', index: i, latencyMs };
|
|
1393
|
+
} catch (err) {
|
|
1394
|
+
const msg = err.name === 'TimeoutError' ? '连接超时 (15s)' : `连接失败: ${err.message}`;
|
|
1395
|
+
return { ok: false, alias: k.alias || '', index: i, message: msg };
|
|
1396
|
+
}
|
|
1397
|
+
}));
|
|
1398
|
+
const passed = results.filter(r => r.ok).length;
|
|
1399
|
+
return { ok: passed === existingKeys.length, passed, failed: existingKeys.length - passed, results };
|
|
1400
|
+
},
|
|
1401
|
+
|
|
1402
|
+
get_provider_models: async (args) => {
|
|
1403
|
+
const provider = configStore.getProviderById(args.providerId);
|
|
1404
|
+
if (!provider) return { error: `供应商 ${args.providerId} 不存在` };
|
|
1405
|
+
const enabledKeys = (provider.apiKeys || []).filter(k => k.enabled !== false).map(k => k.key);
|
|
1406
|
+
if (enabledKeys.length === 0) return { error: '没有可用的 API Key' };
|
|
1407
|
+
const key = enabledKeys[0];
|
|
1408
|
+
const protocol = provider.protocol || 'openai';
|
|
1409
|
+
const base = provider.url.replace(/\/$/, '');
|
|
1410
|
+
const hasV1Suffix = base.endsWith('/v1');
|
|
1411
|
+
const isAzure = protocol === 'openai' && !!provider.azureDeployment;
|
|
1412
|
+
let url, headers = {};
|
|
1413
|
+
if (protocol === 'openai') {
|
|
1414
|
+
if (isAzure) {
|
|
1415
|
+
const ver = provider.azureApiVersion || '2024-02-01';
|
|
1416
|
+
url = `${base}/openai/deployments/${provider.azureDeployment}/models?api-version=${ver}`;
|
|
1417
|
+
headers['api-key'] = key;
|
|
1418
|
+
} else {
|
|
1419
|
+
url = hasV1Suffix ? `${base}/models` : `${base}/v1/models`;
|
|
1420
|
+
headers['Authorization'] = `Bearer ${key}`;
|
|
1421
|
+
}
|
|
1422
|
+
} else if (protocol === 'anthropic') {
|
|
1423
|
+
return { models: provider.models || [], message: 'Anthropic 不支持模型列表查询' };
|
|
1424
|
+
} else if (protocol === 'gemini') {
|
|
1425
|
+
url = `${base}/v1beta/models?key=${key}`;
|
|
1426
|
+
} else {
|
|
1427
|
+
return { error: `不支持的协议: ${protocol}` };
|
|
1428
|
+
}
|
|
1429
|
+
try {
|
|
1430
|
+
const res = await fetch(url, { headers, signal: AbortSignal.timeout(15000) });
|
|
1431
|
+
if (!res.ok) return { error: `HTTP ${res.status}: ${await res.text().catch(() => '')}` };
|
|
1432
|
+
const data = await res.json();
|
|
1433
|
+
const models = (data.data || data.models || []).map(m => m.id || m.name).filter(Boolean);
|
|
1434
|
+
return { models };
|
|
1435
|
+
} catch (err) {
|
|
1436
|
+
return { error: err.message };
|
|
1437
|
+
}
|
|
1438
|
+
},
|
|
1439
|
+
|
|
1440
|
+
// --- 代理管理 ---
|
|
1441
|
+
create_proxy: async (args) => {
|
|
1442
|
+
if (!args.name || !args.port || !args.providerId) return { error: 'name, port, providerId 是必填项' };
|
|
1443
|
+
const provider = configStore.getProviderById(args.providerId);
|
|
1444
|
+
if (!provider) return { error: '供应商不存在' };
|
|
1445
|
+
const parsedPort = parseInt(args.port);
|
|
1446
|
+
const existing = configStore.getProxies().find(p => p.port === parsedPort);
|
|
1447
|
+
if (existing) return { error: `端口 ${parsedPort} 已被代理「${existing.name}」占用` };
|
|
1448
|
+
configStore.saveSnapshot('create-proxy');
|
|
1449
|
+
const proxy = configStore.addProxy({
|
|
1450
|
+
name: args.name,
|
|
1451
|
+
port: parsedPort,
|
|
1452
|
+
providerId: args.providerId,
|
|
1453
|
+
defaultModel: args.defaultModel || '',
|
|
1454
|
+
providerWeight: 1,
|
|
1455
|
+
routingStrategy: normalizeRoutingStrategyInput(args.routingStrategy),
|
|
1456
|
+
providerPool: normalizeProviderPoolInput(args.providerPool),
|
|
1457
|
+
});
|
|
1458
|
+
try {
|
|
1459
|
+
await startProxyWithProvider(proxy);
|
|
1460
|
+
return { success: true, id: proxy.id, name: proxy.name, port: proxy.port, running: true };
|
|
1461
|
+
} catch (err) {
|
|
1462
|
+
configStore.removeProxy(proxy.id);
|
|
1463
|
+
return { error: `代理启动失败: ${err.message}` };
|
|
1464
|
+
}
|
|
1465
|
+
},
|
|
1466
|
+
|
|
1467
|
+
update_proxy: async (args) => {
|
|
1468
|
+
const existing = configStore.getProxyById(args.proxyId);
|
|
1469
|
+
if (!existing) return { error: `代理 ${args.proxyId} 不存在` };
|
|
1470
|
+
configStore.saveSnapshot('update-proxy');
|
|
1471
|
+
const updates = {};
|
|
1472
|
+
if (args.name !== undefined) updates.name = args.name;
|
|
1473
|
+
if (args.port !== undefined) updates.port = parseInt(args.port);
|
|
1474
|
+
if (args.providerId !== undefined) {
|
|
1475
|
+
if (!configStore.getProviderById(args.providerId)) return { error: '供应商不存在' };
|
|
1476
|
+
updates.providerId = args.providerId;
|
|
1477
|
+
}
|
|
1478
|
+
if (args.defaultModel !== undefined) updates.defaultModel = args.defaultModel;
|
|
1479
|
+
if (args.routingStrategy !== undefined) updates.routingStrategy = normalizeRoutingStrategyInput(args.routingStrategy);
|
|
1480
|
+
const needRestart = updates.port !== undefined && updates.port !== existing.port;
|
|
1481
|
+
if (needRestart) {
|
|
1482
|
+
const conflict = configStore.getProxies().find(p => p.id !== args.proxyId && p.port === updates.port);
|
|
1483
|
+
if (conflict) return { error: `端口 ${updates.port} 已被代理「${conflict.name}」占用` };
|
|
1484
|
+
}
|
|
1485
|
+
const updated = configStore.updateProxy(args.proxyId, updates);
|
|
1486
|
+
if (needRestart) {
|
|
1487
|
+
try {
|
|
1488
|
+
await startProxyWithProvider(updated);
|
|
1489
|
+
} catch (err) {
|
|
1490
|
+
return { error: `代理重启失败: ${err.message}` };
|
|
1491
|
+
}
|
|
1492
|
+
} else {
|
|
1493
|
+
const target = resolveTarget(updated);
|
|
1494
|
+
if (target) proxyManager.updateProxyConfig({ ...updated, target });
|
|
1495
|
+
}
|
|
1496
|
+
return { success: true, id: updated.id, name: updated.name, running: proxyManager.isRunning(updated.id) };
|
|
1497
|
+
},
|
|
1498
|
+
|
|
1499
|
+
delete_proxy: async (args) => {
|
|
1500
|
+
const existing = configStore.getProxyById(args.proxyId);
|
|
1501
|
+
if (!existing) return { error: `代理 ${args.proxyId} 不存在` };
|
|
1502
|
+
configStore.saveSnapshot('delete-proxy');
|
|
1503
|
+
await proxyManager.stopProxy(args.proxyId);
|
|
1504
|
+
configStore.removeProxy(args.proxyId);
|
|
1505
|
+
return { success: true };
|
|
1506
|
+
},
|
|
1507
|
+
|
|
1508
|
+
start_proxy: async (args) => {
|
|
1509
|
+
const proxy = configStore.getProxyById(args.proxyId);
|
|
1510
|
+
if (!proxy) return { error: `代理 ${args.proxyId} 不存在` };
|
|
1511
|
+
try {
|
|
1512
|
+
await startProxyWithProvider(proxy);
|
|
1513
|
+
return { success: true, running: true };
|
|
1514
|
+
} catch (err) {
|
|
1515
|
+
return { error: `启动失败: ${err.message}` };
|
|
1516
|
+
}
|
|
1517
|
+
},
|
|
1518
|
+
|
|
1519
|
+
stop_proxy: async (args) => {
|
|
1520
|
+
const proxy = configStore.getProxyById(args.proxyId);
|
|
1521
|
+
if (!proxy) return { error: `代理 ${args.proxyId} 不存在` };
|
|
1522
|
+
await proxyManager.stopProxy(args.proxyId);
|
|
1523
|
+
return { success: true, running: false };
|
|
1524
|
+
},
|
|
1525
|
+
|
|
1526
|
+
start_all_proxies: async () => {
|
|
1527
|
+
const proxies = configStore.getProxies();
|
|
1528
|
+
const results = [];
|
|
1529
|
+
for (const proxy of proxies) {
|
|
1530
|
+
if (proxyManager.isRunning(proxy.id)) {
|
|
1531
|
+
results.push({ id: proxy.id, name: proxy.name, skipped: true });
|
|
1532
|
+
continue;
|
|
1533
|
+
}
|
|
1534
|
+
try {
|
|
1535
|
+
await startProxyWithProvider(proxy);
|
|
1536
|
+
results.push({ id: proxy.id, name: proxy.name, success: true });
|
|
1537
|
+
} catch (err) {
|
|
1538
|
+
results.push({ id: proxy.id, name: proxy.name, success: false, error: err.message });
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
return { results };
|
|
1542
|
+
},
|
|
1543
|
+
|
|
1544
|
+
stop_all_proxies: async () => {
|
|
1545
|
+
const running = proxyManager.getRunningPorts();
|
|
1546
|
+
const results = [];
|
|
1547
|
+
for (const r of running) {
|
|
1548
|
+
await proxyManager.stopProxy(r.id);
|
|
1549
|
+
results.push({ id: r.id, name: r.name, success: true });
|
|
1550
|
+
}
|
|
1551
|
+
return { results };
|
|
1552
|
+
},
|
|
1553
|
+
|
|
1554
|
+
// --- MCP 服务器管理 ---
|
|
1555
|
+
get_mcp_servers: async () => {
|
|
1556
|
+
const servers = configStore.getMcpServers();
|
|
1557
|
+
const status = mcpClient.getStatus();
|
|
1558
|
+
const statusMap = Object.fromEntries(status.map(s => [s.name, s]));
|
|
1559
|
+
return Object.entries(servers).map(([name, config]) => ({
|
|
1560
|
+
name,
|
|
1561
|
+
enabled: config.enabled !== false,
|
|
1562
|
+
transport: config.url ? 'http' : 'stdio',
|
|
1563
|
+
command: config.command,
|
|
1564
|
+
url: config.url,
|
|
1565
|
+
...(statusMap[name] || { status: 'disconnected', tools: [], lastError: null }),
|
|
1566
|
+
}));
|
|
1567
|
+
},
|
|
1568
|
+
|
|
1569
|
+
add_mcp_server: async (args) => {
|
|
1570
|
+
if (!args.name) return { error: '需要服务名称' };
|
|
1571
|
+
if (!args.command && !args.url) return { error: '需要 command(本地)或 url(远程)' };
|
|
1572
|
+
const existing = configStore.getMcpServer(args.name);
|
|
1573
|
+
if (existing) return { error: '服务名已存在' };
|
|
1574
|
+
const serverConfig = {};
|
|
1575
|
+
if (args.url) {
|
|
1576
|
+
serverConfig.url = args.url;
|
|
1577
|
+
if (args.headers) serverConfig.headers = args.headers;
|
|
1578
|
+
} else {
|
|
1579
|
+
serverConfig.command = args.command;
|
|
1580
|
+
if (args.args) serverConfig.args = Array.isArray(args.args) ? args.args : String(args.args).split(/\s+/).filter(Boolean);
|
|
1581
|
+
if (args.env && Object.keys(args.env).length) serverConfig.env = args.env;
|
|
1582
|
+
}
|
|
1583
|
+
serverConfig.enabled = args.enabled !== false;
|
|
1584
|
+
configStore.addMcpServer(args.name, serverConfig);
|
|
1585
|
+
if (serverConfig.enabled) {
|
|
1586
|
+
mcpClient.connectServer(args.name, serverConfig).catch(err => {
|
|
1587
|
+
logger.error(`[MCP] 后台连接 ${args.name} 失败: ${err.message}`);
|
|
1588
|
+
});
|
|
1589
|
+
}
|
|
1590
|
+
return { success: true, name: args.name };
|
|
486
1591
|
},
|
|
487
1592
|
|
|
488
|
-
|
|
489
|
-
|
|
1593
|
+
update_mcp_server: async (args) => {
|
|
1594
|
+
const existing = configStore.getMcpServer(args.name);
|
|
1595
|
+
if (!existing) return { error: `MCP 服务 "${args.name}" 不存在` };
|
|
1596
|
+
const updates = {};
|
|
1597
|
+
if (args.url !== undefined) {
|
|
1598
|
+
updates.url = args.url;
|
|
1599
|
+
if (args.headers !== undefined) updates.headers = args.headers;
|
|
1600
|
+
delete updates.command;
|
|
1601
|
+
delete updates.args;
|
|
1602
|
+
delete updates.env;
|
|
1603
|
+
}
|
|
1604
|
+
if (args.command !== undefined) {
|
|
1605
|
+
updates.command = args.command;
|
|
1606
|
+
if (args.args !== undefined) updates.args = Array.isArray(args.args) ? args.args : String(args.args).split(/\s+/).filter(Boolean);
|
|
1607
|
+
if (args.env !== undefined) updates.env = args.env;
|
|
1608
|
+
delete updates.url;
|
|
1609
|
+
delete updates.headers;
|
|
1610
|
+
}
|
|
1611
|
+
if (args.enabled !== undefined) updates.enabled = args.enabled;
|
|
1612
|
+
configStore.updateMcpServer(args.name, updates);
|
|
1613
|
+
const newConfig = configStore.getMcpServer(args.name);
|
|
1614
|
+
if (newConfig.enabled) {
|
|
1615
|
+
await mcpClient.reconnectIfChanged(args.name, newConfig).catch(() => {});
|
|
1616
|
+
} else {
|
|
1617
|
+
await mcpClient.disconnectServer(args.name);
|
|
1618
|
+
}
|
|
1619
|
+
return { success: true };
|
|
490
1620
|
},
|
|
491
1621
|
|
|
492
|
-
|
|
493
|
-
const
|
|
494
|
-
return {
|
|
1622
|
+
delete_mcp_server: async (args) => {
|
|
1623
|
+
const existing = configStore.getMcpServer(args.name);
|
|
1624
|
+
if (!existing) return { error: `MCP 服务 "${args.name}" 不存在` };
|
|
1625
|
+
await mcpClient.disconnectServer(args.name);
|
|
1626
|
+
configStore.removeMcpServer(args.name);
|
|
1627
|
+
return { success: true };
|
|
495
1628
|
},
|
|
496
1629
|
|
|
497
|
-
|
|
498
|
-
const
|
|
1630
|
+
connect_mcp_server: async (args) => {
|
|
1631
|
+
const config = configStore.getMcpServer(args.name);
|
|
1632
|
+
if (!config) return { error: `MCP 服务 "${args.name}" 不存在` };
|
|
499
1633
|
try {
|
|
500
|
-
|
|
501
|
-
const
|
|
502
|
-
return {
|
|
503
|
-
} catch {
|
|
504
|
-
return {
|
|
1634
|
+
await mcpClient.connectServer(args.name, config);
|
|
1635
|
+
const status = mcpClient.getStatus().find(s => s.name === args.name);
|
|
1636
|
+
return status || { status: 'error', lastError: '连接失败' };
|
|
1637
|
+
} catch (err) {
|
|
1638
|
+
return { error: err.message };
|
|
505
1639
|
}
|
|
506
1640
|
},
|
|
507
1641
|
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
result[providerId] = health;
|
|
512
|
-
}
|
|
513
|
-
return result;
|
|
1642
|
+
disconnect_mcp_server: async (args) => {
|
|
1643
|
+
await mcpClient.disconnectServer(args.name);
|
|
1644
|
+
return { success: true };
|
|
514
1645
|
},
|
|
515
1646
|
|
|
516
|
-
|
|
517
|
-
|
|
1647
|
+
get_mcp_tools: async () => {
|
|
1648
|
+
const status = mcpClient.getStatus();
|
|
1649
|
+
return status.filter(s => s.status === 'connected').flatMap(s =>
|
|
1650
|
+
s.tools.map(t => ({ ...t, server: s.name, transport: s.transport }))
|
|
1651
|
+
);
|
|
518
1652
|
},
|
|
519
1653
|
|
|
520
|
-
|
|
521
|
-
|
|
1654
|
+
// --- 技能管理 ---
|
|
1655
|
+
get_skills: async () => {
|
|
1656
|
+
return { skills: skillStore.list() };
|
|
522
1657
|
},
|
|
523
1658
|
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
const offset = Math.max(0, parseInt(args.offset) || 0);
|
|
530
|
-
const limit = Math.min(Math.max(1, parseInt(args.limit) || 500), 2000);
|
|
531
|
-
const sliced = lines.slice(offset, offset + limit);
|
|
532
|
-
return { content: sliced.join('\n'), totalLines: lines.length, offset, returnedLines: sliced.length };
|
|
533
|
-
} catch (err) {
|
|
534
|
-
return { error: err.message };
|
|
535
|
-
}
|
|
1659
|
+
create_skill: async (args) => {
|
|
1660
|
+
if (!args.name || !args.content) return { error: '需要 name 和 content' };
|
|
1661
|
+
const skill = skillStore.create(args.name, args.description || '', args.content);
|
|
1662
|
+
if (!skill) return { error: '技能已存在' };
|
|
1663
|
+
return { success: true, name: skill.name };
|
|
536
1664
|
},
|
|
537
1665
|
|
|
538
|
-
|
|
539
|
-
const
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
await fs.promises.writeFile(filePath, args.content, 'utf8');
|
|
543
|
-
return { success: true, path: filePath, bytes: Buffer.byteLength(args.content, 'utf8') };
|
|
544
|
-
} catch (err) {
|
|
545
|
-
return { error: err.message };
|
|
546
|
-
}
|
|
1666
|
+
update_skill: async (args) => {
|
|
1667
|
+
const skill = skillStore.update(args.name, args.description || '', args.content || '');
|
|
1668
|
+
if (!skill) return { error: `技能 "${args.name}" 不存在或不可编辑` };
|
|
1669
|
+
return { success: true, name: skill.name };
|
|
547
1670
|
},
|
|
548
1671
|
|
|
549
|
-
|
|
550
|
-
const
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
1672
|
+
delete_skill: async (args) => {
|
|
1673
|
+
const skill = skillStore.get(args.name);
|
|
1674
|
+
if (!skill) return { error: `技能 "${args.name}" 不存在` };
|
|
1675
|
+
if (skill.category === 'system') return { error: '系统级技能不可删除' };
|
|
1676
|
+
if (!skillStore.remove(args.name)) return { error: '删除失败' };
|
|
1677
|
+
return { success: true };
|
|
1678
|
+
},
|
|
1679
|
+
|
|
1680
|
+
// --- 配置管理 ---
|
|
1681
|
+
export_config: async () => {
|
|
1682
|
+
const providers = configStore.getProviders();
|
|
1683
|
+
const proxies = configStore.getProxies().map(p => {
|
|
1684
|
+
const provider = configStore.getProviderById(p.providerId);
|
|
1685
|
+
return { id: p.id, name: p.name, port: p.port, providerId: p.providerId, defaultModel: p.defaultModel || '', routingStrategy: p.routingStrategy || 'primary_fallback', providerName: provider?.name || '' };
|
|
1686
|
+
});
|
|
1687
|
+
return { providers, proxies, exportedAt: new Date().toISOString() };
|
|
1688
|
+
},
|
|
1689
|
+
|
|
1690
|
+
import_config: async (args) => {
|
|
1691
|
+
if (!args.config || !args.mode || !['overwrite', 'merge'].includes(args.mode)) {
|
|
1692
|
+
return { error: '需要 config 和 mode(overwrite/merge)' };
|
|
1693
|
+
}
|
|
1694
|
+
if (!Array.isArray(args.config.providers) || !Array.isArray(args.config.proxies)) {
|
|
1695
|
+
return { error: '配置格式错误:需要 providers 和 proxies 数组' };
|
|
1696
|
+
}
|
|
1697
|
+
configStore.saveSnapshot('import-' + args.mode);
|
|
1698
|
+
if (args.mode === 'overwrite') {
|
|
1699
|
+
const newConfig = {
|
|
1700
|
+
providers: args.config.providers.map(p => ({
|
|
1701
|
+
id: p.id, name: p.name, url: p.url, protocol: p.protocol,
|
|
1702
|
+
apiKey: p.apiKey || '', models: Array.isArray(p.models) ? p.models : [],
|
|
1703
|
+
})),
|
|
1704
|
+
proxies: args.config.proxies.map(p => ({
|
|
1705
|
+
id: p.id, name: p.name, port: p.port, providerId: p.providerId,
|
|
1706
|
+
defaultModel: p.defaultModel || '', routingStrategy: normalizeRoutingStrategyInput(p.routingStrategy),
|
|
1707
|
+
providerPool: normalizeProviderPoolInput(p.providerPool),
|
|
558
1708
|
})),
|
|
559
1709
|
};
|
|
560
|
-
|
|
561
|
-
return {
|
|
1710
|
+
configStore.saveConfig(newConfig);
|
|
1711
|
+
return { success: true, mode: 'overwrite', providers: newConfig.providers.length, proxies: newConfig.proxies.length };
|
|
1712
|
+
}
|
|
1713
|
+
// merge 模式
|
|
1714
|
+
const existingProviders = configStore.getProviders();
|
|
1715
|
+
const existingProxies = configStore.getProxies();
|
|
1716
|
+
const providerMap = new Map(existingProviders.map(p => [p.id, p]));
|
|
1717
|
+
for (const p of args.config.providers) {
|
|
1718
|
+
providerMap.set(p.id, { id: p.id, name: p.name, url: p.url, protocol: p.protocol, apiKey: p.apiKey || '', models: Array.isArray(p.models) ? p.models : [] });
|
|
562
1719
|
}
|
|
1720
|
+
const proxyMap = new Map(existingProxies.map(p => [p.id, p]));
|
|
1721
|
+
for (const p of args.config.proxies) {
|
|
1722
|
+
const conflict = proxyMap.get(p.id) ? null : Array.from(proxyMap.values()).find(ep => ep.port === p.port);
|
|
1723
|
+
if (conflict) return { error: `端口 ${p.port} 已被代理「${conflict.name}」占用` };
|
|
1724
|
+
proxyMap.set(p.id, { id: p.id, name: p.name, port: p.port, providerId: p.providerId, defaultModel: p.defaultModel || '', routingStrategy: normalizeRoutingStrategyInput(p.routingStrategy), providerPool: normalizeProviderPoolInput(p.providerPool) });
|
|
1725
|
+
}
|
|
1726
|
+
const merged = { providers: Array.from(providerMap.values()), proxies: Array.from(proxyMap.values()) };
|
|
1727
|
+
configStore.saveConfig(merged);
|
|
1728
|
+
return { success: true, mode: 'merge', providers: merged.providers.length, proxies: merged.proxies.length };
|
|
563
1729
|
},
|
|
564
1730
|
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
const
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
const regex = globToRegex(pattern);
|
|
578
|
-
const walk = async (dir, rel) => {
|
|
579
|
-
const entries = await fs.promises.readdir(dir, { withFileTypes: true });
|
|
580
|
-
for (const e of entries) {
|
|
581
|
-
const fullPath = path.join(dir, e.name);
|
|
582
|
-
const relPath = rel ? `${rel}/${e.name}` : e.name;
|
|
583
|
-
if (e.isDirectory()) {
|
|
584
|
-
if (e.name === 'node_modules' || e.name === '.git') continue;
|
|
585
|
-
await walk(fullPath, relPath);
|
|
586
|
-
} else if (regex.test(relPath)) {
|
|
587
|
-
results.push(relPath);
|
|
588
|
-
}
|
|
589
|
-
}
|
|
590
|
-
};
|
|
591
|
-
await walk(root, '');
|
|
592
|
-
return { pattern, root, matches: results.slice(0, 200), total: results.length };
|
|
593
|
-
} catch (err) {
|
|
594
|
-
return { error: err.message };
|
|
1731
|
+
rollback_config: async (args) => {
|
|
1732
|
+
if (!args.file) return { error: '需要指定快照文件' };
|
|
1733
|
+
const result = configStore.restoreSnapshot(args.file);
|
|
1734
|
+
if (result.error) return { error: result.error };
|
|
1735
|
+
return { success: true };
|
|
1736
|
+
},
|
|
1737
|
+
|
|
1738
|
+
// --- 系统操作 ---
|
|
1739
|
+
update_settings: async (args) => {
|
|
1740
|
+
if (!args.settings || typeof args.settings !== 'object') return { error: '需要 settings 对象' };
|
|
1741
|
+
for (const [key, value] of Object.entries(args.settings)) {
|
|
1742
|
+
configStore.setSetting(key, value);
|
|
595
1743
|
}
|
|
1744
|
+
return { success: true, settings: configStore.getSettings() };
|
|
596
1745
|
},
|
|
597
1746
|
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
return
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
1747
|
+
trigger_key_health_check: async () => {
|
|
1748
|
+
await checkAllProviderKeys();
|
|
1749
|
+
return { success: true };
|
|
1750
|
+
},
|
|
1751
|
+
|
|
1752
|
+
check_health: async () => {
|
|
1753
|
+
return {
|
|
1754
|
+
status: 'ok',
|
|
1755
|
+
version: require('./package.json').version,
|
|
1756
|
+
uptime: Math.floor(process.uptime()),
|
|
1757
|
+
proxies: {
|
|
1758
|
+
total: configStore.getProxies().length,
|
|
1759
|
+
running: proxyManager.getRunningPorts().length,
|
|
1760
|
+
},
|
|
1761
|
+
};
|
|
609
1762
|
},
|
|
610
1763
|
};
|
|
611
1764
|
|
|
@@ -1392,48 +2545,393 @@ async function init() {
|
|
|
1392
2545
|
|
|
1393
2546
|
// ==================== 智控助手 Tool Calling API ====================
|
|
1394
2547
|
|
|
2548
|
+
const conversationStore = require('./lib/conversation-store');
|
|
2549
|
+
conversationStore.init();
|
|
2550
|
+
|
|
2551
|
+
const skillStore = require('./lib/skill-store');
|
|
2552
|
+
skillStore.init();
|
|
2553
|
+
|
|
2554
|
+
const promptBuilder = require('./lib/prompt-builder');
|
|
2555
|
+
|
|
2556
|
+
// 会话并发锁:convId → true 表示正在 streaming
|
|
2557
|
+
const activeStreams = new Set();
|
|
2558
|
+
|
|
1395
2559
|
function sendSSE(res, event, data) {
|
|
1396
2560
|
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
1397
2561
|
}
|
|
1398
2562
|
|
|
2563
|
+
// 会话管理 API
|
|
2564
|
+
app.get('/api/assistant/conversations', (req, res) => {
|
|
2565
|
+
res.json({ conversations: conversationStore.list() });
|
|
2566
|
+
});
|
|
2567
|
+
|
|
2568
|
+
app.delete('/api/assistant/conversations/:id', (req, res) => {
|
|
2569
|
+
const conv = conversationStore.get(req.params.id);
|
|
2570
|
+
if (!conv) return res.status(404).json({ error: '会话不存在' });
|
|
2571
|
+
conversationStore.remove(req.params.id);
|
|
2572
|
+
res.json({ success: true });
|
|
2573
|
+
});
|
|
2574
|
+
|
|
2575
|
+
// 获取单个会话的消息历史(用于恢复会话显示)
|
|
2576
|
+
app.get('/api/assistant/conversations/:id/messages', (req, res) => {
|
|
2577
|
+
const conv = conversationStore.get(req.params.id);
|
|
2578
|
+
if (!conv) return res.status(404).json({ error: '会话不存在' });
|
|
2579
|
+
// 返回消息历史(过滤掉 system 消息,前端不需要显示)
|
|
2580
|
+
const messages = (conv.messages || []).filter(m => m.role !== 'system');
|
|
2581
|
+
const compressionSummary = conv.compressionSummary || null;
|
|
2582
|
+
res.json({ id: conv.id, proxyId: conv.proxyId, messages, compressionSummary });
|
|
2583
|
+
});
|
|
2584
|
+
|
|
2585
|
+
// 获取代理的候选供应商及其模型列表(供前端级联选择)
|
|
2586
|
+
app.get('/api/assistant/proxy-providers/:proxyId', (req, res) => {
|
|
2587
|
+
const proxy = configStore.getProxyById(req.params.proxyId);
|
|
2588
|
+
if (!proxy) return res.status(404).json({ error: '代理不存在' });
|
|
2589
|
+
const providers = configStore.getProviders().map(p => ({
|
|
2590
|
+
id: p.id,
|
|
2591
|
+
name: p.name,
|
|
2592
|
+
protocol: p.protocol,
|
|
2593
|
+
models: p.models || [],
|
|
2594
|
+
}));
|
|
2595
|
+
res.json({ providers, defaultModel: proxy.defaultModel || '' });
|
|
2596
|
+
});
|
|
2597
|
+
|
|
2598
|
+
// ========== Skill API ==========
|
|
2599
|
+
app.get('/api/skills', (req, res) => {
|
|
2600
|
+
res.json({ skills: skillStore.list() });
|
|
2601
|
+
});
|
|
2602
|
+
|
|
2603
|
+
app.get('/api/skills/:name', (req, res) => {
|
|
2604
|
+
const skill = skillStore.get(req.params.name);
|
|
2605
|
+
if (!skill) return res.status(404).json({ error: '技能不存在' });
|
|
2606
|
+
res.json(skill);
|
|
2607
|
+
});
|
|
2608
|
+
|
|
2609
|
+
app.post('/api/skills', (req, res) => {
|
|
2610
|
+
const { name, description, content } = req.body;
|
|
2611
|
+
if (!name || !content) return res.status(400).json({ error: '需要 name 和 content' });
|
|
2612
|
+
const skill = skillStore.create(name, description || '', content);
|
|
2613
|
+
if (!skill) return res.status(409).json({ error: '技能已存在' });
|
|
2614
|
+
res.json(skill);
|
|
2615
|
+
});
|
|
2616
|
+
|
|
2617
|
+
// 上传技能文件夹创建技能
|
|
2618
|
+
app.post('/api/skills/upload', (req, res) => {
|
|
2619
|
+
const { files } = req.body;
|
|
2620
|
+
if (!files || !files.length) return res.status(400).json({ error: '需要文件列表' });
|
|
2621
|
+
const skillMd = files.find(f => f.path === 'SKILL.md');
|
|
2622
|
+
if (!skillMd) return res.status(400).json({ error: '缺少 SKILL.md' });
|
|
2623
|
+
const MAX_BASE64 = 1024 * 1024;
|
|
2624
|
+
for (const f of files) {
|
|
2625
|
+
if (f.content.length > MAX_BASE64) return res.status(413).json({ error: `文件 ${f.path} 过大` });
|
|
2626
|
+
}
|
|
2627
|
+
const skill = skillStore.createFromUpload(files);
|
|
2628
|
+
if (!skill) return res.status(400).json({ error: 'SKILL.md 缺少 name 字段或技能已存在' });
|
|
2629
|
+
res.json(skill);
|
|
2630
|
+
});
|
|
2631
|
+
|
|
2632
|
+
app.put('/api/skills/:name', (req, res) => {
|
|
2633
|
+
const { description, content } = req.body;
|
|
2634
|
+
const skill = skillStore.update(req.params.name, description || '', content || '');
|
|
2635
|
+
if (!skill) return res.status(404).json({ error: '技能不存在或不可编辑' });
|
|
2636
|
+
res.json(skill);
|
|
2637
|
+
});
|
|
2638
|
+
|
|
2639
|
+
// 上传 skill 附属文件(scripts/reference)
|
|
2640
|
+
app.post('/api/skills/:name/upload', (req, res) => {
|
|
2641
|
+
const skill = skillStore.get(req.params.name);
|
|
2642
|
+
if (!skill) return res.status(404).json({ error: '技能不存在' });
|
|
2643
|
+
if (skill.category === 'system') return res.status(403).json({ error: '系统级技能不可修改' });
|
|
2644
|
+
const { filename, subDir, content } = req.body; // content: base64
|
|
2645
|
+
if (!filename || !content) return res.status(400).json({ error: '需要 filename 和 content' });
|
|
2646
|
+
const MAX_BASE64 = 1024 * 1024; // ~768KB decoded
|
|
2647
|
+
if (content.length > MAX_BASE64) return res.status(413).json({ error: '文件过大,最大 768KB' });
|
|
2648
|
+
const dir = subDir === 'reference' ? 'reference' : 'scripts';
|
|
2649
|
+
const targetDir = path.join(skill.dirPath, dir);
|
|
2650
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
2651
|
+
const safeName = path.basename(filename).replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
2652
|
+
fs.writeFileSync(path.join(targetDir, safeName), Buffer.from(content, 'base64'));
|
|
2653
|
+
skillStore.init(); // 重新加载
|
|
2654
|
+
res.json({ success: true, path: `${dir}/${safeName}` });
|
|
2655
|
+
});
|
|
2656
|
+
|
|
2657
|
+
// 删除 skill 附属文件
|
|
2658
|
+
app.delete('/api/skills/:name/file', (req, res) => {
|
|
2659
|
+
const skill = skillStore.get(req.params.name);
|
|
2660
|
+
if (!skill) return res.status(404).json({ error: '技能不存在' });
|
|
2661
|
+
if (skill.category === 'system') return res.status(403).json({ error: '系统级技能不可修改' });
|
|
2662
|
+
const { filePath } = req.body;
|
|
2663
|
+
if (!filePath) return res.status(400).json({ error: '需要 filePath' });
|
|
2664
|
+
const fullPath = path.join(skill.dirPath, filePath);
|
|
2665
|
+
if (!fullPath.startsWith(skill.dirPath)) return res.status(400).json({ error: '无效路径' });
|
|
2666
|
+
try { fs.unlinkSync(fullPath); } catch {}
|
|
2667
|
+
skillStore.init();
|
|
2668
|
+
res.json({ success: true });
|
|
2669
|
+
});
|
|
2670
|
+
|
|
2671
|
+
app.delete('/api/skills/:name', (req, res) => {
|
|
2672
|
+
const skill = skillStore.get(req.params.name);
|
|
2673
|
+
if (!skill) return res.status(404).json({ error: '技能不存在' });
|
|
2674
|
+
if (skill.category === 'system') return res.status(403).json({ error: '系统级技能不可删除' });
|
|
2675
|
+
if (!skillStore.remove(req.params.name)) {
|
|
2676
|
+
return res.status(500).json({ error: '删除失败,请检查文件权限' });
|
|
2677
|
+
}
|
|
2678
|
+
res.json({ success: true });
|
|
2679
|
+
});
|
|
2680
|
+
|
|
2681
|
+
// ==================== MCP 服务管理 API ====================
|
|
2682
|
+
|
|
2683
|
+
app.get('/api/mcp/servers', (req, res) => {
|
|
2684
|
+
const servers = configStore.getMcpServers();
|
|
2685
|
+
const status = mcpClient.getStatus();
|
|
2686
|
+
const statusMap = Object.fromEntries(status.map(s => [s.name, s]));
|
|
2687
|
+
const result = Object.entries(servers).map(([name, config]) => ({
|
|
2688
|
+
name,
|
|
2689
|
+
enabled: config.enabled !== false,
|
|
2690
|
+
transport: config.url ? 'http' : 'stdio',
|
|
2691
|
+
command: config.command,
|
|
2692
|
+
url: config.url,
|
|
2693
|
+
...(statusMap[name] || { status: 'disconnected', tools: [], lastError: null }),
|
|
2694
|
+
}));
|
|
2695
|
+
res.json(result);
|
|
2696
|
+
});
|
|
2697
|
+
|
|
2698
|
+
app.get('/api/mcp/servers/:name', (req, res) => {
|
|
2699
|
+
const config = configStore.getMcpServer(req.params.name);
|
|
2700
|
+
if (!config) return res.status(404).json({ error: 'MCP 服务不存在' });
|
|
2701
|
+
const status = mcpClient.getStatus().find(s => s.name === req.params.name);
|
|
2702
|
+
res.json({ name: req.params.name, config, ...(status || { status: 'disconnected', tools: [] }) });
|
|
2703
|
+
});
|
|
2704
|
+
|
|
2705
|
+
app.post('/api/mcp/servers', async (req, res) => {
|
|
2706
|
+
const { name, command, args, env, url, headers, enabled } = req.body;
|
|
2707
|
+
if (!name) return res.status(400).json({ error: '需要服务名称' });
|
|
2708
|
+
if (!command && !url) return res.status(400).json({ error: '需要 command(本地)或 url(远程)' });
|
|
2709
|
+
const existing = configStore.getMcpServer(name);
|
|
2710
|
+
if (existing) return res.status(409).json({ error: '服务名已存在' });
|
|
2711
|
+
const serverConfig = {};
|
|
2712
|
+
if (url) {
|
|
2713
|
+
serverConfig.url = url;
|
|
2714
|
+
if (headers) serverConfig.headers = headers;
|
|
2715
|
+
} else {
|
|
2716
|
+
serverConfig.command = command;
|
|
2717
|
+
if (args) serverConfig.args = Array.isArray(args) ? args : args.split(/\s+/).filter(Boolean);
|
|
2718
|
+
if (env && Object.keys(env).length) serverConfig.env = env;
|
|
2719
|
+
}
|
|
2720
|
+
serverConfig.enabled = enabled !== false;
|
|
2721
|
+
configStore.addMcpServer(name, serverConfig);
|
|
2722
|
+
if (serverConfig.enabled) {
|
|
2723
|
+
mcpClient.connectServer(name, serverConfig).catch(err => {
|
|
2724
|
+
logger.error(`[MCP] 后台连接 ${name} 失败: ${err.message}`);
|
|
2725
|
+
});
|
|
2726
|
+
}
|
|
2727
|
+
res.json({ success: true, name });
|
|
2728
|
+
});
|
|
2729
|
+
|
|
2730
|
+
app.put('/api/mcp/servers/:name', async (req, res) => {
|
|
2731
|
+
const { command, args, env, url, headers, enabled } = req.body;
|
|
2732
|
+
const existing = configStore.getMcpServer(req.params.name);
|
|
2733
|
+
if (!existing) return res.status(404).json({ error: 'MCP 服务不存在' });
|
|
2734
|
+
const updates = {};
|
|
2735
|
+
if (url !== undefined) {
|
|
2736
|
+
updates.url = url;
|
|
2737
|
+
if (headers !== undefined) updates.headers = headers;
|
|
2738
|
+
delete updates.command;
|
|
2739
|
+
delete updates.args;
|
|
2740
|
+
delete updates.env;
|
|
2741
|
+
}
|
|
2742
|
+
if (command !== undefined) {
|
|
2743
|
+
updates.command = command;
|
|
2744
|
+
if (args !== undefined) updates.args = Array.isArray(args) ? args : args.split(/\s+/).filter(Boolean);
|
|
2745
|
+
if (env !== undefined) updates.env = env;
|
|
2746
|
+
delete updates.url;
|
|
2747
|
+
delete updates.headers;
|
|
2748
|
+
}
|
|
2749
|
+
if (enabled !== undefined) updates.enabled = enabled;
|
|
2750
|
+
configStore.updateMcpServer(req.params.name, updates);
|
|
2751
|
+
const newConfig = configStore.getMcpServer(req.params.name);
|
|
2752
|
+
if (newConfig.enabled) {
|
|
2753
|
+
mcpClient.reconnectIfChanged(req.params.name, newConfig).catch(() => {});
|
|
2754
|
+
} else {
|
|
2755
|
+
await mcpClient.disconnectServer(req.params.name);
|
|
2756
|
+
}
|
|
2757
|
+
res.json({ success: true });
|
|
2758
|
+
});
|
|
2759
|
+
|
|
2760
|
+
app.delete('/api/mcp/servers/:name', async (req, res) => {
|
|
2761
|
+
const existing = configStore.getMcpServer(req.params.name);
|
|
2762
|
+
if (!existing) return res.status(404).json({ error: 'MCP 服务不存在' });
|
|
2763
|
+
await mcpClient.disconnectServer(req.params.name);
|
|
2764
|
+
configStore.removeMcpServer(req.params.name);
|
|
2765
|
+
res.json({ success: true });
|
|
2766
|
+
});
|
|
2767
|
+
|
|
2768
|
+
app.post('/api/mcp/servers/:name/connect', async (req, res) => {
|
|
2769
|
+
const config = configStore.getMcpServer(req.params.name);
|
|
2770
|
+
if (!config) return res.status(404).json({ error: 'MCP 服务不存在' });
|
|
2771
|
+
try {
|
|
2772
|
+
await mcpClient.connectServer(req.params.name, config);
|
|
2773
|
+
const status = mcpClient.getStatus().find(s => s.name === req.params.name);
|
|
2774
|
+
res.json(status || { status: 'error', lastError: '连接失败' });
|
|
2775
|
+
} catch (err) {
|
|
2776
|
+
res.status(500).json({ error: err.message });
|
|
2777
|
+
}
|
|
2778
|
+
});
|
|
2779
|
+
|
|
2780
|
+
app.post('/api/mcp/servers/:name/disconnect', async (req, res) => {
|
|
2781
|
+
await mcpClient.disconnectServer(req.params.name);
|
|
2782
|
+
res.json({ success: true });
|
|
2783
|
+
});
|
|
2784
|
+
|
|
2785
|
+
app.get('/api/mcp/tools', (req, res) => {
|
|
2786
|
+
const status = mcpClient.getStatus();
|
|
2787
|
+
const allTools = status.filter(s => s.status === 'connected').flatMap(s =>
|
|
2788
|
+
s.tools.map(t => ({ ...t, server: s.name, transport: s.transport }))
|
|
2789
|
+
);
|
|
2790
|
+
res.json(allTools);
|
|
2791
|
+
});
|
|
2792
|
+
|
|
2793
|
+
|
|
1399
2794
|
app.post('/api/assistant/chat', async (req, res) => {
|
|
1400
|
-
const { proxyId,
|
|
1401
|
-
if (!proxyId || !
|
|
1402
|
-
return res.status(400).json({ error: '需要 proxyId 和
|
|
2795
|
+
const { proxyId, conversationId, message, compress, providerId, model } = req.body;
|
|
2796
|
+
if (!proxyId || (!compress && !message)) {
|
|
2797
|
+
return res.status(400).json({ error: '需要 proxyId 和 message' });
|
|
1403
2798
|
}
|
|
1404
2799
|
|
|
1405
2800
|
const proxy = configStore.getProxyById(proxyId);
|
|
1406
2801
|
if (!proxy) return res.status(404).json({ error: '代理不存在' });
|
|
1407
2802
|
if (!resolveTarget(proxy)) return res.status(500).json({ error: '代理目标未配置' });
|
|
1408
2803
|
|
|
2804
|
+
// 查找或创建对话
|
|
2805
|
+
const settings = configStore.getSettings();
|
|
2806
|
+
let convId = conversationId;
|
|
2807
|
+
let conv;
|
|
2808
|
+
if (convId) {
|
|
2809
|
+
conv = conversationStore.get(convId);
|
|
2810
|
+
}
|
|
2811
|
+
if (!conv && compress) {
|
|
2812
|
+
return res.status(404).json({ error: '会话不存在,无法压缩' });
|
|
2813
|
+
}
|
|
2814
|
+
if (!conv) {
|
|
2815
|
+
const maxConvs = parseInt(settings.maxConversations) || 0;
|
|
2816
|
+
conv = conversationStore.create(proxyId, maxConvs);
|
|
2817
|
+
convId = conv.id;
|
|
2818
|
+
}
|
|
2819
|
+
|
|
2820
|
+
// 并发锁:同一会话正在 streaming 时拒绝新请求
|
|
2821
|
+
if (activeStreams.has(convId)) {
|
|
2822
|
+
return res.status(429).json({ error: '该会话正在处理中,请稍后再试' });
|
|
2823
|
+
}
|
|
2824
|
+
activeStreams.add(convId);
|
|
2825
|
+
conversationStore.touch(conv);
|
|
2826
|
+
|
|
2827
|
+
// 追加用户消息到对话历史(压缩请求不追加空消息)
|
|
2828
|
+
// 检测 /skillname 前缀触发技能
|
|
2829
|
+
let activeSkill = null;
|
|
2830
|
+
if (!compress && message) {
|
|
2831
|
+
const slashMatch = message.match(/^\/([a-zA-Z0-9_-]+)(?:\s+([\s\S]*))?$/);
|
|
2832
|
+
if (slashMatch) {
|
|
2833
|
+
const skillName = slashMatch[1];
|
|
2834
|
+
const skill = skillStore.get(skillName);
|
|
2835
|
+
if (skill) {
|
|
2836
|
+
activeSkill = skill;
|
|
2837
|
+
// 将用户消息中的参数部分也保留
|
|
2838
|
+
const args = slashMatch[2]?.trim();
|
|
2839
|
+
if (args) {
|
|
2840
|
+
conv.messages.push({ role: 'user', content: args });
|
|
2841
|
+
}
|
|
2842
|
+
} else {
|
|
2843
|
+
conv.messages.push({ role: 'user', content: message });
|
|
2844
|
+
}
|
|
2845
|
+
} else {
|
|
2846
|
+
conv.messages.push({ role: 'user', content: message });
|
|
2847
|
+
}
|
|
2848
|
+
conversationStore.touch(conv);
|
|
2849
|
+
}
|
|
2850
|
+
|
|
1409
2851
|
const proxyUrl = `http://localhost:${proxy.port}/v1/chat/completions`;
|
|
1410
2852
|
const proxyHeaders = { 'Content-Type': 'application/json' };
|
|
1411
2853
|
if (proxy.requireAuth && proxy.authToken) {
|
|
1412
2854
|
proxyHeaders['Authorization'] = `Bearer ${proxy.authToken}`;
|
|
1413
2855
|
}
|
|
2856
|
+
if (providerId) proxyHeaders['x-pp-provider-id'] = providerId;
|
|
2857
|
+
if (model) proxyHeaders['x-pp-model'] = model;
|
|
2858
|
+
// 若供应商不在代理候选池中,传递完整供应商配置供代理动态构建临时候选
|
|
2859
|
+
if (providerId) {
|
|
2860
|
+
const target = resolveTarget(proxy);
|
|
2861
|
+
const inPool = target?.providerPool?.some(c => c.providerId === providerId);
|
|
2862
|
+
if (!inPool) {
|
|
2863
|
+
const provider = configStore.getProviderById(providerId);
|
|
2864
|
+
if (provider) {
|
|
2865
|
+
proxyHeaders['x-pp-provider-url'] = provider.url;
|
|
2866
|
+
proxyHeaders['x-pp-provider-protocol'] = provider.protocol;
|
|
2867
|
+
const enabledKeys = (provider.apiKeys || []).filter(k => k.enabled !== false).map(k => k.key);
|
|
2868
|
+
if (enabledKeys.length > 0) proxyHeaders['x-pp-provider-keys'] = JSON.stringify(enabledKeys);
|
|
2869
|
+
}
|
|
2870
|
+
}
|
|
2871
|
+
}
|
|
1414
2872
|
|
|
1415
2873
|
// SSE 响应头
|
|
1416
2874
|
res.setHeader('Content-Type', 'text/event-stream');
|
|
1417
2875
|
res.setHeader('Cache-Control', 'no-cache');
|
|
1418
2876
|
res.setHeader('Connection', 'keep-alive');
|
|
1419
2877
|
|
|
1420
|
-
// 发送 SSE 的辅助函数,忽略写入错误
|
|
1421
2878
|
function safeSSE(event, data) {
|
|
1422
2879
|
try { sendSSE(res, event, data); } catch {}
|
|
1423
2880
|
}
|
|
2881
|
+
const MAX_CONTEXT = Math.max(10000, parseInt(settings.maxContext) || 200000);
|
|
2882
|
+
const MAX_TOOL_ROUNDS = Math.max(1, Math.min(100, parseInt(settings.maxRounds) || 10));
|
|
2883
|
+
|
|
2884
|
+
// 手动压缩请求
|
|
2885
|
+
if (compress) {
|
|
2886
|
+
logger.log(`[assistant] 压缩请求 — ${conv.messages.length} messages`);
|
|
2887
|
+
safeSSE('compressing', {});
|
|
2888
|
+
const result = await compressConversation(conv, MAX_CONTEXT, proxyUrl, proxyHeaders, proxy.defaultModel);
|
|
2889
|
+
if (result) {
|
|
2890
|
+
conv.messages = result.messages;
|
|
2891
|
+
conv.compressionSummary = result.summary;
|
|
2892
|
+
conversationStore.touch(conv);
|
|
2893
|
+
safeSSE('compressed', { summary: result.summary, removedCount: result.removedCount, tokens: result.newTokens, maxTokens: MAX_CONTEXT, messages: conv.messages.length });
|
|
2894
|
+
logger.log(`[assistant] 压缩完成 — 移除 ${result.removedCount} 条`);
|
|
2895
|
+
} else {
|
|
2896
|
+
safeSSE('compressed', { summary: null, removedCount: 0, tokens: estimateConversationTokens(conv.messages), maxTokens: MAX_CONTEXT, messages: conv.messages.length });
|
|
2897
|
+
}
|
|
2898
|
+
safeSSE('done', {});
|
|
2899
|
+
res.end();
|
|
2900
|
+
return;
|
|
2901
|
+
}
|
|
1424
2902
|
|
|
1425
|
-
|
|
1426
|
-
|
|
2903
|
+
// 发送 conversationId 给前端
|
|
2904
|
+
safeSSE('conversation', { id: convId });
|
|
1427
2905
|
|
|
1428
2906
|
try {
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
2907
|
+
// 请求级别缓存 system prompt(避免每轮重建导致 prompt cache 失效)
|
|
2908
|
+
const systemPrompt = promptBuilder.buildSystemPrompt({ skillStore, mcpClient });
|
|
2909
|
+
const buildMessages = () => {
|
|
2910
|
+
const msgs = [{ role: 'system', content: systemPrompt }];
|
|
2911
|
+
if (activeSkill) {
|
|
2912
|
+
let skillInfo = `[技能指令: ${activeSkill.name}]\n${activeSkill.content}`;
|
|
2913
|
+
if (activeSkill.dirPath) skillInfo += `\n\n技能目录: ${activeSkill.dirPath}`;
|
|
2914
|
+
if (activeSkill.scripts?.length > 0) skillInfo += `\n可用脚本: ${activeSkill.scripts.map(f => 'scripts/' + f).join(', ')}`;
|
|
2915
|
+
msgs.push({ role: 'system', content: skillInfo });
|
|
2916
|
+
}
|
|
2917
|
+
if (conv.compressionSummary) {
|
|
2918
|
+
msgs.push({ role: 'system', content: `[压缩摘要]\n${conv.compressionSummary}\n\n---\n以上是之前对话的压缩摘要。最近的消息保留原文。请继续对话,不要复述摘要内容。` });
|
|
1434
2919
|
}
|
|
2920
|
+
msgs.push(...conv.messages);
|
|
2921
|
+
return msgs;
|
|
2922
|
+
};
|
|
2923
|
+
|
|
2924
|
+
let currentTokens = estimateConversationTokens(buildMessages());
|
|
2925
|
+
const sendContext = () => {
|
|
2926
|
+
const pct = Math.round(currentTokens / MAX_CONTEXT * 1000) / 10;
|
|
2927
|
+
safeSSE('context', { tokens: currentTokens, maxTokens: MAX_CONTEXT, percent: pct, messages: conv.messages.length });
|
|
2928
|
+
};
|
|
2929
|
+
sendContext();
|
|
2930
|
+
|
|
2931
|
+
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
|
|
2932
|
+
const messages = buildMessages();
|
|
2933
|
+
logger.log(`[assistant] round ${round} — ${messages.length} messages, ~${currentTokens} tokens`);
|
|
1435
2934
|
|
|
1436
|
-
// 调用本地代理
|
|
1437
2935
|
let fetchRes;
|
|
1438
2936
|
try {
|
|
1439
2937
|
fetchRes = await fetch(proxyUrl, {
|
|
@@ -1442,9 +2940,9 @@ async function init() {
|
|
|
1442
2940
|
signal: AbortSignal.timeout(300000),
|
|
1443
2941
|
body: JSON.stringify({
|
|
1444
2942
|
model: proxy.defaultModel || 'gpt-4o',
|
|
1445
|
-
messages
|
|
2943
|
+
messages,
|
|
1446
2944
|
stream: true,
|
|
1447
|
-
tools: TOOL_DEFINITIONS,
|
|
2945
|
+
tools: [...TOOL_DEFINITIONS, ...mcpClient.getToolDefinitions()],
|
|
1448
2946
|
tool_choice: 'auto',
|
|
1449
2947
|
}),
|
|
1450
2948
|
});
|
|
@@ -1472,37 +2970,24 @@ async function init() {
|
|
|
1472
2970
|
while (true) {
|
|
1473
2971
|
const { done, value } = await reader.read();
|
|
1474
2972
|
if (done) break;
|
|
1475
|
-
|
|
1476
2973
|
buffer += decoder.decode(value, { stream: true });
|
|
1477
2974
|
const lines = buffer.split('\n');
|
|
1478
2975
|
buffer = lines.pop();
|
|
1479
|
-
|
|
1480
2976
|
for (const line of lines) {
|
|
1481
2977
|
const trimmed = line.trim();
|
|
1482
2978
|
if (!trimmed || !trimmed.startsWith('data: ')) continue;
|
|
1483
2979
|
const payload = trimmed.slice(6);
|
|
1484
2980
|
if (payload === '[DONE]') continue;
|
|
1485
|
-
|
|
1486
2981
|
try {
|
|
1487
2982
|
const data = JSON.parse(payload);
|
|
1488
2983
|
const delta = data.choices?.[0]?.delta;
|
|
1489
2984
|
if (!delta) continue;
|
|
1490
|
-
|
|
1491
|
-
if (delta.
|
|
1492
|
-
fullContent += delta.content;
|
|
1493
|
-
safeSSE('content', { delta: delta.content });
|
|
1494
|
-
}
|
|
1495
|
-
|
|
1496
|
-
if (delta.reasoning_content) {
|
|
1497
|
-
reasoningContent += delta.reasoning_content;
|
|
1498
|
-
}
|
|
1499
|
-
|
|
2985
|
+
if (delta.content) { fullContent += delta.content; safeSSE('content', { delta: delta.content }); }
|
|
2986
|
+
if (delta.reasoning_content) reasoningContent += delta.reasoning_content;
|
|
1500
2987
|
if (delta.tool_calls) {
|
|
1501
2988
|
for (const tc of delta.tool_calls) {
|
|
1502
2989
|
const idx = tc.index;
|
|
1503
|
-
if (!toolCallAccumulator[idx]) {
|
|
1504
|
-
toolCallAccumulator[idx] = { id: '', name: '', arguments: '' };
|
|
1505
|
-
}
|
|
2990
|
+
if (!toolCallAccumulator[idx]) toolCallAccumulator[idx] = { id: '', name: '', arguments: '' };
|
|
1506
2991
|
if (tc.id) toolCallAccumulator[idx].id = tc.id;
|
|
1507
2992
|
if (tc.function?.name) toolCallAccumulator[idx].name = tc.function.name;
|
|
1508
2993
|
if (tc.function?.arguments) toolCallAccumulator[idx].arguments += tc.function.arguments;
|
|
@@ -1513,9 +2998,17 @@ async function init() {
|
|
|
1513
2998
|
}
|
|
1514
2999
|
|
|
1515
3000
|
const toolCalls = Object.values(toolCallAccumulator).filter(tc => tc.id && tc.name);
|
|
1516
|
-
logger.log(`[assistant] round ${round} done —
|
|
3001
|
+
logger.log(`[assistant] round ${round} done — ${fullContent.length} chars, ${toolCalls.length} tool calls`);
|
|
1517
3002
|
|
|
1518
3003
|
if (toolCalls.length === 0) {
|
|
3004
|
+
// 最终回复,追加到对话历史(跳过空响应避免 null content 污染历史)
|
|
3005
|
+
if (fullContent || reasoningContent) {
|
|
3006
|
+
const assistantMsg = { role: 'assistant', content: fullContent || null };
|
|
3007
|
+
if (reasoningContent) assistantMsg.reasoning_content = reasoningContent;
|
|
3008
|
+
conv.messages.push(assistantMsg);
|
|
3009
|
+
}
|
|
3010
|
+
currentTokens = estimateConversationTokens(buildMessages());
|
|
3011
|
+
sendContext();
|
|
1519
3012
|
safeSSE('done', { reasoning_content: reasoningContent || undefined });
|
|
1520
3013
|
break;
|
|
1521
3014
|
}
|
|
@@ -1525,52 +3018,123 @@ async function init() {
|
|
|
1525
3018
|
reasoning_content: reasoningContent || undefined,
|
|
1526
3019
|
calls: toolCalls.map(tc => {
|
|
1527
3020
|
let args = {};
|
|
1528
|
-
try { args = JSON.parse(tc.arguments); } catch {
|
|
3021
|
+
try { args = JSON.parse(tc.arguments); } catch (e) {
|
|
3022
|
+
logger.log(`[assistant] tool_calls args parse error (${tc.name}): ${e.message}, raw: ${(tc.arguments || '').slice(0, 200)}`);
|
|
3023
|
+
args = { _raw: tc.arguments, _parseError: true };
|
|
3024
|
+
}
|
|
1529
3025
|
return { id: tc.id, name: tc.name, arguments: args };
|
|
1530
3026
|
}),
|
|
1531
3027
|
});
|
|
1532
3028
|
|
|
1533
|
-
// 追加 assistant
|
|
3029
|
+
// 追加 assistant(tool_calls) 到对话历史
|
|
1534
3030
|
const assistantMsg = {
|
|
1535
3031
|
role: 'assistant',
|
|
1536
3032
|
content: fullContent || null,
|
|
1537
|
-
tool_calls: toolCalls.map(tc => ({
|
|
1538
|
-
id: tc.id,
|
|
1539
|
-
type: 'function',
|
|
1540
|
-
function: { name: tc.name, arguments: tc.arguments },
|
|
1541
|
-
})),
|
|
3033
|
+
tool_calls: toolCalls.map(tc => ({ id: tc.id, type: 'function', function: { name: tc.name, arguments: tc.arguments } })),
|
|
1542
3034
|
};
|
|
1543
3035
|
if (reasoningContent) assistantMsg.reasoning_content = reasoningContent;
|
|
1544
|
-
|
|
3036
|
+
conv.messages.push(assistantMsg);
|
|
1545
3037
|
|
|
1546
3038
|
// 执行工具
|
|
1547
3039
|
for (const tc of toolCalls) {
|
|
1548
3040
|
let args = {};
|
|
1549
|
-
|
|
1550
|
-
|
|
3041
|
+
let argsParseError = false;
|
|
3042
|
+
try { args = JSON.parse(tc.arguments); } catch (e) {
|
|
3043
|
+
logger.log(`[assistant] tool args parse error (${tc.name}): ${e.message}`);
|
|
3044
|
+
argsParseError = true;
|
|
3045
|
+
}
|
|
3046
|
+
logger.log(`[assistant] EXEC tool: ${tc.name}`);
|
|
1551
3047
|
let result;
|
|
1552
|
-
|
|
1553
|
-
|
|
3048
|
+
let isError = false;
|
|
3049
|
+
if (argsParseError) {
|
|
3050
|
+
result = { error: `工具 ${tc.name} 的参数 JSON 解析失败,原始内容: ${(tc.arguments || '').slice(0, 200)}` };
|
|
3051
|
+
isError = true;
|
|
3052
|
+
} else try {
|
|
3053
|
+
const mcpHandler = tc.name.startsWith('mcp__') ? mcpClient.getToolHandlerMap()[tc.name] : null;
|
|
3054
|
+
result = await (TOOL_HANDLERS[tc.name] || mcpHandler)?.(args) || { error: `未知工具: ${tc.name}` };
|
|
3055
|
+
if (result && result.error) isError = true;
|
|
1554
3056
|
} catch (err) {
|
|
1555
3057
|
logger.log(`[assistant] tool ${tc.name} error: ${err.message}`);
|
|
1556
3058
|
result = { error: err.message };
|
|
3059
|
+
isError = true;
|
|
1557
3060
|
}
|
|
1558
|
-
|
|
3061
|
+
result = truncateOutput(result);
|
|
1559
3062
|
const resultStr = JSON.stringify(result);
|
|
1560
|
-
logger.log(`[assistant] tool ${tc.name} done: ${resultStr.length} chars`);
|
|
1561
|
-
safeSSE('tool_result', { tool_call_id: tc.id, name: tc.name, result });
|
|
3063
|
+
logger.log(`[assistant] tool ${tc.name} done: ${resultStr.length} chars${isError ? ' (error)' : ''}`);
|
|
3064
|
+
safeSSE('tool_result', { tool_call_id: tc.id, name: tc.name, result, is_error: isError });
|
|
3065
|
+
conv.messages.push({ role: 'tool', tool_call_id: tc.id, content: isError ? `[ERROR] ${resultStr}` : resultStr });
|
|
3066
|
+
}
|
|
1562
3067
|
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
});
|
|
3068
|
+
// token 检查 + 压缩
|
|
3069
|
+
currentTokens = estimateConversationTokens(buildMessages());
|
|
3070
|
+
sendContext();
|
|
3071
|
+
if (currentTokens >= MAX_CONTEXT * 0.8) {
|
|
3072
|
+
logger.log(`[assistant] 上下文 ${Math.round(currentTokens / MAX_CONTEXT * 100)}%,自动压缩`);
|
|
3073
|
+
safeSSE('compressing', {});
|
|
3074
|
+
const compResult = await compressConversation(conv, MAX_CONTEXT, proxyUrl, proxyHeaders, proxy.defaultModel);
|
|
3075
|
+
if (compResult) {
|
|
3076
|
+
conv.messages = compResult.messages;
|
|
3077
|
+
conv.compressionSummary = compResult.summary;
|
|
3078
|
+
conversationStore.touch(conv);
|
|
3079
|
+
currentTokens = compResult.newTokens;
|
|
3080
|
+
safeSSE('compressed', { summary: compResult.summary, removedCount: compResult.removedCount, tokens: currentTokens, maxTokens: MAX_CONTEXT, messages: conv.messages.length });
|
|
3081
|
+
sendContext();
|
|
3082
|
+
logger.log(`[assistant] 压缩完成 — 移除 ${compResult.removedCount} 条`);
|
|
3083
|
+
}
|
|
1568
3084
|
}
|
|
1569
|
-
// 继续下一轮
|
|
1570
3085
|
}
|
|
1571
3086
|
|
|
1572
|
-
//
|
|
1573
|
-
|
|
3087
|
+
// 达到最大轮次 → 总结回复
|
|
3088
|
+
logger.log(`[assistant] max rounds reached, requesting summary`);
|
|
3089
|
+
try {
|
|
3090
|
+
const summaryRes = await fetch(proxyUrl, {
|
|
3091
|
+
method: 'POST',
|
|
3092
|
+
headers: proxyHeaders,
|
|
3093
|
+
signal: AbortSignal.timeout(120000),
|
|
3094
|
+
body: JSON.stringify({
|
|
3095
|
+
model: proxy.defaultModel || 'gpt-4o',
|
|
3096
|
+
messages: [
|
|
3097
|
+
...buildMessages(),
|
|
3098
|
+
{ role: 'system', content: '你已达到最大工具调用轮次限制(' + MAX_TOOL_ROUNDS + ' 轮),无法继续调用工具。请基于已获取的信息给出回复,并明确告知用户:由于达到工具调用轮次上限,信息获取可能不完整或操作被迫中断。如果还有未完成的工作,请说明并建议用户重新提问以继续。' },
|
|
3099
|
+
],
|
|
3100
|
+
stream: true,
|
|
3101
|
+
}),
|
|
3102
|
+
});
|
|
3103
|
+
if (summaryRes.ok) {
|
|
3104
|
+
const sr = summaryRes.body.getReader();
|
|
3105
|
+
const sd = new TextDecoder();
|
|
3106
|
+
let sb = '';
|
|
3107
|
+
let summaryContent = '';
|
|
3108
|
+
let summaryReasoning = '';
|
|
3109
|
+
while (true) {
|
|
3110
|
+
const { done: finished, value: v } = await sr.read();
|
|
3111
|
+
if (finished) break;
|
|
3112
|
+
sb += sd.decode(v, { stream: true });
|
|
3113
|
+
const lines = sb.split('\n');
|
|
3114
|
+
sb = lines.pop();
|
|
3115
|
+
for (const line of lines) {
|
|
3116
|
+
const t = line.trim();
|
|
3117
|
+
if (!t || !t.startsWith('data: ') || t === 'data: [DONE]') continue;
|
|
3118
|
+
try {
|
|
3119
|
+
const chunk = JSON.parse(t.slice(6));
|
|
3120
|
+
const delta = chunk.choices?.[0]?.delta;
|
|
3121
|
+
if (!delta) continue;
|
|
3122
|
+
if (delta.content) { summaryContent += delta.content; safeSSE('content', { delta: delta.content }); }
|
|
3123
|
+
if (delta.reasoning_content) summaryReasoning += delta.reasoning_content;
|
|
3124
|
+
} catch {}
|
|
3125
|
+
}
|
|
3126
|
+
}
|
|
3127
|
+
// 追加总结到对话历史
|
|
3128
|
+
const summaryMsg = { role: 'assistant', content: summaryContent || null };
|
|
3129
|
+
if (summaryReasoning) summaryMsg.reasoning_content = summaryReasoning;
|
|
3130
|
+
conv.messages.push(summaryMsg);
|
|
3131
|
+
safeSSE('done', { reasoning_content: summaryReasoning || undefined });
|
|
3132
|
+
} else {
|
|
3133
|
+
safeSSE('done', {});
|
|
3134
|
+
}
|
|
3135
|
+
} catch {
|
|
3136
|
+
safeSSE('done', {});
|
|
3137
|
+
}
|
|
1574
3138
|
} catch (err) {
|
|
1575
3139
|
logger.log(`[assistant] error: ${err.message}`);
|
|
1576
3140
|
if (!res.headersSent) {
|
|
@@ -1579,6 +3143,8 @@ async function init() {
|
|
|
1579
3143
|
safeSSE('error', { message: err.message });
|
|
1580
3144
|
}
|
|
1581
3145
|
} finally {
|
|
3146
|
+
activeStreams.delete(convId);
|
|
3147
|
+
conversationStore.touch(conv); // 保存最终对话状态
|
|
1582
3148
|
res.end();
|
|
1583
3149
|
}
|
|
1584
3150
|
});
|
|
@@ -1765,6 +3331,19 @@ async function init() {
|
|
|
1765
3331
|
requestLog.onEntry((entry) => wsServer.broadcast(entry));
|
|
1766
3332
|
logger.log(`[Admin] WebSocket 已附加 (ws://localhost:${PORT})`);
|
|
1767
3333
|
|
|
3334
|
+
// 初始化 MCP 客户端
|
|
3335
|
+
mcpClient.init({
|
|
3336
|
+
onUpdate: (serverName, status) => {
|
|
3337
|
+
wsServer.broadcast({ type: 'mcp_status', server: serverName, ...status });
|
|
3338
|
+
}
|
|
3339
|
+
}).then(() => {
|
|
3340
|
+
const status = mcpClient.getStatus();
|
|
3341
|
+
const connected = status.filter(s => s.status === 'connected').length;
|
|
3342
|
+
if (status.length > 0) logger.log(`[MCP] ${connected}/${status.length} 个 MCP 服务已连接`);
|
|
3343
|
+
}).catch(err => {
|
|
3344
|
+
logger.error('[MCP] 初始化失败:', err.message);
|
|
3345
|
+
});
|
|
3346
|
+
|
|
1768
3347
|
openBrowser(adminUrl);
|
|
1769
3348
|
});
|
|
1770
3349
|
}
|
|
@@ -1776,6 +3355,8 @@ process.on('SIGINT', async () => {
|
|
|
1776
3355
|
try {
|
|
1777
3356
|
const wsServer = require('./lib/ws-server');
|
|
1778
3357
|
wsServer.close();
|
|
3358
|
+
const mcpClient = require('./lib/mcp-client');
|
|
3359
|
+
await mcpClient.shutdown();
|
|
1779
3360
|
const proxyManager = require('./lib/proxy-manager');
|
|
1780
3361
|
const statsStore = require('./lib/stats-store');
|
|
1781
3362
|
statsStore.flush();
|
|
@@ -1791,6 +3372,8 @@ process.on('SIGTERM', async () => {
|
|
|
1791
3372
|
try {
|
|
1792
3373
|
const wsServer = require('./lib/ws-server');
|
|
1793
3374
|
wsServer.close();
|
|
3375
|
+
const mcpClient = require('./lib/mcp-client');
|
|
3376
|
+
await mcpClient.shutdown();
|
|
1794
3377
|
const proxyManager = require('./lib/proxy-manager');
|
|
1795
3378
|
const statsStore = require('./lib/stats-store');
|
|
1796
3379
|
statsStore.flush();
|