protocol-proxy 2.8.3 → 2.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/config-store.js +45 -2
- package/lib/conversation-store.js +108 -0
- package/lib/mcp-client.js +423 -0
- package/lib/prompt-builder.js +94 -0
- package/lib/proxy-server.js +44 -5
- package/lib/skill-store.js +150 -0
- package/package.json +2 -1
- package/public/app.js +1102 -106
- package/public/index.html +250 -8
- package/public/style.css +458 -2
- package/server.js +1774 -191
package/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
|
-
|
|
1620
|
-
|
|
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
|
-
|
|
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({
|
|
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 (!
|
|
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
|
-
|
|
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"
|
|
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
|
-
|
|
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">▶</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()">×</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()">×</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, '&').replace(/"/g, '"').replace(/'/g, ''').replace(/</g, '<').replace(/>/g, '>');
|
|
2966
|
+
}
|
|
2967
|
+
|
|
1972
2968
|
init();
|