protocol-proxy 2.9.0 → 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/public/app.js CHANGED
@@ -108,6 +108,8 @@ function navigateTo(page) {
108
108
  'request-logs': '\u8bf7\u6c42\u65e5\u5fd7',
109
109
  'system-logs': '\u7cfb\u7edf\u65e5\u5fd7',
110
110
  assistant: '\u667a\u63a7\u52a9\u624b',
111
+ skills: '\u6280\u80fd\u7ba1\u7406',
112
+ 'mcp-servers': 'MCP \u670d\u52a1',
111
113
  settings: '\u8bbe\u7f6e',
112
114
  };
113
115
  document.getElementById('page-title').textContent = titles[page] || page;
@@ -119,7 +121,9 @@ function navigateTo(page) {
119
121
  if (page === 'stats') loadStats();
120
122
  if (page === 'system-logs') loadLogs();
121
123
  if (page === 'request-logs') renderRequestLogs();
122
- if (page === 'assistant') { populateAssistantProxySelect(); loadConversations(); }
124
+ if (page === 'assistant') { populateAssistantProxySelect(); loadConversations(); loadAssistantSkills(); }
125
+ if (page === 'skills') loadSkills();
126
+ if (page === 'mcp-servers') loadMcpServers();
123
127
  }
124
128
 
