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/public/app.js CHANGED
@@ -17,9 +17,20 @@ let currentPage = 'dashboard';
17
17
  let providerPoolItems = [];
18
18
  let providerModelTags = [];
19
19
  let providerKeys = [];
20
- let assistantMessages = [];
20
+ let assistantMessages = []; // 仅用于 UI 渲染
21
21
  let assistantProxyId = '';
22
+ let assistantProviderId = ''; // 用于级联选择的模型列表
23
+ let proxyProviders = []; // 当前代理的候选供应商列表
24
+ let savedAssistantProxyId = ''; // 从设置恢复的上次选择
25
+ let savedAssistantProviderId = '';
26
+ let savedAssistantModel = '';
22
27
  let assistantAbortController = null;
28
+ let assistantConversationId = '';
29
+ let contextTokens = 0;
30
+ let contextMaxTokens = 200000;
31
+ let contextPercent = 0;
32
+ let contextMessages = 0;
33
+ let assistantMaxRounds = 10;
23
34
 
24
35
  // ---------- Theme ----------
25
36
  const THEMES = [
@@ -61,6 +72,19 @@ function cycleTheme() {
61
72
  const res = await fetch('/api/settings');
62
73
  const settings = await res.json();
63
74
  applyTheme(settings.theme || localStorage.getItem('theme') || 'dark');
75
+ if (settings.maxContext) contextMaxTokens = parseInt(settings.maxContext) || 200000;
76
+ if (settings.maxRounds) assistantMaxRounds = Math.max(1, Math.min(100, parseInt(settings.maxRounds) || 10));
77
+ // 更新设置页输入框(此时 DOM 已加载)
78
+ const mcInput = document.getElementById('settings-max-conversations');
79
+ if (mcInput && settings.maxConversations !== undefined) mcInput.value = settings.maxConversations;
80
+ const mcxInput = document.getElementById('settings-max-context');
81
+ if (mcxInput) mcxInput.value = contextMaxTokens;
82
+ const mrInput = document.getElementById('settings-max-rounds');
83
+ if (mrInput) mrInput.value = assistantMaxRounds;
84
+ // 恢复助手选择
85
+ if (settings.assistantProxyId) savedAssistantProxyId = settings.assistantProxyId;
86
+ if (settings.assistantProviderId) savedAssistantProviderId = settings.assistantProviderId;
87
+ if (settings.assistantModel) savedAssistantModel = settings.assistantModel;
64
88
  } catch {
65
89
  applyTheme(localStorage.getItem('theme') || 'dark');
66
90
  }
@@ -84,6 +108,8 @@ function navigateTo(page) {
84
108
  'request-logs': '\u8bf7\u6c42\u65e5\u5fd7',
85
109
  'system-logs': '\u7cfb\u7edf\u65e5\u5fd7',
86
110
  assistant: '\u667a\u63a7\u52a9\u624b',
111
+ skills: '\u6280\u80fd\u7ba1\u7406',
112
+ 'mcp-servers': 'MCP \u670d\u52a1',
87
113
  settings: '\u8bbe\u7f6e',
88
114
  };
89
115
  document.getElementById('page-title').textContent = titles[page] || page;
@@ -95,7 +121,9 @@ function navigateTo(page) {
95
121
  if (page === 'stats') loadStats();
96
122
  if (page === 'system-logs') loadLogs();
97
123
  if (page === 'request-logs') renderRequestLogs();
98
- if (page === 'assistant') populateAssistantProxySelect();
124
+ if (page === 'assistant') { populateAssistantProxySelect(); loadConversations(); loadAssistantSkills(); }
125
+ if (page === 'skills') loadSkills();
126
+ if (page === 'mcp-servers') loadMcpServers();
99
127
  }
100
128
 
101
129
  document.querySelectorAll('.nav-item[data-page]').forEach(item => {
@@ -1105,6 +1133,11 @@ function connectRequestLogWS() {
1105
1133
  ws.onmessage = (e) => {
1106
1134
  try {
1107
1135
  const msg = JSON.parse(e.data);
1136
+ // MCP 状态更新
1137
+ if (msg.type === 'mcp_status') {
1138
+ updateMcpServerStatus(msg.server, msg);
1139
+ return;
1140
+ }
1108
1141
  const entry = msg.id ? msg : (msg.data || msg);
1109
1142
  if (entry && entry.id) {
1110
1143
  requestLogs.unshift(entry);
@@ -1555,6 +1588,12 @@ async function init() {
1555
1588
  document.getElementById('provider-azure-row').style.display = this.value === 'openai' ? 'grid' : 'none';
1556
1589
  });
1557
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
+
1558
1597
  await Promise.all([loadProxies(), loadProviders(), loadKeyHealth()]);
1559
1598
  loadStats();
1560
1599
  loadLogs();
@@ -1579,12 +1618,13 @@ async function init() {
1579
1618
  setInterval(loadStats, 30000);
1580
1619
  setInterval(loadKeyHealth, 5 * 60 * 1000);
1581
1620
 
1582
- // Assistant textarea auto-resize
1621
+ // Assistant textarea auto-resize + skill autocomplete
1583
1622
  const assistantInput = document.getElementById('assistant-input');
1584
1623
  if (assistantInput) {
1585
1624
  assistantInput.addEventListener('input', function() {
1586
1625
  this.style.height = 'auto';
1587
1626
  this.style.height = Math.min(this.scrollHeight, 120) + 'px';
1627
+ updateSkillAutocomplete(this.value);
1588
1628
  });
1589
1629
  assistantInput.addEventListener('keydown', function(e) {
1590
1630
  if (e.key === 'Enter' && !e.shiftKey) {
@@ -1593,6 +1633,13 @@ async function init() {
1593
1633
  }
1594
1634
  });
1595
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
+ });
1596
1643
  }
1597
1644
 
1598
1645
  async function loadRequestLogHistory() {
@@ -1616,56 +1663,22 @@ function populateAssistantProxySelect() {
1616
1663
  const current = select.value;
1617
1664
  select.innerHTML = '<option value="">选择后端代理...</option>' +
1618
1665
  running.map(p => `<option value="${escapeHtml(p.id)}">${escapeHtml(p.name)} (:${p.port})</option>`).join('');
1619
- if (current && running.find(p => p.id === current)) {
1620
- select.value = current;
1666
+ const preferredId = (current && running.find(p => p.id === current)) ? current
1667
+ : (savedAssistantProxyId && running.find(p => p.id === savedAssistantProxyId)) ? savedAssistantProxyId
1668
+ : running.length > 0 ? running[0].id : '';
1669
+ if (preferredId) {
1670
+ select.value = preferredId;
1671
+ assistantProxyId = preferredId;
1672
+ document.getElementById('assistant-send-btn').disabled = false;
1673
+ loadProxyProviders(preferredId);
1621
1674
  } else {
1622
1675
  assistantProxyId = '';
1623
1676
  document.getElementById('assistant-send-btn').disabled = true;
1624
1677
  }
1625
1678
  }
1626
1679
 
1627
- function buildSystemPrompt() {
1628
- const now = new Date().toLocaleString('zh-CN', { hour12: false });
1629
- return `你是 Protocol Proxy 的智能助手,专门帮助管理员监控和排障。当前时间:${now}
1630
-
1631
- 你有以下工具可以调用:
1632
-
1633
- 系统查询:
1634
- - get_system_status: 获取系统概览(代理运行状态、供应商数量、运行时长)
1635
- - get_providers / get_provider: 获取供应商列表或详情
1636
- - get_proxies / get_proxy: 获取代理列表或详情
1637
- - get_usage_stats: 查询用量统计(支持按时间范围、代理筛选)
1638
- - get_recent_requests: 获取最近请求日志
1639
- - get_system_logs: 获取系统日志
1640
- - get_key_health: 获取 API Key 健康检查结果
1641
- - get_settings: 获取系统设置项
1642
- - get_config_history: 获取配置快照历史
1643
-
1644
- 文件与命令:
1645
- - read_file: 读取任意文件内容(支持指定行范围)
1646
- - write_file: 写入文件(会覆盖已有内容)
1647
- - list_directory: 列出目录内容
1648
- - search_files: 按 glob 模式搜索文件
1649
- - execute_command: 执行 shell 命令
1650
-
1651
- 规则:
1652
- - 当用户询问系统状态、代理、供应商、日志、用量等运维相关问题时,调用工具获取实时数据后再回答
1653
- - 当用户需要查看或修改文件、执行命令时,使用对应的文件和命令工具
1654
- - 当用户只是打招呼、闲聊、或询问与系统无关的问题时,直接回答,不要调用工具
1655
- - 不要凭空猜测系统状态,需要数据时必须调用工具
1656
- - 执行写操作或危险命令前,先告知用户将要做什么
1657
-
1658
- 你的职责:
1659
- 1. 回答关于代理配置和运行状态的问题
1660
- 2. 分析日志,指出异常和可能原因
1661
- 3. 根据数据给出优化建议(负载均衡、模型选择、故障切换策略)
1662
- 4. 用自然语言解释技术问题
1663
- 5. 如果发现问题,给出具体的修复步骤
1664
-
1665
- 请用中文回答,保持专业且易懂。`;
1666
- }
1667
-
1668
1680
  async function sendAssistantMessage() {
1681
+ if (assistantAbortController) return; // 已有请求进行中,防连点
1669
1682
  const input = document.getElementById('assistant-input');
1670
1683
  const text = input.value.trim();
1671
1684
  if (!text || !assistantProxyId) return;
@@ -1674,29 +1687,6 @@ async function sendAssistantMessage() {
1674
1687
  input.value = '';
1675
1688
  input.style.height = 'auto';
1676
1689
 
1677
- const systemPrompt = buildSystemPrompt();
1678
-
1679
- // 构建消息历史,正确序列化 tool 相关字段
1680
- const messages = [
1681
- { role: 'system', content: systemPrompt },
1682
- ...assistantMessages.filter(m => m.role !== 'thinking' && m.role !== 'tool-calls' && m.role !== 'tool-result').map(m => {
1683
- if (m.role === 'assistant' && m.tool_calls) {
1684
- const msg = { role: 'assistant', content: m.content || null, tool_calls: m.tool_calls };
1685
- if (m.reasoning_content) msg.reasoning_content = m.reasoning_content;
1686
- return msg;
1687
- }
1688
- if (m.role === 'assistant' && m.reasoning_content) {
1689
- const msg = { role: 'assistant', content: m.content };
1690
- msg.reasoning_content = m.reasoning_content;
1691
- return msg;
1692
- }
1693
- if (m.role === 'tool') {
1694
- return { role: 'tool', tool_call_id: m.tool_call_id, content: m.content };
1695
- }
1696
- return { role: m.role, content: m.content };
1697
- })
1698
- ];
1699
-
1700
1690
  const proxy = proxies.find(p => p.id === assistantProxyId);
1701
1691
  if (!proxy) {
1702
1692
  addAssistantMessage('assistant', '所选代理不存在或已停止,请重新选择。');
@@ -1704,13 +1694,20 @@ async function sendAssistantMessage() {
1704
1694
  }
1705
1695
 
1706
1696
  const thinkingId = addAssistantMessage('thinking', '');
1707
- assistantAbortController = new AbortController();
1697
+ const myController = new AbortController();
1698
+ assistantAbortController = myController;
1708
1699
 
1709
1700
  try {
1701
+ const providerVal = document.getElementById('assistant-provider-select')?.value || '';
1702
+ const modelVal = document.getElementById('assistant-model-select')?.value || '';
1710
1703
  const res = await fetch('/api/assistant/chat', {
1711
1704
  method: 'POST',
1712
1705
  headers: { 'Content-Type': 'application/json' },
1713
- body: JSON.stringify({ proxyId: proxy.id, messages }),
1706
+ body: JSON.stringify({
1707
+ proxyId: proxy.id, conversationId: assistantConversationId, message: text,
1708
+ ...(providerVal && { providerId: providerVal }),
1709
+ ...(modelVal && { model: modelVal }),
1710
+ }),
1714
1711
  signal: assistantAbortController.signal,
1715
1712
  });
1716
1713
 
@@ -1727,9 +1724,7 @@ async function sendAssistantMessage() {
1727
1724
  let fullContent = '';
1728
1725
  let currentEvent = '';
1729
1726
  let msgId = null;
1730
-
1731
- removeAssistantMessage(thinkingId);
1732
- console.log('[assistant] SSE stream started');
1727
+ let thinkingRemoved = false;
1733
1728
 
1734
1729
  while (true) {
1735
1730
  const { done, value } = await reader.read();
@@ -1752,33 +1747,22 @@ async function sendAssistantMessage() {
1752
1747
  let data;
1753
1748
  try { data = JSON.parse(trimmed.slice(6)); } catch { continue; }
1754
1749
 
1755
- console.log('[assistant] SSE event:', currentEvent, data);
1756
1750
  switch (currentEvent) {
1757
1751
  case 'content': {
1758
- if (!msgId) msgId = addAssistantMessage('assistant', '');
1752
+ if (!thinkingRemoved) { removeAssistantMessage(thinkingId); thinkingRemoved = true; }
1753
+ if (!msgId) {
1754
+ msgId = addAssistantMessage('assistant', '');
1755
+
1756
+ }
1759
1757
  fullContent += data.delta;
1760
1758
  updateAssistantMessage(msgId, fullContent);
1761
1759
  break;
1762
1760
  }
1763
1761
 
1764
1762
  case 'tool_calls': {
1763
+ if (!thinkingRemoved) { removeAssistantMessage(thinkingId); thinkingRemoved = true; }
1765
1764
  const calls = data.calls || [];
1766
- const toolCallsData = calls.map(tc => ({
1767
- id: tc.id,
1768
- type: 'function',
1769
- function: { name: tc.name, arguments: JSON.stringify(tc.arguments || {}) },
1770
- }));
1771
- // 存储 assistant 消息(含 tool_calls)用于对话序列化
1772
- const assistantMsg = {
1773
- id: 'msg-' + Date.now() + '-' + Math.random().toString(36).slice(2),
1774
- role: 'assistant',
1775
- content: fullContent || null,
1776
- tool_calls: toolCallsData,
1777
- };
1778
- if (data.reasoning_content) assistantMsg.reasoning_content = data.reasoning_content;
1779
- assistantMessages.push(assistantMsg);
1780
1765
  fullContent = '';
1781
- // 创建显示用的 tool-calls 消息
1782
1766
  const callHtml = calls.map(tc => {
1783
1767
  const argsStr = Object.keys(tc.arguments || {}).length > 0
1784
1768
  ? `<span class="tool-call-args">${escapeHtml(JSON.stringify(tc.arguments))}</span>`
@@ -1791,37 +1775,62 @@ async function sendAssistantMessage() {
1791
1775
 
1792
1776
  case 'tool_result': {
1793
1777
  const resultStr = JSON.stringify(data.result, null, 2);
1794
- addAssistantMessage('tool-result', { name: data.name, result: resultStr, tool_call_id: data.tool_call_id });
1795
- // 追加 role: 'tool' 消息用于对话序列化(不创建 DOM 元素)
1796
- assistantMessages.push({
1797
- id: 'msg-' + Date.now() + '-' + Math.random().toString(36).slice(2),
1798
- role: 'tool',
1799
- tool_call_id: data.tool_call_id,
1800
- tool_name: data.name,
1801
- content: JSON.stringify(data.result),
1802
- });
1778
+ addAssistantMessage('tool-result', { name: data.name, result: resultStr, tool_call_id: data.tool_call_id, is_error: data.is_error });
1803
1779
  break;
1804
1780
  }
1805
1781
 
1806
1782
  case 'done': {
1783
+ if (!thinkingRemoved) { removeAssistantMessage(thinkingId); thinkingRemoved = true; }
1807
1784
  if (msgId) {
1808
1785
  const msgObj = assistantMessages.find(m => m.id === msgId);
1809
1786
  if (msgObj) {
1787
+ // 将 reasoning_content 包装为 <think> 标签以便统一渲染
1788
+ if (data.reasoning_content && !fullContent.includes('<think>')) {
1789
+ fullContent = `<think>${data.reasoning_content}</think>` + fullContent;
1790
+ }
1810
1791
  msgObj.content = fullContent;
1811
- if (data.reasoning_content) msgObj.reasoning_content = data.reasoning_content;
1792
+ updateAssistantMessage(msgId, fullContent);
1812
1793
  }
1813
1794
  }
1814
1795
  break;
1815
1796
  }
1816
1797
 
1817
1798
  case 'error': {
1799
+ if (!thinkingRemoved) { removeAssistantMessage(thinkingId); thinkingRemoved = true; }
1818
1800
  addAssistantMessage('assistant', `错误: ${data.message}`);
1819
1801
  break;
1820
1802
  }
1803
+
1804
+ case 'context': {
1805
+ contextTokens = data.tokens;
1806
+ contextMaxTokens = data.maxTokens || contextMaxTokens;
1807
+ contextPercent = data.percent;
1808
+ contextMessages = data.messages;
1809
+ updateContextBar();
1810
+ break;
1811
+ }
1812
+
1813
+ case 'compressed': {
1814
+ if (data.summary) applyCompression(data);
1815
+ break;
1816
+ }
1817
+
1818
+ case 'compressing': {
1819
+ break;
1820
+ }
1821
+
1822
+ case 'conversation': {
1823
+ assistantConversationId = data.id;
1824
+ loadConversations();
1825
+ break;
1826
+ }
1821
1827
  }
1822
1828
  }
1823
1829
  }
1824
1830
 
1831
+ // 流结束时确保 thinking 点被移除
1832
+ if (!thinkingRemoved) removeAssistantMessage(thinkingId);
1833
+
1825
1834
  // 流结束但没有收到 done 事件时,确保最终内容被保存
1826
1835
  if (msgId && fullContent) {
1827
1836
  const msgObj = assistantMessages.find(m => m.id === msgId);
@@ -1836,7 +1845,7 @@ async function sendAssistantMessage() {
1836
1845
  addAssistantMessage('assistant', `请求出错: ${err.message}`);
1837
1846
  }
1838
1847
  } finally {
1839
- assistantAbortController = null;
1848
+ if (assistantAbortController === myController) assistantAbortController = null;
1840
1849
  }
1841
1850
  }
1842
1851
 
@@ -1862,13 +1871,14 @@ function addAssistantMessage(role, content) {
1862
1871
  // content 已经是 HTML 字符串
1863
1872
  div.innerHTML = `<div class="tool-calls-header">调用工具</div>${content}`;
1864
1873
  } else if (role === 'tool-result') {
1865
- // content 是 {name, result, tool_call_id}
1874
+ // content 是 {name, result, tool_call_id, is_error}
1866
1875
  const toolData = typeof content === 'object' ? content : { name: 'unknown', result: content };
1867
1876
  const resultId = 'result-' + id;
1877
+ if (toolData.is_error) div.classList.add('tool-error');
1868
1878
  div.innerHTML = `
1869
1879
  <div class="tool-result-header" onclick="document.getElementById('${resultId}').classList.toggle('expanded')">
1870
- <span class="tool-result-name">${escapeHtml(toolData.name)}</span>
1871
- <span class="tool-result-toggle">展开结果 ▾</span>
1880
+ <span class="tool-result-name">${toolData.is_error ? '⚠ ' : ''}${escapeHtml(toolData.name)}</span>
1881
+ <span class="tool-result-toggle">${toolData.is_error ? '错误详情 ▾' : '展开结果 ▾'}</span>
1872
1882
  </div>
1873
1883
  <div class="tool-result-body" id="${resultId}">
1874
1884
  <pre>${escapeHtml(toolData.result)}</pre>
@@ -1908,7 +1918,19 @@ function removeAssistantMessage(id) {
1908
1918
 
1909
1919
  function formatAssistantContent(text) {
1910
1920
  if (!text) return '';
1911
- let html = escapeHtml(text);
1921
+ // <think>...</think> 渲染为可折叠的思考块(在 escapeHtml 之前提取)
1922
+ const thinkBlocks = [];
1923
+ let processed = text.replace(/<think>([\s\S]*?)<\/think>/g, (_, think) => {
1924
+ const idx = thinkBlocks.length;
1925
+ thinkBlocks.push(think.trim());
1926
+ return `\x00THINK_${idx}\x00`;
1927
+ });
1928
+ let html = escapeHtml(processed);
1929
+ // 还原思考块为 HTML
1930
+ html = html.replace(/\x00THINK_(\d+)\x00/g, (_, idx) => {
1931
+ const thinkId = 'think-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6);
1932
+ return `<details class="think-block"><summary>思考过程</summary><div class="think-content" id="${thinkId}">${escapeHtml(thinkBlocks[parseInt(idx)])}</div></details>`;
1933
+ });
1912
1934
  // 代码块
1913
1935
  html = html.replace(/```([\s\S]*?)```/g, '<pre><code>$1</code></pre>');
1914
1936
  // 行内代码
@@ -1937,6 +1959,17 @@ function formatAssistantContent(text) {
1937
1959
 
1938
1960
  function clearAssistantChat() {
1939
1961
  assistantMessages = [];
1962
+ assistantConversationId = '';
1963
+ assistantProviderId = '';
1964
+ const trigger = document.getElementById('conversation-dropdown-trigger');
1965
+ if (trigger) trigger.textContent = '新会话';
1966
+ proxyProviders = [];
1967
+ populateProviderSelect();
1968
+ populateModelSelect();
1969
+ contextTokens = 0;
1970
+ contextPercent = 0;
1971
+ contextMessages = 0;
1972
+ updateContextBar();
1940
1973
  const chat = document.getElementById('assistant-chat');
1941
1974
  if (!chat) return;
1942
1975
  chat.innerHTML = `
@@ -1957,6 +1990,301 @@ function clearAssistantChat() {
1957
1990
  `;
1958
1991
  }
1959
1992
 
1993
+ function formatTokenCount(n) {
1994
+ if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
1995
+ if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
1996
+ return String(n);
1997
+ }
1998
+
1999
+ function updateContextBar() {
2000
+ const bar = document.getElementById('assistant-context-bar');
2001
+ if (!bar) return;
2002
+ if (contextMessages === 0) {
2003
+ bar.style.display = 'none';
2004
+ return;
2005
+ }
2006
+ bar.style.display = 'flex';
2007
+
2008
+ document.getElementById('context-bar-tokens').textContent = formatTokenCount(contextTokens);
2009
+ document.getElementById('context-bar-max').textContent = formatTokenCount(contextMaxTokens);
2010
+ document.getElementById('context-bar-percent').textContent = contextPercent.toFixed(1);
2011
+ document.getElementById('context-bar-messages').textContent = contextMessages;
2012
+
2013
+ const fill = document.getElementById('context-bar-fill');
2014
+ const pct = Math.min(100, contextPercent);
2015
+ fill.style.width = pct + '%';
2016
+ fill.className = 'context-bar-fill' + (pct >= 80 ? ' high' : pct >= 50 ? ' mid' : '');
2017
+
2018
+ const compressBtn = document.getElementById('context-compress-btn');
2019
+ if (compressBtn) {
2020
+ compressBtn.style.display = pct >= 50 ? '' : 'none';
2021
+ }
2022
+ }
2023
+
2024
+ function updateMaxContext(value) {
2025
+ const v = Math.max(10000, parseInt(value) || 200000);
2026
+ contextMaxTokens = v;
2027
+ fetch('/api/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ maxContext: v }) }).catch(() => {});
2028
+ if (contextTokens > 0) {
2029
+ contextPercent = Math.round(contextTokens / contextMaxTokens * 1000) / 10;
2030
+ updateContextBar();
2031
+ }
2032
+ }
2033
+
2034
+ function updateMaxRounds(value) {
2035
+ const v = Math.max(1, Math.min(100, parseInt(value) || 10));
2036
+ assistantMaxRounds = v;
2037
+ fetch('/api/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ maxRounds: v }) }).catch(() => {});
2038
+ }
2039
+
2040
+ function updateMaxConversations(value) {
2041
+ const v = Math.max(0, parseInt(value) || 0);
2042
+ fetch('/api/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ maxConversations: v }) }).catch(() => {});
2043
+ }
2044
+
2045
+ function toggleConversationDropdown() {
2046
+ const menu = document.getElementById('conversation-dropdown-menu');
2047
+ if (!menu) return;
2048
+ menu.classList.toggle('open');
2049
+ }
2050
+
2051
+ function updateConversationTriggerLabel() {
2052
+ const trigger = document.getElementById('conversation-dropdown-trigger');
2053
+ if (!trigger) return;
2054
+ if (assistantConversationId) {
2055
+ const item = document.querySelector(`.conversation-dropdown-item[data-id="${assistantConversationId}"] .conversation-dropdown-item-label`);
2056
+ trigger.textContent = item ? item.textContent : assistantConversationId;
2057
+ } else {
2058
+ trigger.textContent = '新会话';
2059
+ }
2060
+ }
2061
+
2062
+ async function deleteConversationById(convId, event) {
2063
+ event.stopPropagation();
2064
+ const ok = await showConfirm('确定删除该会话?');
2065
+ if (!ok) return;
2066
+ try {
2067
+ await fetch(`/api/assistant/conversations/${convId}`, { method: 'DELETE' });
2068
+ if (assistantConversationId === convId) clearAssistantChat();
2069
+ await loadConversations();
2070
+ showToast('会话已删除');
2071
+ } catch (err) {
2072
+ showToast('删除失败: ' + err.message, true);
2073
+ }
2074
+ }
2075
+
2076
+ async function loadConversations() {
2077
+ try {
2078
+ const res = await fetch('/api/assistant/conversations');
2079
+ const data = await res.json();
2080
+ const menu = document.getElementById('conversation-dropdown-menu');
2081
+ if (!menu) return;
2082
+ const sorted = (data.conversations || []).slice().reverse();
2083
+ menu.innerHTML = '';
2084
+ for (const c of sorted) {
2085
+ const date = new Date(c.lastActivity).toLocaleString('zh-CN', { hour12: false, month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
2086
+ const label = (c.preview || '空会话').slice(0, 30) + ' — ' + date;
2087
+ const item = document.createElement('div');
2088
+ item.className = 'conversation-dropdown-item' + (c.id === assistantConversationId ? ' active' : '');
2089
+ item.dataset.id = c.id;
2090
+ item.innerHTML = `<span class="conversation-dropdown-item-label">${escapeHtml(label)}</span><button class="conversation-dropdown-item-delete" title="删除">×</button>`;
2091
+ item.querySelector('.conversation-dropdown-item-label').onclick = () => {
2092
+ menu.classList.remove('open');
2093
+ document.getElementById('conversation-dropdown-trigger').textContent = label;
2094
+ switchConversation(c.id);
2095
+ };
2096
+ item.querySelector('.conversation-dropdown-item-delete').onclick = (e) => deleteConversationById(c.id, e);
2097
+ menu.appendChild(item);
2098
+ }
2099
+ updateConversationTriggerLabel();
2100
+ } catch (err) {
2101
+ showToast('加载会话列表失败: ' + err.message, true);
2102
+ }
2103
+ }
2104
+
2105
+ async function switchConversation(convId) {
2106
+ // 中断进行中的请求,避免旧流的 DOM 更新与新会话冲突
2107
+ if (assistantAbortController) {
2108
+ assistantAbortController.abort();
2109
+ assistantAbortController = null;
2110
+ }
2111
+ if (!convId) {
2112
+ // 选择"新会话"
2113
+ clearAssistantChat();
2114
+ return;
2115
+ }
2116
+ assistantConversationId = convId;
2117
+ assistantMessages = [];
2118
+ const chat = document.getElementById('assistant-chat');
2119
+ if (chat) chat.innerHTML = '';
2120
+
2121
+ // 重置上下文栏,避免显示前一个会话的 token 统计
2122
+ contextTokens = 0;
2123
+ contextPercent = 0;
2124
+ contextMessages = 0;
2125
+ updateContextBar();
2126
+
2127
+ try {
2128
+ const res = await fetch(`/api/assistant/conversations/${convId}/messages`);
2129
+ const data = await res.json();
2130
+ if (data.proxyId) {
2131
+ // 自动选中对应的代理
2132
+ const select = document.getElementById('assistant-proxy-select');
2133
+ if (select && select.querySelector(`option[value="${data.proxyId}"]`)) {
2134
+ select.value = data.proxyId;
2135
+ assistantProxyId = data.proxyId;
2136
+ document.getElementById('assistant-send-btn').disabled = false;
2137
+ }
2138
+ loadProxyProviders(data.proxyId);
2139
+ }
2140
+ // 渲染压缩摘要(如果有)
2141
+ if (data.compressionSummary) {
2142
+ const chatEl = document.getElementById('assistant-chat');
2143
+ if (chatEl) {
2144
+ const details = document.createElement('details');
2145
+ details.className = 'compression-summary';
2146
+ details.innerHTML = `<summary>之前的对话已被压缩</summary><div class="compression-summary-content">${escapeHtml(data.compressionSummary)}</div>`;
2147
+ chatEl.appendChild(details);
2148
+ }
2149
+ }
2150
+ // 渲染历史消息
2151
+ for (const m of (data.messages || [])) {
2152
+ if (m.role === 'user') {
2153
+ addAssistantMessage('user', m.content);
2154
+ } else if (m.role === 'assistant') {
2155
+ addAssistantMessage('assistant', m.content || '');
2156
+ }
2157
+ }
2158
+ } catch (err) {
2159
+ showToast('加载会话失败: ' + err.message, true);
2160
+ }
2161
+ }
2162
+
2163
+
2164
+ async function compressAssistantContext() {
2165
+ if (!assistantProxyId || !assistantConversationId) return;
2166
+ const btn = document.getElementById('context-compress-btn');
2167
+ if (btn) { btn.disabled = true; btn.textContent = '压缩中...'; }
2168
+ try {
2169
+ const res = await fetch('/api/assistant/chat', {
2170
+ method: 'POST',
2171
+ headers: { 'Content-Type': 'application/json' },
2172
+ body: JSON.stringify({
2173
+ proxyId: assistantProxyId, conversationId: assistantConversationId, message: '', compress: true,
2174
+ ...(document.getElementById('assistant-provider-select')?.value && { providerId: document.getElementById('assistant-provider-select').value }),
2175
+ ...(document.getElementById('assistant-model-select')?.value && { model: document.getElementById('assistant-model-select').value }),
2176
+ }),
2177
+ });
2178
+ const reader = res.body.getReader();
2179
+ const decoder = new TextDecoder();
2180
+ let buffer = '';
2181
+ let currentEvent = '';
2182
+ while (true) {
2183
+ const { done, value } = await reader.read();
2184
+ if (done) break;
2185
+ buffer += decoder.decode(value, { stream: true });
2186
+ const lines = buffer.split('\n');
2187
+ buffer = lines.pop();
2188
+ for (const line of lines) {
2189
+ const trimmed = line.trim();
2190
+ if (!trimmed) continue;
2191
+ if (trimmed.startsWith('event: ')) { currentEvent = trimmed.slice(7); continue; }
2192
+ if (!trimmed.startsWith('data: ')) continue;
2193
+ let data;
2194
+ try { data = JSON.parse(trimmed.slice(6)); } catch { continue; }
2195
+ if (currentEvent === 'compressed' && data.summary) applyCompression(data);
2196
+ if (currentEvent === 'context') {
2197
+ contextTokens = data.tokens;
2198
+ contextMaxTokens = data.maxTokens;
2199
+ contextPercent = data.percent;
2200
+ contextMessages = data.messages;
2201
+ updateContextBar();
2202
+ }
2203
+ }
2204
+ }
2205
+ } catch (err) {
2206
+ showToast('压缩失败: ' + err.message, true);
2207
+ } finally {
2208
+ if (btn) { btn.disabled = false; btn.textContent = '压缩'; }
2209
+ }
2210
+ }
2211
+
2212
+ function applyCompression(data) {
2213
+ // 后端已完成压缩,前端只更新显示
2214
+ if (data.tokens != null) contextTokens = data.tokens;
2215
+ if (data.maxTokens) contextMaxTokens = data.maxTokens;
2216
+ contextPercent = Math.round(contextTokens / contextMaxTokens * 1000) / 10;
2217
+ contextMessages = data.messages || 0;
2218
+ updateContextBar();
2219
+ if (data.summary) {
2220
+ showToast(`已压缩 ${data.removedCount} 条消息,摘要已保存`);
2221
+ }
2222
+ }
2223
+
2224
+ // 加载代理的候选供应商列表
2225
+ async function loadProxyProviders(proxyId) {
2226
+ assistantProviderId = '';
2227
+ proxyProviders = [];
2228
+ populateProviderSelect();
2229
+ populateModelSelect();
2230
+ if (!proxyId) return;
2231
+ try {
2232
+ const res = await fetch(`/api/assistant/proxy-providers/${proxyId}`);
2233
+ const data = await res.json();
2234
+ proxyProviders = data.providers || [];
2235
+ // 恢复保存的供应商选择
2236
+ if (savedAssistantProviderId && proxyProviders.find(p => p.id === savedAssistantProviderId)) {
2237
+ assistantProviderId = savedAssistantProviderId;
2238
+ savedAssistantProviderId = ''; // 只恢复一次
2239
+ }
2240
+ populateProviderSelect();
2241
+ populateModelSelect();
2242
+ // 恢复保存的模型选择
2243
+ if (savedAssistantModel) {
2244
+ const modelSelect = document.getElementById('assistant-model-select');
2245
+ if (modelSelect && modelSelect.querySelector(`option[value="${savedAssistantModel}"]`)) {
2246
+ modelSelect.value = savedAssistantModel;
2247
+ }
2248
+ savedAssistantModel = ''; // 只恢复一次
2249
+ }
2250
+ } catch (err) {
2251
+ console.warn('[assistant] 加载供应商列表失败:', err.message);
2252
+ }
2253
+ }
2254
+
2255
+ function populateProviderSelect() {
2256
+ const select = document.getElementById('assistant-provider-select');
2257
+ if (!select) return;
2258
+ select.innerHTML = '<option value="">自动(跟随代理)</option>' +
2259
+ proxyProviders.map(p => `<option value="${escapeHtml(p.id)}">${escapeHtml(p.name)} (${escapeHtml(p.protocol)})</option>`).join('');
2260
+ select.value = assistantProviderId;
2261
+ }
2262
+
2263
+ function populateModelSelect() {
2264
+ const select = document.getElementById('assistant-model-select');
2265
+ if (!select) return;
2266
+ const provider = proxyProviders.find(p => p.id === assistantProviderId);
2267
+ const models = provider?.models || [];
2268
+ select.innerHTML = '<option value="">自动(跟随代理)</option>' +
2269
+ models.map(m => `<option value="${escapeHtml(m)}">${escapeHtml(m)}</option>`).join('');
2270
+ }
2271
+
2272
+ // eslint-disable-next-line no-unused-vars
2273
+ function onAssistantProviderChange(value) {
2274
+ assistantProviderId = value;
2275
+ populateModelSelect();
2276
+ saveAssistantSelection();
2277
+ }
2278
+
2279
+ function saveAssistantSelection() {
2280
+ const modelVal = document.getElementById('assistant-model-select')?.value || '';
2281
+ fetch('/api/settings', {
2282
+ method: 'PUT',
2283
+ headers: { 'Content-Type': 'application/json' },
2284
+ body: JSON.stringify({ assistantProxyId, assistantProviderId, assistantModel: modelVal }),
2285
+ }).catch(() => {});
2286
+ }
2287
+
1960
2288
  // 监听代理选择
1961
2289
  (function() {
1962
2290
  const select = document.getElementById('assistant-proxy-select');
@@ -1965,8 +2293,676 @@ function clearAssistantChat() {
1965
2293
  assistantProxyId = this.value;
1966
2294
  const btn = document.getElementById('assistant-send-btn');
1967
2295
  if (btn) btn.disabled = !this.value;
2296
+ loadProxyProviders(this.value);
2297
+ saveAssistantSelection();
2298
+ });
2299
+ }
2300
+ // 监听模型选择
2301
+ const modelSelect = document.getElementById('assistant-model-select');
2302
+ if (modelSelect) {
2303
+ modelSelect.addEventListener('change', function() {
2304
+ saveAssistantSelection();
1968
2305
  });
1969
2306
  }
1970
2307
  })();
1971
2308
 
2309
+ // 点击外部关闭会话下拉
2310
+ document.addEventListener('click', (e) => {
2311
+ const dropdown = document.getElementById('conversation-dropdown');
2312
+ if (dropdown && !dropdown.contains(e.target)) {
2313
+ document.getElementById('conversation-dropdown-menu')?.classList.remove('open');
2314
+ }
2315
+ });
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
+
1972
2968
  init();