125
129
  document.querySelectorAll('.nav-item[data-page]').forEach(item => {
@@ -1129,6 +1133,11 @@ function connectRequestLogWS() {
1129
1133
  ws.onmessage = (e) => {
1130
1134
  try {
1131
1135
  const msg = JSON.parse(e.data);
1136
+ // MCP 状态更新
1137
+ if (msg.type === 'mcp_status') {
1138
+ updateMcpServerStatus(msg.server, msg);
1139
+ return;
1140
+ }
1132
1141
  const entry = msg.id ? msg : (msg.data || msg);
1133
1142
  if (entry && entry.id) {
1134
1143
  requestLogs.unshift(entry);
@@ -1579,6 +1588,12 @@ async function init() {
1579
1588
  document.getElementById('provider-azure-row').style.display = this.value === 'openai' ? 'grid' : 'none';
1580
1589
  });
1581
1590
 
1591
+ // 加载版本号
1592
+ fetch('/api/health').then(r => r.json()).then(d => {
1593
+ const el = document.getElementById('app-version');
1594
+ if (el && d.version) el.textContent = 'v' + d.version;
1595
+ }).catch(() => {});
1596
+
1582
1597
  await Promise.all([loadProxies(), loadProviders(), loadKeyHealth()]);
1583
1598
  loadStats();
1584
1599
  loadLogs();
@@ -1603,12 +1618,13 @@ async function init() {
1603
1618
  setInterval(loadStats, 30000);
1604
1619
  setInterval(loadKeyHealth, 5 * 60 * 1000);
1605
1620
 
1606
- // Assistant textarea auto-resize
1621
+ // Assistant textarea auto-resize + skill autocomplete
1607
1622
  const assistantInput = document.getElementById('assistant-input');
1608
1623
  if (assistantInput) {
1609
1624
  assistantInput.addEventListener('input', function() {
1610
1625
  this.style.height = 'auto';
1611
1626
  this.style.height = Math.min(this.scrollHeight, 120) + 'px';
1627
+ updateSkillAutocomplete(this.value);
1612
1628
  });
1613
1629
  assistantInput.addEventListener('keydown', function(e) {
1614
1630
  if (e.key === 'Enter' && !e.shiftKey) {
@@ -1617,6 +1633,13 @@ async function init() {
1617
1633
  }
1618
1634
  });
1619
1635
  }
1636
+ // 点击外部关闭补全
1637
+ document.addEventListener('click', (e) => {
1638
+ const ac = document.getElementById('skill-autocomplete');
1639
+ if (ac && !ac.contains(e.target) && e.target.id !== 'assistant-input') {
1640
+ ac.style.display = 'none';
1641
+ }
1642
+ });
1620
1643
  }
1621
1644
 
1622
1645
  async function loadRequestLogHistory() {
@@ -1654,49 +1677,6 @@ function populateAssistantProxySelect() {
1654
1677
  }
1655
1678
  }
1656
1679
 
1657
- function buildSystemPrompt() {
1658
- const now = new Date().toLocaleString('zh-CN', { hour12: false });
1659
- return `你是 Protocol Proxy 的智能助手,专门帮助管理员监控和排障。当前时间:${now}
1660
-
1661
- 你有以下工具可以调用:
1662
-
1663
- 系统查询:
1664
- - get_system_status: 获取系统概览(代理运行状态、供应商数量、运行时长)
1665
- - get_providers / get_provider: 获取供应商列表或详情
1666
- - get_proxies / get_proxy: 获取代理列表或详情
1667
- - get_usage_stats: 查询用量统计(支持按时间范围、代理筛选)
1668
- - get_recent_requests: 获取最近请求日志
1669
- - get_system_logs: 获取系统日志
1670
- - get_key_health: 获取 API Key 健康检查结果
1671
- - get_settings: 获取系统设置项
1672
- - get_config_history: 获取配置快照历史
1673
-
1674
- 文件与命令:
1675
- - read_file: 读取任意文件内容(支持指定行范围,自动检测二进制文件)
1676
- - write_file: 写入文件(会覆盖已有内容)
1677
- - edit_file: 精确替换文件中的字符串(比 write_file 更安全,只替换匹配内容)
1678
- - list_directory: 列出目录内容
1679
- - search_files: 按文件名 glob 模式搜索文件
1680
- - grep_search: 按正则表达式搜索文件内容(用于查找代码、日志关键字等)
1681
- - execute_command: 执行 shell 命令
1682
-
1683
- 规则:
1684
- - 当用户询问系统状态、代理、供应商、日志、用量等运维相关问题时,调用工具获取实时数据后再回答
1685
- - 当用户需要查看或修改文件、执行命令时,使用对应的文件和命令工具
1686
- - 当用户只是打招呼、闲聊、或询问与系统无关的问题时,直接回答,不要调用工具
1687
- - 不要凭空猜测系统状态,需要数据时必须调用工具
1688
- - 执行写操作或危险命令前,先告知用户将要做什么
1689
-
1690
- 你的职责:
1691
- 1. 回答关于代理配置和运行状态的问题
1692
- 2. 分析日志,指出异常和可能原因
1693
- 3. 根据数据给出优化建议(负载均衡、模型选择、故障切换策略)
1694
- 4. 用自然语言解释技术问题
1695
- 5. 如果发现问题,给出具体的修复步骤
1696
-
1697
- 请用中文回答,保持专业且易懂。`;
1698
- }
1699
-
1700
1680
  async function sendAssistantMessage() {
1701
1681
  if (assistantAbortController) return; // 已有请求进行中,防连点
1702
1682
  const input = document.getElementById('assistant-input');
@@ -2334,4 +2314,655 @@ document.addEventListener('click', (e) => {
2334
2314
  }
2335
2315
  });
2336
2316
 
2317
+ // ========== 助手 Skill 补全 ==========
2318
+ let assistantSkills = []; // 助手页面缓存的 skill 列表
2319
+
2320
+ async function loadAssistantSkills() {
2321
+ try {
2322
+ const res = await fetch('/api/skills');
2323
+ const data = await res.json();
2324
+ assistantSkills = data.skills || [];
2325
+ renderSkillPanel();
2326
+ } catch {}
2327
+ }
2328
+
2329
+ function toggleSkillPanel() {
2330
+ const panel = document.getElementById('skill-panel');
2331
+ if (!panel) return;
2332
+ panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
2333
+ }
2334
+
2335
+ function renderSkillPanel() {
2336
+ const list = document.getElementById('skill-panel-list');
2337
+ if (!list) return;
2338
+ const catColors = { system: 'var(--error)', preset: 'var(--warning)', user: 'var(--success)' };
2339
+ const catLabels = { system: '系统', preset: '预设', user: '用户' };
2340
+ if (assistantSkills.length === 0) {
2341
+ list.innerHTML = '<div style="padding:8px;color:var(--text-muted);font-size:12px">暂无可用技能,可前往「技能管理」页面创建</div>';
2342
+ return;
2343
+ }
2344
+ list.innerHTML = assistantSkills.map(s =>
2345
+ `<div class="skill-panel-item" data-name="${escapeHtml(s.name)}">
2346
+ <span class="skill-panel-badge" style="background:${catColors[s.category] || 'var(--text-muted)'}">${catLabels[s.category] || s.category}</span>
2347
+ <strong>/${escapeHtml(s.name)}</strong>
2348
+ <span class="skill-panel-desc">${escapeHtml(s.description || '')}</span>
2349
+ </div>`
2350
+ ).join('');
2351
+ list.onclick = (e) => {
2352
+ const item = e.target.closest('[data-name]');
2353
+ if (item) selectSkill(item.dataset.name);
2354
+ };
2355
+ }
2356
+
2357
+ function updateSkillAutocomplete(text) {
2358
+ const ac = document.getElementById('skill-autocomplete');
2359
+ if (!ac) return;
2360
+ // 只在输入以 / 开头且没有空格(还没输入参数)时显示
2361
+ const match = text.match(/^\/([a-zA-Z0-9_-]*)$/);
2362
+ if (!match) { ac.style.display = 'none'; return; }
2363
+ const query = match[1].toLowerCase();
2364
+ const filtered = assistantSkills.filter(s => s.name.toLowerCase().includes(query));
2365
+ if (filtered.length === 0) { ac.style.display = 'none'; return; }
2366
+ ac.innerHTML = filtered.map(s =>
2367
+ `<div class="skill-ac-item" data-name="${escapeHtml(s.name)}"><strong>/${escapeHtml(s.name)}</strong><span class="skill-ac-desc">${escapeHtml(s.description || '')}</span></div>`
2368
+ ).join('');
2369
+ ac.style.display = 'block';
2370
+ ac.onclick = (e) => {
2371
+ const item = e.target.closest('[data-name]');
2372
+ if (item) selectSkill(item.dataset.name);
2373
+ };
2374
+ }
2375
+
2376
+ function selectSkill(name) {
2377
+ const input = document.getElementById('assistant-input');
2378
+ if (input) {
2379
+ input.value = '/' + name + ' ';
2380
+ input.focus();
2381
+ }
2382
+ const ac = document.getElementById('skill-autocomplete');
2383
+ if (ac) ac.style.display = 'none';
2384
+ }
2385
+
2386
+ // ========== 技能管理 ==========
2387
+ let allSkills = [];
2388
+ let editingSkillName = '';
2389
+ let pendingSkillFiles = null; // 待上传的文件夹 [{path, content(base64)}]
2390
+
2391
+ async function loadSkills() {
2392
+ try {
2393
+ const res = await fetch('/api/skills');
2394
+ const data = await res.json();
2395
+ allSkills = data.skills || [];
2396
+ renderSkills();
2397
+ const badge = document.getElementById('nav-skill-count');
2398
+ if (badge) badge.textContent = allSkills.length;
2399
+ } catch (err) {
2400
+ showToast('加载技能失败: ' + err.message, true);
2401
+ }
2402
+ }
2403
+
2404
+ function renderSkills() {
2405
+ const container = document.getElementById('skills-container');
2406
+ if (!container) return;
2407
+ const groups = { system: [], preset: [], user: [] };
2408
+ for (const s of allSkills) (groups[s.category] || groups.user).push(s);
2409
+ const labels = { system: '系统级', preset: '预设', user: '用户' };
2410
+ const colors = { system: 'var(--error)', preset: 'var(--warning)', user: 'var(--success)' };
2411
+ let html = '';
2412
+ for (const [cat, items] of Object.entries(groups)) {
2413
+ if (items.length === 0) continue;
2414
+ html += `<div class="skill-group"><h3 style="margin:0 0 12px;display:flex;align-items:center;gap:8px"><span class="skill-badge" style="background:${colors[cat]};color:#fff;padding:2px 8px;border-radius:4px;font-size:12px">${labels[cat]}</span> ${labels[cat]}技能 (${items.length})</h3><div class="skills-grid">`;
2415
+ for (const s of items) {
2416
+ const canEdit = cat === 'user';
2417
+ const canDelete = cat !== 'system';
2418
+ html += `<div class="skill-card">
2419
+ <div class="skill-card-header"><strong>${escapeHtml(s.name)}</strong></div>
2420
+ <div class="skill-card-desc">${escapeHtml(s.description || '无描述')}</div>
2421
+ <div class="skill-card-actions">
2422
+ <button class="btn btn-sm" data-action="view" data-name="${escapeHtml(s.name)}">查看</button>
2423
+ ${canEdit ? `<button class="btn btn-sm" data-action="edit" data-name="${escapeHtml(s.name)}">编辑</button>` : ''}
2424
+ ${canDelete ? `<button class="btn btn-sm btn-danger" data-action="delete" data-name="${escapeHtml(s.name)}">删除</button>` : ''}
2425
+ </div>
2426
+ </div>`;
2427
+ }
2428
+ html += '</div></div>';
2429
+ }
2430
+ container.innerHTML = html || '<p style="color:var(--text-muted)">暂无技能</p>';
2431
+ container.onclick = (e) => {
2432
+ const btn = e.target.closest('[data-action]');
2433
+ if (!btn) return;
2434
+ const name = btn.dataset.name;
2435
+ const action = btn.dataset.action;
2436
+ if (action === 'view') viewSkill(name);
2437
+ else if (action === 'edit') editSkill(name);
2438
+ else if (action === 'delete') deleteSkill(name);
2439
+ };
2440
+ }
2441
+
2442
+ function showSkillModal(name) {
2443
+ editingSkillName = name || '';
2444
+ pendingSkillFile = null;
2445
+ const isEdit = !!name;
2446
+ document.getElementById('skill-modal-title').textContent = isEdit ? '编辑技能' : '上传技能';
2447
+ document.getElementById('skill-save-btn').textContent = isEdit ? '保存' : '上传';
2448
+ document.getElementById('skill-upload-section').style.display = isEdit ? 'none' : 'block';
2449
+ document.getElementById('skill-edit-section').style.display = isEdit ? 'block' : 'none';
2450
+ if (isEdit) {
2451
+ document.getElementById('skill-name').value = name;
2452
+ document.getElementById('skill-name').disabled = true;
2453
+ document.getElementById('skill-existing-files').innerHTML = '';
2454
+ fetch(`/api/skills/${encodeURIComponent(name)}`).then(r => r.json()).then(s => {
2455
+ document.getElementById('skill-description').value = s.description || '';
2456
+ document.getElementById('skill-content').value = s.content || '';
2457
+ renderSkillFiles(s);
2458
+ });
2459
+ } else {
2460
+ document.getElementById('skill-file-input').value = '';
2461
+ document.getElementById('skill-file-preview').innerHTML = '';
2462
+ }
2463
+ showModal('skill-modal');
2464
+ }
2465
+
2466
+ function renderSkillFiles(skill) {
2467
+ const el = document.getElementById('skill-existing-files');
2468
+ const allPaths = [];
2469
+ if (skill.scripts?.length) allPaths.push(...skill.scripts.map(f => `scripts/${f}`));
2470
+ if (skill.references?.length) allPaths.push(...skill.references.map(f => `reference/${f}`));
2471
+ if (allPaths.length === 0) { el.innerHTML = '<span style="color:var(--text-muted)">暂无附属文件</span>'; return; }
2472
+ // 构建树:{ name, children: [...dirs], files: [{name, fullPath}] }
2473
+ const tree = { name: '', children: [], files: [] };
2474
+ for (const p of allPaths) {
2475
+ const parts = p.split('/');
2476
+ let node = tree;
2477
+ for (let i = 0; i < parts.length - 1; i++) {
2478
+ let child = node.children.find(c => c.name === parts[i]);
2479
+ if (!child) { child = { name: parts[i], children: [], files: [] }; node.children.push(child); }
2480
+ node = child;
2481
+ }
2482
+ node.files.push({ name: parts[parts.length - 1], fullPath: p });
2483
+ }
2484
+ const canDelete = skill.category !== 'system';
2485
+ function countAll(node) {
2486
+ return node.files.length + node.children.reduce((s, c) => s + countAll(c), 0);
2487
+ }
2488
+ function renderNode(node, depth) {
2489
+ let html = '';
2490
+ html += renderFiles(node.files, depth);
2491
+ for (const dir of node.children) {
2492
+ html += `<div class="skill-tree-dir" style="padding-left:${depth * 16}px">` +
2493
+ `<span class="skill-tree-arrow">&#9654;</span> <code>${escapeHtml(dir.name)}/</code> <span style="color:var(--text-muted);font-size:11px">(${countAll(dir)})</span></div>`;
2494
+ html += `<div class="skill-tree-children" style="display:none">`;
2495
+ html += renderNode(dir, depth + 1);
2496
+ html += '</div>';
2497
+ }
2498
+ return html;
2499
+ }
2500
+ function renderFiles(files, depth) {
2501
+ return files.map(f =>
2502
+ `<div class="skill-tree-file" style="padding-left:${depth * 16}px">` +
2503
+ `<code>${escapeHtml(f.name)}</code>${canDelete ? ` <button class="btn btn-sm btn-danger" style="padding:0 6px;font-size:11px" data-action="delete-file" data-path="${escapeHtml(f.fullPath)}">删除</button>` : ''}</div>`
2504
+ ).join('');
2505
+ }
2506
+ el.innerHTML = renderNode(tree, 0);
2507
+ el.onclick = (e) => {
2508
+ const dir = e.target.closest('.skill-tree-dir');
2509
+ if (dir) {
2510
+ const children = dir.nextElementSibling;
2511
+ const arrow = dir.querySelector('.skill-tree-arrow');
2512
+ const open = children.style.display === 'none';
2513
+ children.style.display = open ? 'block' : 'none';
2514
+ arrow.style.transform = open ? 'rotate(90deg)' : '';
2515
+ return;
2516
+ }
2517
+ const btn = e.target.closest('[data-action="delete-file"]');
2518
+ if (btn) deleteSkillFile(btn.dataset.path);
2519
+ };
2520
+ }
2521
+
2522
+ async function uploadSkillFiles() {
2523
+ if (!editingSkillName) return;
2524
+ const input = document.getElementById('skill-upload-input');
2525
+ const subDir = document.getElementById('skill-upload-dir').value;
2526
+ const files = input.files;
2527
+ if (!files.length) return showToast('请选择文件', true);
2528
+ for (const file of files) {
2529
+ const reader = new FileReader();
2530
+ reader.onload = async () => {
2531
+ const base64 = reader.result.split(',')[1];
2532
+ try {
2533
+ const res = await fetch(`/api/skills/${editingSkillName}/upload`, {
2534
+ method: 'POST',
2535
+ headers: { 'Content-Type': 'application/json' },
2536
+ body: JSON.stringify({ filename: file.name, subDir, content: base64 }),
2537
+ });
2538
+ if (!res.ok) { const e = await res.json(); throw new Error(e.error); }
2539
+ showToast(`已上传 ${file.name}`);
2540
+ // 刷新文件列表
2541
+ const s = await (await fetch(`/api/skills/${editingSkillName}`)).json();
2542
+ renderSkillFiles(s);
2543
+ } catch (err) {
2544
+ showToast('上传失败: ' + err.message, true);
2545
+ }
2546
+ };
2547
+ reader.readAsDataURL(file);
2548
+ }
2549
+ input.value = '';
2550
+ }
2551
+
2552
+ async function deleteSkillFile(filePath) {
2553
+ if (!editingSkillName) return;
2554
+ const ok = await showConfirm(`确定删除文件 <strong>${escapeHtml(filePath)}</strong>?`);
2555
+ if (!ok) return;
2556
+ try {
2557
+ const res = await fetch(`/api/skills/${encodeURIComponent(editingSkillName)}/file`, {
2558
+ method: 'DELETE',
2559
+ headers: { 'Content-Type': 'application/json' },
2560
+ body: JSON.stringify({ filePath }),
2561
+ });
2562
+ if (!res.ok) { const e = await res.json(); throw new Error(e.error); }
2563
+ showToast('文件已删除');
2564
+ const s = await (await fetch(`/api/skills/${editingSkillName}`)).json();
2565
+ renderSkillFiles(s);
2566
+ } catch (err) {
2567
+ showToast('删除失败: ' + err.message, true);
2568
+ }
2569
+ }
2570
+
2571
+ function closeSkillModal() {
2572
+ hideModal('skill-modal');
2573
+ editingSkillName = '';
2574
+ pendingSkillFiles = null;
2575
+ }
2576
+
2577
+ // 技能文件夹选择预览
2578
+ document.getElementById('skill-file-input')?.addEventListener('change', async (e) => {
2579
+ const files = Array.from(e.target.files);
2580
+ const preview = document.getElementById('skill-file-preview');
2581
+ if (!files.length) { pendingSkillFiles = null; preview.innerHTML = ''; return; }
2582
+ // 找到 SKILL.md(可能在子目录中)
2583
+ const skillMd = files.find(f => f.webkitRelativePath.endsWith('/SKILL.md') || f.name === 'SKILL.md');
2584
+ if (!skillMd) {
2585
+ pendingSkillFiles = null;
2586
+ preview.innerHTML = '<div style="color:var(--error);font-size:13px">文件夹中未找到 SKILL.md</div>';
2587
+ return;
2588
+ }
2589
+ // 读取 SKILL.md 解析 frontmatter
2590
+ const text = await skillMd.text();
2591
+ const fm = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
2592
+ let name = '', desc = '';
2593
+ if (fm) {
2594
+ for (const line of fm[1].split('\n')) {
2595
+ const nm = line.trim().match(/^name:\s*['"]?(.+?)['"]?\s*$/);
2596
+ const dm = line.trim().match(/^description:\s*['"]?(.+?)['"]?\s*$/);
2597
+ if (nm) name = nm[1];
2598
+ if (dm) desc = dm[1];
2599
+ }
2600
+ }
2601
+ if (!name) {
2602
+ pendingSkillFiles = null;
2603
+ preview.innerHTML = '<div style="color:var(--error);font-size:13px">SKILL.md 缺少 name 字段</div>';
2604
+ return;
2605
+ }
2606
+ // 读取所有文件为 base64
2607
+ const prefix = skillMd.webkitRelativePath.split('/')[0] + '/';
2608
+ pendingSkillFiles = await Promise.all(files.map(async f => {
2609
+ const buf = await f.arrayBuffer();
2610
+ const bytes = new Uint8Array(buf);
2611
+ let binary = '';
2612
+ for (let i = 0; i < bytes.length; i += 8192) binary += String.fromCharCode.apply(null, bytes.subarray(i, i + 8192));
2613
+ return { path: f.webkitRelativePath.replace(prefix, ''), content: btoa(binary) };
2614
+ }));
2615
+ // 预览
2616
+ const fileList = pendingSkillFiles.map(f => `<code>${escapeHtml(f.path)}</code>`).join(' ');
2617
+ preview.innerHTML =
2618
+ `<div style="padding:8px 12px;background:var(--bg-elevated);border-radius:6px;font-size:13px">` +
2619
+ `<div><strong>名称:</strong>${escapeHtml(name)}</div>` +
2620
+ `<div style="color:var(--text-muted)"><strong>描述:</strong>${escapeHtml(desc || '无')}</div>` +
2621
+ `<div style="color:var(--text-muted);font-size:12px;margin-top:4px">文件 (${pendingSkillFiles.length}):${fileList}</div></div>`;
2622
+ });
2623
+
2624
+ async function saveSkill() {
2625
+ if (!editingSkillName) {
2626
+ // 上传模式
2627
+ if (!pendingSkillFiles) return showToast('请选择技能文件夹', true);
2628
+ try {
2629
+ const res = await fetch('/api/skills/upload', {
2630
+ method: 'POST',
2631
+ headers: { 'Content-Type': 'application/json' },
2632
+ body: JSON.stringify({ files: pendingSkillFiles }),
2633
+ });
2634
+ if (!res.ok) { const e = await res.json(); throw new Error(e.error); }
2635
+ closeSkillModal();
2636
+ await loadSkills();
2637
+ showToast('技能已上传');
2638
+ } catch (err) {
2639
+ showToast('上传失败: ' + err.message, true);
2640
+ }
2641
+ return;
2642
+ }
2643
+ // 编辑模式
2644
+ const name = document.getElementById('skill-name').value.trim();
2645
+ const description = document.getElementById('skill-description').value.trim();
2646
+ const content = document.getElementById('skill-content').value.trim();
2647
+ if (!name || !content) return showToast('名称和内容不能为空', true);
2648
+ try {
2649
+ const res = await fetch(`/api/skills/${encodeURIComponent(editingSkillName)}`, {
2650
+ method: 'PUT',
2651
+ headers: { 'Content-Type': 'application/json' },
2652
+ body: JSON.stringify({ name, description, content }),
2653
+ });
2654
+ if (!res.ok) { const e = await res.json(); throw new Error(e.error); }
2655
+ closeSkillModal();
2656
+ await loadSkills();
2657
+ showToast('技能已更新');
2658
+ } catch (err) {
2659
+ showToast('保存失败: ' + err.message, true);
2660
+ }
2661
+ }
2662
+
2663
+ function editSkill(name) {
2664
+ showSkillModal(name);
2665
+ }
2666
+
2667
+ async function deleteSkill(name) {
2668
+ const ok = await showConfirm(`确定删除技能 <strong>${escapeHtml(name)}</strong>?`);
2669
+ if (!ok) return;
2670
+ try {
2671
+ const res = await fetch(`/api/skills/${encodeURIComponent(name)}`, { method: 'DELETE' });
2672
+ if (!res.ok) { const e = await res.json(); throw new Error(e.error); }
2673
+ await loadSkills();
2674
+ showToast('技能已删除');
2675
+ } catch (err) {
2676
+ showToast('删除失败: ' + err.message, true);
2677
+ }
2678
+ }
2679
+
2680
+ async function viewSkill(name) {
2681
+ try {
2682
+ const res = await fetch(`/api/skills/${encodeURIComponent(name)}`);
2683
+ const s = await res.json();
2684
+ const catColors = { system: 'var(--error)', preset: 'var(--warning)', user: 'var(--success)' };
2685
+ const catLabels = { system: '系统级', preset: '预设', user: '用户' };
2686
+ document.getElementById('skill-view-title').textContent = s.name;
2687
+ const badge = document.getElementById('skill-view-badge');
2688
+ badge.textContent = catLabels[s.category] || s.category;
2689
+ badge.style.background = catColors[s.category] || 'var(--text-muted)';
2690
+ badge.style.color = '#fff';
2691
+ document.getElementById('skill-view-desc').textContent = s.description || '';
2692
+ // 显示附属文件
2693
+ const filesEl = document.getElementById('skill-view-files');
2694
+ const files = [];
2695
+ if (s.scripts?.length) files.push(...s.scripts.map(f => `<code>scripts/${escapeHtml(f)}</code>`));
2696
+ if (s.references?.length) files.push(...s.references.map(f => `<code>reference/${escapeHtml(f)}</code>`));
2697
+ filesEl.innerHTML = files.length > 0 ? `<div style="font-size:12px;color:var(--text-muted)">附属文件: ${files.join(' ')}</div>` : '';
2698
+ // 内容(markdown 渲染)
2699
+ const rawHtml = typeof marked !== 'undefined' ? marked.parse(s.content) : formatAssistantContent(s.content);
2700
+ const contentHtml = typeof DOMPurify !== 'undefined' ? DOMPurify.sanitize(rawHtml) : rawHtml;
2701
+ document.getElementById('skill-view-content').innerHTML = contentHtml;
2702
+ showModal('skill-view-modal');
2703
+ } catch (err) {
2704
+ showToast('加载失败: ' + err.message, true);
2705
+ }
2706
+ }
2707
+
2708
+ function closeSkillViewModal() {
2709
+ hideModal('skill-view-modal');
2710
+ }
2711
+
2712
+ // ==================== MCP 服务管理 ====================
2713
+
2714
+ let mcpServersData = [];
2715
+ let editingMcpName = '';
2716
+
2717
+ async function loadMcpServers() {
2718
+ try {
2719
+ const res = await fetch('/api/mcp/servers');
2720
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
2721
+ mcpServersData = await res.json();
2722
+ renderMcpServers();
2723
+ const navBadge = document.getElementById('nav-mcp-count');
2724
+ if (navBadge) navBadge.textContent = mcpServersData.length;
2725
+ } catch (err) {
2726
+ document.getElementById('mcp-servers-container').innerHTML = `<div style="color:var(--error);padding:20px">加载失败: ${escapeHtml(err.message)}</div>`;
2727
+ }
2728
+ }
2729
+
2730
+ function renderMcpServers() {
2731
+ const container = document.getElementById('mcp-servers-container');
2732
+ if (!mcpServersData.length) {
2733
+ container.innerHTML = `<div style="text-align:center;padding:40px;color:var(--text-muted)">
2734
+ <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="margin-bottom:12px;opacity:0.4"><path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z"></path><path d="M12 18a6 6 0 1 0 0-12 6 6 0 0 0 0 12z"></path><path d="M12 14a2 2 0 1 0 0-4 2 2 0 0 0 0 4z"></path></svg>
2735
+ <p>暂无 MCP 服务配置</p>
2736
+ <p style="font-size:13px">点击上方「添加 MCP 服务」开始</p>
2737
+ </div>`;
2738
+ return;
2739
+ }
2740
+
2741
+ container.innerHTML = `<div class="skills-grid">${mcpServersData.map(s => {
2742
+ const statusColors = { connected: 'var(--success)', connecting: 'var(--warning)', error: 'var(--error)', disconnected: 'var(--text-muted)' };
2743
+ const statusTexts = { connected: '已连接', connecting: '连接中...', error: '错误', disconnected: '已断开' };
2744
+ const color = statusColors[s.status] || 'var(--text-muted)';
2745
+ const statusText = statusTexts[s.status] || s.status;
2746
+ const transportBadge = s.transport === 'http' ? '<span class="skill-badge" style="background:var(--accent-subtle);color:var(--accent);font-size:11px;padding:1px 6px">HTTP</span>' : '<span class="skill-badge" style="background:var(--success-subtle);color:var(--success);font-size:11px;padding:1px 6px">stdio</span>';
2747
+
2748
+ return `<div class="skill-card" data-mcp-name="${escapeHtml(s.name)}">
2749
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">
2750
+ <div style="display:flex;align-items:center;gap:8px">
2751
+ <span style="width:10px;height:10px;border-radius:50%;background:${color};display:inline-block;flex-shrink:0"></span>
2752
+ <strong style="font-size:15px">${escapeHtml(s.name)}</strong>
2753
+ ${transportBadge}
2754
+ ${!s.enabled ? '<span class="skill-badge" style="background:var(--text-muted);color:#fff;font-size:11px;padding:1px 6px">已禁用</span>' : ''}
2755
+ </div>
2756
+ <span style="font-size:12px;color:${color}">${statusText}</span>
2757
+ </div>
2758
+ <div style="font-size:12px;color:var(--text-muted);margin-bottom:8px">
2759
+ ${s.transport === 'stdio' ? escapeHtml(s.command || '') : escapeHtml(s.url || '')}
2760
+ ${s.tools ? ` · ${s.tools.length} 个工具` : ''}
2761
+ ${s.lastError ? `<br><span style="color:var(--error)">${escapeHtml(s.lastError)}</span>` : ''}
2762
+ </div>
2763
+ <div style="display:flex;gap:8px;flex-wrap:wrap">
2764
+ ${s.status === 'connected' ? `<button class="btn btn-sm" onclick="disconnectMcpServer('${escapeAttr(s.name)}')">断开</button>` : `<button class="btn btn-sm btn-primary" onclick="connectMcpServer('${escapeAttr(s.name)}')">连接</button>`}
2765
+ ${s.tools && s.tools.length ? `<button class="btn btn-sm" onclick="viewMcpTools('${escapeAttr(s.name)}')">查看工具</button>` : ''}
2766
+ <button class="btn btn-sm" onclick="editMcpServer('${escapeAttr(s.name)}')">编辑</button>
2767
+ <button class="btn btn-sm" style="color:var(--error)" onclick="deleteMcpServer('${escapeAttr(s.name)}')">删除</button>
2768
+ </div>
2769
+ </div>`;
2770
+ }).join('')}</div>`;
2771
+ }
2772
+
2773
+ function toggleMcpTransport() {
2774
+ const val = document.querySelector('input[name="mcp-transport"]:checked').value;
2775
+ document.getElementById('mcp-stdio-fields').style.display = val === 'stdio' ? '' : 'none';
2776
+ document.getElementById('mcp-http-fields').style.display = val === 'http' ? '' : 'none';
2777
+ }
2778
+
2779
+ function addMcpEnvRow(key, value) {
2780
+ const editor = document.getElementById('mcp-env-editor');
2781
+ const row = document.createElement('div');
2782
+ row.style.cssText = 'display:flex;gap:6px;margin-bottom:4px;align-items:center';
2783
+ row.innerHTML = `<input type="text" placeholder="KEY" style="flex:1;font-size:12px;padding:4px 8px" class="mcp-env-key" value="${escapeAttr(key || '')}"> <input type="text" placeholder="value" style="flex:1;font-size:12px;padding:4px 8px" class="mcp-env-val" value="${escapeAttr(value || '')}"> <button class="btn btn-sm" style="color:var(--error);padding:2px 6px" onclick="this.parentElement.remove()">&times;</button>`;
2784
+ editor.appendChild(row);
2785
+ }
2786
+
2787
+ function addMcpHeaderRow(key, value) {
2788
+ const editor = document.getElementById('mcp-headers-editor');
2789
+ const row = document.createElement('div');
2790
+ row.style.cssText = 'display:flex;gap:6px;margin-bottom:4px;align-items:center';
2791
+ row.innerHTML = `<input type="text" placeholder="Header-Name" style="flex:1;font-size:12px;padding:4px 8px" class="mcp-header-key" value="${escapeAttr(key || '')}"> <input type="text" placeholder="value" style="flex:1;font-size:12px;padding:4px 8px" class="mcp-header-val" value="${escapeAttr(value || '')}"> <button class="btn btn-sm" style="color:var(--error);padding:2px 6px" onclick="this.parentElement.remove()">&times;</button>`;
2792
+ editor.appendChild(row);
2793
+ }
2794
+
2795
+ function collectMcpKvPairs(containerId, keyClass, valClass) {
2796
+ const result = {};
2797
+ document.querySelectorAll(`#${containerId} .${keyClass}`).forEach((input, i) => {
2798
+ const key = input.value.trim();
2799
+ const val = input.closest('div').querySelector(`.${valClass}`).value;
2800
+ if (key) result[key] = val;
2801
+ });
2802
+ return result;
2803
+ }
2804
+
2805
+ function showMcpModal(name) {
2806
+ editingMcpName = name || '';
2807
+ document.getElementById('mcp-modal-title').textContent = name ? '编辑 MCP 服务' : '添加 MCP 服务';
2808
+ document.getElementById('mcp-name').value = name || '';
2809
+ document.getElementById('mcp-name').disabled = !!name;
2810
+ document.getElementById('mcp-command').value = '';
2811
+ document.getElementById('mcp-args').value = '';
2812
+ document.getElementById('mcp-url').value = '';
2813
+ document.getElementById('mcp-enabled').checked = true;
2814
+ document.getElementById('mcp-env-editor').innerHTML = '';
2815
+ document.getElementById('mcp-headers-editor').innerHTML = '';
2816
+ document.querySelector('input[name="mcp-transport"][value="stdio"]').checked = true;
2817
+ toggleMcpTransport();
2818
+
2819
+ if (name) {
2820
+ const s = mcpServersData.find(x => x.name === name);
2821
+ if (s) {
2822
+ document.getElementById('mcp-enabled').checked = s.enabled !== false;
2823
+ if (s.transport === 'http') {
2824
+ document.querySelector('input[name="mcp-transport"][value="http"]').checked = true;
2825
+ toggleMcpTransport();
2826
+ // 从 config 获取 url/headers
2827
+ fetch(`/api/mcp/servers/${encodeURIComponent(name)}`).then(r => r.json()).then(detail => {
2828
+ if (detail.config?.url) document.getElementById('mcp-url').value = detail.config.url;
2829
+ if (detail.config?.headers) {
2830
+ Object.entries(detail.config.headers).forEach(([k, v]) => addMcpHeaderRow(k, v));
2831
+ }
2832
+ });
2833
+ } else {
2834
+ fetch(`/api/mcp/servers/${encodeURIComponent(name)}`).then(r => r.json()).then(detail => {
2835
+ if (detail.config?.command) document.getElementById('mcp-command').value = detail.config.command;
2836
+ if (detail.config?.args) document.getElementById('mcp-args').value = (Array.isArray(detail.config.args) ? detail.config.args : [detail.config.args]).join(' ');
2837
+ if (detail.config?.env) {
2838
+ Object.entries(detail.config.env).forEach(([k, v]) => addMcpEnvRow(k, v));
2839
+ }
2840
+ });
2841
+ }
2842
+ }
2843
+ }
2844
+ showModal('mcp-modal');
2845
+ }
2846
+
2847
+ function closeMcpModal() {
2848
+ hideModal('mcp-modal');
2849
+ editingMcpName = '';
2850
+ }
2851
+
2852
+ async function saveMcpServer() {
2853
+ const name = document.getElementById('mcp-name').value.trim();
2854
+ if (!name) return showToast('请输入服务名称', true);
2855
+ if (!/^[a-zA-Z0-9_-]+$/.test(name)) return showToast('名称只能包含英文、数字、连字符、下划线', true);
2856
+
2857
+ const transport = document.querySelector('input[name="mcp-transport"]:checked').value;
2858
+ const enabled = document.getElementById('mcp-enabled').checked;
2859
+ const body = { name, enabled };
2860
+
2861
+ if (transport === 'stdio') {
2862
+ body.command = document.getElementById('mcp-command').value.trim();
2863
+ if (!body.command) return showToast('请输入命令', true);
2864
+ const argsStr = document.getElementById('mcp-args').value.trim();
2865
+ if (argsStr) body.args = argsStr.split(/\s+/);
2866
+ const env = collectMcpKvPairs('mcp-env-editor', 'mcp-env-key', 'mcp-env-val');
2867
+ if (Object.keys(env).length) body.env = env;
2868
+ } else {
2869
+ body.url = document.getElementById('mcp-url').value.trim();
2870
+ if (!body.url) return showToast('请输入 URL', true);
2871
+ const headers = collectMcpKvPairs('mcp-headers-editor', 'mcp-header-key', 'mcp-header-val');
2872
+ if (Object.keys(headers).length) body.headers = headers;
2873
+ }
2874
+
2875
+ try {
2876
+ const url = editingMcpName ? `/api/mcp/servers/${encodeURIComponent(editingMcpName)}` : '/api/mcp/servers';
2877
+ const method = editingMcpName ? 'PUT' : 'POST';
2878
+ const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
2879
+ const data = await res.json();
2880
+ if (!res.ok) return showToast(data.error || '操作失败', true);
2881
+ showToast(editingMcpName ? '已更新' : '已添加');
2882
+ closeMcpModal();
2883
+ loadMcpServers();
2884
+ } catch (err) {
2885
+ showToast('操作失败: ' + err.message, true);
2886
+ }
2887
+ }
2888
+
2889
+ async function deleteMcpServer(name) {
2890
+ if (!await showConfirm(`确定删除 MCP 服务「${escapeHtml(name)}」?`)) return;
2891
+ try {
2892
+ const res = await fetch(`/api/mcp/servers/${encodeURIComponent(name)}`, { method: 'DELETE' });
2893
+ if (!res.ok) { const d = await res.json(); return showToast(d.error || '删除失败', true); }
2894
+ showToast('已删除');
2895
+ loadMcpServers();
2896
+ } catch (err) {
2897
+ showToast('删除失败: ' + err.message, true);
2898
+ }
2899
+ }
2900
+
2901
+ async function connectMcpServer(name) {
2902
+ try {
2903
+ const res = await fetch(`/api/mcp/servers/${encodeURIComponent(name)}/connect`, { method: 'POST' });
2904
+ const data = await res.json();
2905
+ if (!res.ok) return showToast(data.error || '连接失败', true);
2906
+ showToast(`已连接 ${name}`);
2907
+ loadMcpServers();
2908
+ } catch (err) {
2909
+ showToast('连接失败: ' + err.message, true);
2910
+ }
2911
+ }
2912
+
2913
+ async function disconnectMcpServer(name) {
2914
+ try {
2915
+ await fetch(`/api/mcp/servers/${encodeURIComponent(name)}/disconnect`, { method: 'POST' });
2916
+ showToast(`已断开 ${name}`);
2917
+ loadMcpServers();
2918
+ } catch (err) {
2919
+ showToast('断开失败: ' + err.message, true);
2920
+ }
2921
+ }
2922
+
2923
+ function editMcpServer(name) {
2924
+ showMcpModal(name);
2925
+ }
2926
+
2927
+ async function viewMcpTools(name) {
2928
+ try {
2929
+ const res = await fetch(`/api/mcp/servers/${encodeURIComponent(name)}`);
2930
+ const data = await res.json();
2931
+ const tools = data.tools || [];
2932
+ document.getElementById('mcp-tools-title').textContent = `${name} 的工具列表`;
2933
+ const container = document.getElementById('mcp-tools-list');
2934
+ if (!tools.length) {
2935
+ container.innerHTML = '<div style="color:var(--text-muted);text-align:center;padding:20px">暂无工具</div>';
2936
+ } else {
2937
+ container.innerHTML = tools.map(t => `<div style="padding:10px;border:1px solid var(--border);border-radius:6px;margin-bottom:8px">
2938
+ <div style="font-weight:600;font-size:13px;margin-bottom:4px"><code>mcp__${escapeHtml(sanitizeName(name))}__${escapeHtml(t.name)}</code></div>
2939
+ <div style="font-size:12px;color:var(--text-muted)">${escapeHtml(t.description || '')}</div>
2940
+ </div>`).join('');
2941
+ }
2942
+ showModal('mcp-tools-modal');
2943
+ } catch (err) {
2944
+ showToast('加载工具失败: ' + err.message, true);
2945
+ }
2946
+ }
2947
+
2948
+ function closeMcpToolsModal() {
2949
+ hideModal('mcp-tools-modal');
2950
+ }
2951
+
2952
+ function sanitizeName(name) {
2953
+ return name.replace(/[^a-zA-Z0-9_-]/g, '_').replace(/-/g, '_');
2954
+ }
2955
+
2956
+ function updateMcpServerStatus(serverName, statusData) {
2957
+ const idx = mcpServersData.findIndex(s => s.name === serverName);
2958
+ if (idx >= 0) {
2959
+ Object.assign(mcpServersData[idx], statusData);
2960
+ if (currentPage === 'mcp-servers') renderMcpServers();
2961
+ }
2962
+ }
2963
+
2964
+ function escapeAttr(str) {
2965
+ return String(str).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/'/g, '&#39;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
2966
+ }
2967
+
2337
2968
  init();