protocol-proxy 2.8.2 → 2.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/conversation-store.js +108 -0
- package/lib/converters/openai-to-anthropic.js +4 -0
- package/lib/proxy-server.js +46 -5
- package/package.json +1 -1
- package/public/app.js +747 -0
- package/public/index.html +103 -0
- package/public/style.css +514 -0
- package/server.js +2327 -1226
package/public/app.js
CHANGED
|
@@ -17,6 +17,20 @@ let currentPage = 'dashboard';
|
|
|
17
17
|
let providerPoolItems = [];
|
|
18
18
|
let providerModelTags = [];
|
|
19
19
|
let providerKeys = [];
|
|
20
|
+
let assistantMessages = []; // 仅用于 UI 渲染
|
|
21
|
+
let assistantProxyId = '';
|
|
22
|
+
let assistantProviderId = ''; // 用于级联选择的模型列表
|
|
23
|
+
let proxyProviders = []; // 当前代理的候选供应商列表
|
|
24
|
+
let savedAssistantProxyId = ''; // 从设置恢复的上次选择
|
|
25
|
+
let savedAssistantProviderId = '';
|
|
26
|
+
let savedAssistantModel = '';
|
|
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;
|
|
20
34
|
|
|
21
35
|
// ---------- Theme ----------
|
|
22
36
|
const THEMES = [
|
|
@@ -58,6 +72,19 @@ function cycleTheme() {
|
|
|
58
72
|
const res = await fetch('/api/settings');
|
|
59
73
|
const settings = await res.json();
|
|
60
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;
|
|
61
88
|
} catch {
|
|
62
89
|
applyTheme(localStorage.getItem('theme') || 'dark');
|
|
63
90
|
}
|
|
@@ -80,6 +107,7 @@ function navigateTo(page) {
|
|
|
80
107
|
stats: '\u7528\u91cf\u7edf\u8ba1',
|
|
81
108
|
'request-logs': '\u8bf7\u6c42\u65e5\u5fd7',
|
|
82
109
|
'system-logs': '\u7cfb\u7edf\u65e5\u5fd7',
|
|
110
|
+
assistant: '\u667a\u63a7\u52a9\u624b',
|
|
83
111
|
settings: '\u8bbe\u7f6e',
|
|
84
112
|
};
|
|
85
113
|
document.getElementById('page-title').textContent = titles[page] || page;
|
|
@@ -91,6 +119,7 @@ function navigateTo(page) {
|
|
|
91
119
|
if (page === 'stats') loadStats();
|
|
92
120
|
if (page === 'system-logs') loadLogs();
|
|
93
121
|
if (page === 'request-logs') renderRequestLogs();
|
|
122
|
+
if (page === 'assistant') { populateAssistantProxySelect(); loadConversations(); }
|
|
94
123
|
}
|
|
95
124
|
|
|
96
125
|
document.querySelectorAll('.nav-item[data-page]').forEach(item => {
|
|
@@ -1573,6 +1602,21 @@ async function init() {
|
|
|
1573
1602
|
// Auto-refresh
|
|
1574
1603
|
setInterval(loadStats, 30000);
|
|
1575
1604
|
setInterval(loadKeyHealth, 5 * 60 * 1000);
|
|
1605
|
+
|
|
1606
|
+
// Assistant textarea auto-resize
|
|
1607
|
+
const assistantInput = document.getElementById('assistant-input');
|
|
1608
|
+
if (assistantInput) {
|
|
1609
|
+
assistantInput.addEventListener('input', function() {
|
|
1610
|
+
this.style.height = 'auto';
|
|
1611
|
+
this.style.height = Math.min(this.scrollHeight, 120) + 'px';
|
|
1612
|
+
});
|
|
1613
|
+
assistantInput.addEventListener('keydown', function(e) {
|
|
1614
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
1615
|
+
e.preventDefault();
|
|
1616
|
+
sendAssistantMessage();
|
|
1617
|
+
}
|
|
1618
|
+
});
|
|
1619
|
+
}
|
|
1576
1620
|
}
|
|
1577
1621
|
|
|
1578
1622
|
async function loadRequestLogHistory() {
|
|
@@ -1587,4 +1631,707 @@ async function loadRequestLogHistory() {
|
|
|
1587
1631
|
}
|
|
1588
1632
|
}
|
|
1589
1633
|
|
|
1634
|
+
// ---------- Assistant ----------
|
|
1635
|
+
|
|
1636
|
+
function populateAssistantProxySelect() {
|
|
1637
|
+
const select = document.getElementById('assistant-proxy-select');
|
|
1638
|
+
if (!select) return;
|
|
1639
|
+
const running = proxies.filter(p => p.running);
|
|
1640
|
+
const current = select.value;
|
|
1641
|
+
select.innerHTML = '<option value="">选择后端代理...</option>' +
|
|
1642
|
+
running.map(p => `<option value="${escapeHtml(p.id)}">${escapeHtml(p.name)} (:${p.port})</option>`).join('');
|
|
1643
|
+
const preferredId = (current && running.find(p => p.id === current)) ? current
|
|
1644
|
+
: (savedAssistantProxyId && running.find(p => p.id === savedAssistantProxyId)) ? savedAssistantProxyId
|
|
1645
|
+
: running.length > 0 ? running[0].id : '';
|
|
1646
|
+
if (preferredId) {
|
|
1647
|
+
select.value = preferredId;
|
|
1648
|
+
assistantProxyId = preferredId;
|
|
1649
|
+
document.getElementById('assistant-send-btn').disabled = false;
|
|
1650
|
+
loadProxyProviders(preferredId);
|
|
1651
|
+
} else {
|
|
1652
|
+
assistantProxyId = '';
|
|
1653
|
+
document.getElementById('assistant-send-btn').disabled = true;
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
function buildSystemPrompt() {
|
|
1658
|
+
const now = new Date().toLocaleString('zh-CN', { hour12: false });
|
|
1659
|
+
return `你是 Protocol Proxy 的智能助手,专门帮助管理员监控和排障。当前时间:${now}
|
|
1660
|
+
|
|
1661
|
+
你有以下工具可以调用:
|
|
1662
|
+
|
|
1663
|
+
系统查询:
|
|
1664
|
+
- get_system_status: 获取系统概览(代理运行状态、供应商数量、运行时长)
|
|
1665
|
+
- get_providers / get_provider: 获取供应商列表或详情
|
|
1666
|
+
- get_proxies / get_proxy: 获取代理列表或详情
|
|
1667
|
+
- get_usage_stats: 查询用量统计(支持按时间范围、代理筛选)
|
|
1668
|
+
- get_recent_requests: 获取最近请求日志
|
|
1669
|
+
- get_system_logs: 获取系统日志
|
|
1670
|
+
- get_key_health: 获取 API Key 健康检查结果
|
|
1671
|
+
- get_settings: 获取系统设置项
|
|
1672
|
+
- get_config_history: 获取配置快照历史
|
|
1673
|
+
|
|
1674
|
+
文件与命令:
|
|
1675
|
+
- read_file: 读取任意文件内容(支持指定行范围,自动检测二进制文件)
|
|
1676
|
+
- write_file: 写入文件(会覆盖已有内容)
|
|
1677
|
+
- edit_file: 精确替换文件中的字符串(比 write_file 更安全,只替换匹配内容)
|
|
1678
|
+
- list_directory: 列出目录内容
|
|
1679
|
+
- search_files: 按文件名 glob 模式搜索文件
|
|
1680
|
+
- grep_search: 按正则表达式搜索文件内容(用于查找代码、日志关键字等)
|
|
1681
|
+
- execute_command: 执行 shell 命令
|
|
1682
|
+
|
|
1683
|
+
规则:
|
|
1684
|
+
- 当用户询问系统状态、代理、供应商、日志、用量等运维相关问题时,调用工具获取实时数据后再回答
|
|
1685
|
+
- 当用户需要查看或修改文件、执行命令时,使用对应的文件和命令工具
|
|
1686
|
+
- 当用户只是打招呼、闲聊、或询问与系统无关的问题时,直接回答,不要调用工具
|
|
1687
|
+
- 不要凭空猜测系统状态,需要数据时必须调用工具
|
|
1688
|
+
- 执行写操作或危险命令前,先告知用户将要做什么
|
|
1689
|
+
|
|
1690
|
+
你的职责:
|
|
1691
|
+
1. 回答关于代理配置和运行状态的问题
|
|
1692
|
+
2. 分析日志,指出异常和可能原因
|
|
1693
|
+
3. 根据数据给出优化建议(负载均衡、模型选择、故障切换策略)
|
|
1694
|
+
4. 用自然语言解释技术问题
|
|
1695
|
+
5. 如果发现问题,给出具体的修复步骤
|
|
1696
|
+
|
|
1697
|
+
请用中文回答,保持专业且易懂。`;
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
async function sendAssistantMessage() {
|
|
1701
|
+
if (assistantAbortController) return; // 已有请求进行中,防连点
|
|
1702
|
+
const input = document.getElementById('assistant-input');
|
|
1703
|
+
const text = input.value.trim();
|
|
1704
|
+
if (!text || !assistantProxyId) return;
|
|
1705
|
+
|
|
1706
|
+
addAssistantMessage('user', text);
|
|
1707
|
+
input.value = '';
|
|
1708
|
+
input.style.height = 'auto';
|
|
1709
|
+
|
|
1710
|
+
const proxy = proxies.find(p => p.id === assistantProxyId);
|
|
1711
|
+
if (!proxy) {
|
|
1712
|
+
addAssistantMessage('assistant', '所选代理不存在或已停止,请重新选择。');
|
|
1713
|
+
return;
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
const thinkingId = addAssistantMessage('thinking', '');
|
|
1717
|
+
const myController = new AbortController();
|
|
1718
|
+
assistantAbortController = myController;
|
|
1719
|
+
|
|
1720
|
+
try {
|
|
1721
|
+
const providerVal = document.getElementById('assistant-provider-select')?.value || '';
|
|
1722
|
+
const modelVal = document.getElementById('assistant-model-select')?.value || '';
|
|
1723
|
+
const res = await fetch('/api/assistant/chat', {
|
|
1724
|
+
method: 'POST',
|
|
1725
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1726
|
+
body: JSON.stringify({
|
|
1727
|
+
proxyId: proxy.id, conversationId: assistantConversationId, message: text,
|
|
1728
|
+
...(providerVal && { providerId: providerVal }),
|
|
1729
|
+
...(modelVal && { model: modelVal }),
|
|
1730
|
+
}),
|
|
1731
|
+
signal: assistantAbortController.signal,
|
|
1732
|
+
});
|
|
1733
|
+
|
|
1734
|
+
if (!res.ok) {
|
|
1735
|
+
const err = await res.text().catch(() => 'Unknown error');
|
|
1736
|
+
removeAssistantMessage(thinkingId);
|
|
1737
|
+
addAssistantMessage('assistant', `请求失败: HTTP ${res.status}\n\n${err}`);
|
|
1738
|
+
return;
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
const reader = res.body.getReader();
|
|
1742
|
+
const decoder = new TextDecoder();
|
|
1743
|
+
let buffer = '';
|
|
1744
|
+
let fullContent = '';
|
|
1745
|
+
let currentEvent = '';
|
|
1746
|
+
let msgId = null;
|
|
1747
|
+
let thinkingRemoved = false;
|
|
1748
|
+
|
|
1749
|
+
while (true) {
|
|
1750
|
+
const { done, value } = await reader.read();
|
|
1751
|
+
if (done) break;
|
|
1752
|
+
|
|
1753
|
+
buffer += decoder.decode(value, { stream: true });
|
|
1754
|
+
const lines = buffer.split('\n');
|
|
1755
|
+
buffer = lines.pop();
|
|
1756
|
+
|
|
1757
|
+
for (const line of lines) {
|
|
1758
|
+
const trimmed = line.trim();
|
|
1759
|
+
if (!trimmed) continue;
|
|
1760
|
+
|
|
1761
|
+
if (trimmed.startsWith('event: ')) {
|
|
1762
|
+
currentEvent = trimmed.slice(7);
|
|
1763
|
+
continue;
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
if (!trimmed.startsWith('data: ')) continue;
|
|
1767
|
+
let data;
|
|
1768
|
+
try { data = JSON.parse(trimmed.slice(6)); } catch { continue; }
|
|
1769
|
+
|
|
1770
|
+
switch (currentEvent) {
|
|
1771
|
+
case 'content': {
|
|
1772
|
+
if (!thinkingRemoved) { removeAssistantMessage(thinkingId); thinkingRemoved = true; }
|
|
1773
|
+
if (!msgId) {
|
|
1774
|
+
msgId = addAssistantMessage('assistant', '');
|
|
1775
|
+
|
|
1776
|
+
}
|
|
1777
|
+
fullContent += data.delta;
|
|
1778
|
+
updateAssistantMessage(msgId, fullContent);
|
|
1779
|
+
break;
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
case 'tool_calls': {
|
|
1783
|
+
if (!thinkingRemoved) { removeAssistantMessage(thinkingId); thinkingRemoved = true; }
|
|
1784
|
+
const calls = data.calls || [];
|
|
1785
|
+
fullContent = '';
|
|
1786
|
+
const callHtml = calls.map(tc => {
|
|
1787
|
+
const argsStr = Object.keys(tc.arguments || {}).length > 0
|
|
1788
|
+
? `<span class="tool-call-args">${escapeHtml(JSON.stringify(tc.arguments))}</span>`
|
|
1789
|
+
: '';
|
|
1790
|
+
return `<div class="tool-call-item"><span class="tool-call-name">${escapeHtml(tc.name)}</span>${argsStr}</div>`;
|
|
1791
|
+
}).join('');
|
|
1792
|
+
addAssistantMessage('tool-calls', callHtml);
|
|
1793
|
+
break;
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
case 'tool_result': {
|
|
1797
|
+
const resultStr = JSON.stringify(data.result, null, 2);
|
|
1798
|
+
addAssistantMessage('tool-result', { name: data.name, result: resultStr, tool_call_id: data.tool_call_id, is_error: data.is_error });
|
|
1799
|
+
break;
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
case 'done': {
|
|
1803
|
+
if (!thinkingRemoved) { removeAssistantMessage(thinkingId); thinkingRemoved = true; }
|
|
1804
|
+
if (msgId) {
|
|
1805
|
+
const msgObj = assistantMessages.find(m => m.id === msgId);
|
|
1806
|
+
if (msgObj) {
|
|
1807
|
+
// 将 reasoning_content 包装为 <think> 标签以便统一渲染
|
|
1808
|
+
if (data.reasoning_content && !fullContent.includes('<think>')) {
|
|
1809
|
+
fullContent = `<think>${data.reasoning_content}</think>` + fullContent;
|
|
1810
|
+
}
|
|
1811
|
+
msgObj.content = fullContent;
|
|
1812
|
+
updateAssistantMessage(msgId, fullContent);
|
|
1813
|
+
}
|
|
1814
|
+
}
|
|
1815
|
+
break;
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
case 'error': {
|
|
1819
|
+
if (!thinkingRemoved) { removeAssistantMessage(thinkingId); thinkingRemoved = true; }
|
|
1820
|
+
addAssistantMessage('assistant', `错误: ${data.message}`);
|
|
1821
|
+
break;
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
case 'context': {
|
|
1825
|
+
contextTokens = data.tokens;
|
|
1826
|
+
contextMaxTokens = data.maxTokens || contextMaxTokens;
|
|
1827
|
+
contextPercent = data.percent;
|
|
1828
|
+
contextMessages = data.messages;
|
|
1829
|
+
updateContextBar();
|
|
1830
|
+
break;
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
case 'compressed': {
|
|
1834
|
+
if (data.summary) applyCompression(data);
|
|
1835
|
+
break;
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
case 'compressing': {
|
|
1839
|
+
break;
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
case 'conversation': {
|
|
1843
|
+
assistantConversationId = data.id;
|
|
1844
|
+
loadConversations();
|
|
1845
|
+
break;
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
// 流结束时确保 thinking 点被移除
|
|
1852
|
+
if (!thinkingRemoved) removeAssistantMessage(thinkingId);
|
|
1853
|
+
|
|
1854
|
+
// 流结束但没有收到 done 事件时,确保最终内容被保存
|
|
1855
|
+
if (msgId && fullContent) {
|
|
1856
|
+
const msgObj = assistantMessages.find(m => m.id === msgId);
|
|
1857
|
+
if (msgObj && !msgObj.content) msgObj.content = fullContent;
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
} catch (err) {
|
|
1861
|
+
removeAssistantMessage(thinkingId);
|
|
1862
|
+
if (err.name === 'AbortError') {
|
|
1863
|
+
addAssistantMessage('assistant', '已取消');
|
|
1864
|
+
} else {
|
|
1865
|
+
addAssistantMessage('assistant', `请求出错: ${err.message}`);
|
|
1866
|
+
}
|
|
1867
|
+
} finally {
|
|
1868
|
+
if (assistantAbortController === myController) assistantAbortController = null;
|
|
1869
|
+
}
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
function addAssistantMessage(role, content) {
|
|
1873
|
+
const id = 'msg-' + Date.now() + '-' + Math.random().toString(36).slice(2);
|
|
1874
|
+
assistantMessages.push({ id, role, content });
|
|
1875
|
+
|
|
1876
|
+
const chat = document.getElementById('assistant-chat');
|
|
1877
|
+
if (!chat) return id;
|
|
1878
|
+
|
|
1879
|
+
const displayRoles = ['user', 'assistant', 'tool', 'tool-calls', 'tool-result'];
|
|
1880
|
+
if (assistantMessages.filter(m => displayRoles.includes(m.role)).length === 1 && role === 'user') {
|
|
1881
|
+
chat.innerHTML = '';
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
const div = document.createElement('div');
|
|
1885
|
+
div.id = id;
|
|
1886
|
+
div.className = `assistant-message ${role}`;
|
|
1887
|
+
|
|
1888
|
+
if (role === 'thinking') {
|
|
1889
|
+
div.innerHTML = `<div class="assistant-dot"></div><div class="assistant-dot"></div><div class="assistant-dot"></div>`;
|
|
1890
|
+
} else if (role === 'tool-calls') {
|
|
1891
|
+
// content 已经是 HTML 字符串
|
|
1892
|
+
div.innerHTML = `<div class="tool-calls-header">调用工具</div>${content}`;
|
|
1893
|
+
} else if (role === 'tool-result') {
|
|
1894
|
+
// content 是 {name, result, tool_call_id, is_error}
|
|
1895
|
+
const toolData = typeof content === 'object' ? content : { name: 'unknown', result: content };
|
|
1896
|
+
const resultId = 'result-' + id;
|
|
1897
|
+
if (toolData.is_error) div.classList.add('tool-error');
|
|
1898
|
+
div.innerHTML = `
|
|
1899
|
+
<div class="tool-result-header" onclick="document.getElementById('${resultId}').classList.toggle('expanded')">
|
|
1900
|
+
<span class="tool-result-name">${toolData.is_error ? '⚠ ' : ''}${escapeHtml(toolData.name)}</span>
|
|
1901
|
+
<span class="tool-result-toggle">${toolData.is_error ? '错误详情 ▾' : '展开结果 ▾'}</span>
|
|
1902
|
+
</div>
|
|
1903
|
+
<div class="tool-result-body" id="${resultId}">
|
|
1904
|
+
<pre>${escapeHtml(toolData.result)}</pre>
|
|
1905
|
+
</div>`;
|
|
1906
|
+
// 保存 tool_call_id 到消息对象
|
|
1907
|
+
const msgObj = assistantMessages.find(m => m.id === id);
|
|
1908
|
+
if (msgObj) {
|
|
1909
|
+
msgObj.tool_call_id = toolData.tool_call_id;
|
|
1910
|
+
msgObj.tool_name = toolData.name;
|
|
1911
|
+
msgObj.content = toolData.result;
|
|
1912
|
+
}
|
|
1913
|
+
} else if (role === 'assistant') {
|
|
1914
|
+
div.innerHTML = formatAssistantContent(content);
|
|
1915
|
+
} else {
|
|
1916
|
+
div.textContent = content;
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
chat.appendChild(div);
|
|
1920
|
+
chat.scrollTop = chat.scrollHeight;
|
|
1921
|
+
return id;
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
function updateAssistantMessage(id, content) {
|
|
1925
|
+
const div = document.getElementById(id);
|
|
1926
|
+
if (div) {
|
|
1927
|
+
div.innerHTML = formatAssistantContent(content);
|
|
1928
|
+
const chat = document.getElementById('assistant-chat');
|
|
1929
|
+
if (chat) chat.scrollTop = chat.scrollHeight;
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
function removeAssistantMessage(id) {
|
|
1934
|
+
const div = document.getElementById(id);
|
|
1935
|
+
if (div) div.remove();
|
|
1936
|
+
assistantMessages = assistantMessages.filter(m => m.id !== id);
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
function formatAssistantContent(text) {
|
|
1940
|
+
if (!text) return '';
|
|
1941
|
+
// 将 <think>...</think> 渲染为可折叠的思考块(在 escapeHtml 之前提取)
|
|
1942
|
+
const thinkBlocks = [];
|
|
1943
|
+
let processed = text.replace(/<think>([\s\S]*?)<\/think>/g, (_, think) => {
|
|
1944
|
+
const idx = thinkBlocks.length;
|
|
1945
|
+
thinkBlocks.push(think.trim());
|
|
1946
|
+
return `\x00THINK_${idx}\x00`;
|
|
1947
|
+
});
|
|
1948
|
+
let html = escapeHtml(processed);
|
|
1949
|
+
// 还原思考块为 HTML
|
|
1950
|
+
html = html.replace(/\x00THINK_(\d+)\x00/g, (_, idx) => {
|
|
1951
|
+
const thinkId = 'think-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6);
|
|
1952
|
+
return `<details class="think-block"><summary>思考过程</summary><div class="think-content" id="${thinkId}">${escapeHtml(thinkBlocks[parseInt(idx)])}</div></details>`;
|
|
1953
|
+
});
|
|
1954
|
+
// 代码块
|
|
1955
|
+
html = html.replace(/```([\s\S]*?)```/g, '<pre><code>$1</code></pre>');
|
|
1956
|
+
// 行内代码
|
|
1957
|
+
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
|
|
1958
|
+
// 段落
|
|
1959
|
+
const paragraphs = html.split(/\n{2,}/);
|
|
1960
|
+
html = paragraphs.map(p => {
|
|
1961
|
+
p = p.trim();
|
|
1962
|
+
if (!p) return '';
|
|
1963
|
+
// 如果已经是 pre,不包 p
|
|
1964
|
+
if (p.startsWith('<pre>')) return p;
|
|
1965
|
+
// 列表项
|
|
1966
|
+
if (p.startsWith('- ') || p.startsWith('* ')) {
|
|
1967
|
+
const items = p.split('\n').filter(l => l.trim().startsWith('- ') || l.trim().startsWith('* '));
|
|
1968
|
+
return '<ul>' + items.map(i => `<li>${i.trim().slice(2)}</li>`).join('') + '</ul>';
|
|
1969
|
+
}
|
|
1970
|
+
// 数字列表
|
|
1971
|
+
if (/^\d+\./.test(p)) {
|
|
1972
|
+
const items = p.split('\n').filter(l => /^\d+\./.test(l.trim()));
|
|
1973
|
+
return '<ol>' + items.map(i => `<li>${i.trim().replace(/^\d+\.\s*/, '')}</li>`).join('') + '</ol>';
|
|
1974
|
+
}
|
|
1975
|
+
return '<p>' + p.replace(/\n/g, '<br>') + '</p>';
|
|
1976
|
+
}).join('');
|
|
1977
|
+
return html;
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
function clearAssistantChat() {
|
|
1981
|
+
assistantMessages = [];
|
|
1982
|
+
assistantConversationId = '';
|
|
1983
|
+
assistantProviderId = '';
|
|
1984
|
+
const trigger = document.getElementById('conversation-dropdown-trigger');
|
|
1985
|
+
if (trigger) trigger.textContent = '新会话';
|
|
1986
|
+
proxyProviders = [];
|
|
1987
|
+
populateProviderSelect();
|
|
1988
|
+
populateModelSelect();
|
|
1989
|
+
contextTokens = 0;
|
|
1990
|
+
contextPercent = 0;
|
|
1991
|
+
contextMessages = 0;
|
|
1992
|
+
updateContextBar();
|
|
1993
|
+
const chat = document.getElementById('assistant-chat');
|
|
1994
|
+
if (!chat) return;
|
|
1995
|
+
chat.innerHTML = `
|
|
1996
|
+
<div class="assistant-welcome">
|
|
1997
|
+
<div class="assistant-welcome-icon">
|
|
1998
|
+
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path></svg>
|
|
1999
|
+
</div>
|
|
2000
|
+
<h3>智控助手</h3>
|
|
2001
|
+
<p>我是你的 Protocol Proxy 智能助手,可以帮你:</p>
|
|
2002
|
+
<ul>
|
|
2003
|
+
<li>查询代理和供应商运行状态</li>
|
|
2004
|
+
<li>分析日志,定位异常原因</li>
|
|
2005
|
+
<li>解读配置并给出优化建议</li>
|
|
2006
|
+
<li>自然语言排障与问答</li>
|
|
2007
|
+
</ul>
|
|
2008
|
+
<p class="assistant-hint">请先选择一个运行中的代理作为对话后端</p>
|
|
2009
|
+
</div>
|
|
2010
|
+
`;
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
function formatTokenCount(n) {
|
|
2014
|
+
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
|
|
2015
|
+
if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
|
|
2016
|
+
return String(n);
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
function updateContextBar() {
|
|
2020
|
+
const bar = document.getElementById('assistant-context-bar');
|
|
2021
|
+
if (!bar) return;
|
|
2022
|
+
if (contextMessages === 0) {
|
|
2023
|
+
bar.style.display = 'none';
|
|
2024
|
+
return;
|
|
2025
|
+
}
|
|
2026
|
+
bar.style.display = 'flex';
|
|
2027
|
+
|
|
2028
|
+
document.getElementById('context-bar-tokens').textContent = formatTokenCount(contextTokens);
|
|
2029
|
+
document.getElementById('context-bar-max').textContent = formatTokenCount(contextMaxTokens);
|
|
2030
|
+
document.getElementById('context-bar-percent').textContent = contextPercent.toFixed(1);
|
|
2031
|
+
document.getElementById('context-bar-messages').textContent = contextMessages;
|
|
2032
|
+
|
|
2033
|
+
const fill = document.getElementById('context-bar-fill');
|
|
2034
|
+
const pct = Math.min(100, contextPercent);
|
|
2035
|
+
fill.style.width = pct + '%';
|
|
2036
|
+
fill.className = 'context-bar-fill' + (pct >= 80 ? ' high' : pct >= 50 ? ' mid' : '');
|
|
2037
|
+
|
|
2038
|
+
const compressBtn = document.getElementById('context-compress-btn');
|
|
2039
|
+
if (compressBtn) {
|
|
2040
|
+
compressBtn.style.display = pct >= 50 ? '' : 'none';
|
|
2041
|
+
}
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
function updateMaxContext(value) {
|
|
2045
|
+
const v = Math.max(10000, parseInt(value) || 200000);
|
|
2046
|
+
contextMaxTokens = v;
|
|
2047
|
+
fetch('/api/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ maxContext: v }) }).catch(() => {});
|
|
2048
|
+
if (contextTokens > 0) {
|
|
2049
|
+
contextPercent = Math.round(contextTokens / contextMaxTokens * 1000) / 10;
|
|
2050
|
+
updateContextBar();
|
|
2051
|
+
}
|
|
2052
|
+
}
|
|
2053
|
+
|
|
2054
|
+
function updateMaxRounds(value) {
|
|
2055
|
+
const v = Math.max(1, Math.min(100, parseInt(value) || 10));
|
|
2056
|
+
assistantMaxRounds = v;
|
|
2057
|
+
fetch('/api/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ maxRounds: v }) }).catch(() => {});
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2060
|
+
function updateMaxConversations(value) {
|
|
2061
|
+
const v = Math.max(0, parseInt(value) || 0);
|
|
2062
|
+
fetch('/api/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ maxConversations: v }) }).catch(() => {});
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
function toggleConversationDropdown() {
|
|
2066
|
+
const menu = document.getElementById('conversation-dropdown-menu');
|
|
2067
|
+
if (!menu) return;
|
|
2068
|
+
menu.classList.toggle('open');
|
|
2069
|
+
}
|
|
2070
|
+
|
|
2071
|
+
function updateConversationTriggerLabel() {
|
|
2072
|
+
const trigger = document.getElementById('conversation-dropdown-trigger');
|
|
2073
|
+
if (!trigger) return;
|
|
2074
|
+
if (assistantConversationId) {
|
|
2075
|
+
const item = document.querySelector(`.conversation-dropdown-item[data-id="${assistantConversationId}"] .conversation-dropdown-item-label`);
|
|
2076
|
+
trigger.textContent = item ? item.textContent : assistantConversationId;
|
|
2077
|
+
} else {
|
|
2078
|
+
trigger.textContent = '新会话';
|
|
2079
|
+
}
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
async function deleteConversationById(convId, event) {
|
|
2083
|
+
event.stopPropagation();
|
|
2084
|
+
const ok = await showConfirm('确定删除该会话?');
|
|
2085
|
+
if (!ok) return;
|
|
2086
|
+
try {
|
|
2087
|
+
await fetch(`/api/assistant/conversations/${convId}`, { method: 'DELETE' });
|
|
2088
|
+
if (assistantConversationId === convId) clearAssistantChat();
|
|
2089
|
+
await loadConversations();
|
|
2090
|
+
showToast('会话已删除');
|
|
2091
|
+
} catch (err) {
|
|
2092
|
+
showToast('删除失败: ' + err.message, true);
|
|
2093
|
+
}
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
async function loadConversations() {
|
|
2097
|
+
try {
|
|
2098
|
+
const res = await fetch('/api/assistant/conversations');
|
|
2099
|
+
const data = await res.json();
|
|
2100
|
+
const menu = document.getElementById('conversation-dropdown-menu');
|
|
2101
|
+
if (!menu) return;
|
|
2102
|
+
const sorted = (data.conversations || []).slice().reverse();
|
|
2103
|
+
menu.innerHTML = '';
|
|
2104
|
+
for (const c of sorted) {
|
|
2105
|
+
const date = new Date(c.lastActivity).toLocaleString('zh-CN', { hour12: false, month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
|
|
2106
|
+
const label = (c.preview || '空会话').slice(0, 30) + ' — ' + date;
|
|
2107
|
+
const item = document.createElement('div');
|
|
2108
|
+
item.className = 'conversation-dropdown-item' + (c.id === assistantConversationId ? ' active' : '');
|
|
2109
|
+
item.dataset.id = c.id;
|
|
2110
|
+
item.innerHTML = `<span class="conversation-dropdown-item-label">${escapeHtml(label)}</span><button class="conversation-dropdown-item-delete" title="删除">×</button>`;
|
|
2111
|
+
item.querySelector('.conversation-dropdown-item-label').onclick = () => {
|
|
2112
|
+
menu.classList.remove('open');
|
|
2113
|
+
document.getElementById('conversation-dropdown-trigger').textContent = label;
|
|
2114
|
+
switchConversation(c.id);
|
|
2115
|
+
};
|
|
2116
|
+
item.querySelector('.conversation-dropdown-item-delete').onclick = (e) => deleteConversationById(c.id, e);
|
|
2117
|
+
menu.appendChild(item);
|
|
2118
|
+
}
|
|
2119
|
+
updateConversationTriggerLabel();
|
|
2120
|
+
} catch (err) {
|
|
2121
|
+
showToast('加载会话列表失败: ' + err.message, true);
|
|
2122
|
+
}
|
|
2123
|
+
}
|
|
2124
|
+
|
|
2125
|
+
async function switchConversation(convId) {
|
|
2126
|
+
// 中断进行中的请求,避免旧流的 DOM 更新与新会话冲突
|
|
2127
|
+
if (assistantAbortController) {
|
|
2128
|
+
assistantAbortController.abort();
|
|
2129
|
+
assistantAbortController = null;
|
|
2130
|
+
}
|
|
2131
|
+
if (!convId) {
|
|
2132
|
+
// 选择"新会话"
|
|
2133
|
+
clearAssistantChat();
|
|
2134
|
+
return;
|
|
2135
|
+
}
|
|
2136
|
+
assistantConversationId = convId;
|
|
2137
|
+
assistantMessages = [];
|
|
2138
|
+
const chat = document.getElementById('assistant-chat');
|
|
2139
|
+
if (chat) chat.innerHTML = '';
|
|
2140
|
+
|
|
2141
|
+
// 重置上下文栏,避免显示前一个会话的 token 统计
|
|
2142
|
+
contextTokens = 0;
|
|
2143
|
+
contextPercent = 0;
|
|
2144
|
+
contextMessages = 0;
|
|
2145
|
+
updateContextBar();
|
|
2146
|
+
|
|
2147
|
+
try {
|
|
2148
|
+
const res = await fetch(`/api/assistant/conversations/${convId}/messages`);
|
|
2149
|
+
const data = await res.json();
|
|
2150
|
+
if (data.proxyId) {
|
|
2151
|
+
// 自动选中对应的代理
|
|
2152
|
+
const select = document.getElementById('assistant-proxy-select');
|
|
2153
|
+
if (select && select.querySelector(`option[value="${data.proxyId}"]`)) {
|
|
2154
|
+
select.value = data.proxyId;
|
|
2155
|
+
assistantProxyId = data.proxyId;
|
|
2156
|
+
document.getElementById('assistant-send-btn').disabled = false;
|
|
2157
|
+
}
|
|
2158
|
+
loadProxyProviders(data.proxyId);
|
|
2159
|
+
}
|
|
2160
|
+
// 渲染压缩摘要(如果有)
|
|
2161
|
+
if (data.compressionSummary) {
|
|
2162
|
+
const chatEl = document.getElementById('assistant-chat');
|
|
2163
|
+
if (chatEl) {
|
|
2164
|
+
const details = document.createElement('details');
|
|
2165
|
+
details.className = 'compression-summary';
|
|
2166
|
+
details.innerHTML = `<summary>之前的对话已被压缩</summary><div class="compression-summary-content">${escapeHtml(data.compressionSummary)}</div>`;
|
|
2167
|
+
chatEl.appendChild(details);
|
|
2168
|
+
}
|
|
2169
|
+
}
|
|
2170
|
+
// 渲染历史消息
|
|
2171
|
+
for (const m of (data.messages || [])) {
|
|
2172
|
+
if (m.role === 'user') {
|
|
2173
|
+
addAssistantMessage('user', m.content);
|
|
2174
|
+
} else if (m.role === 'assistant') {
|
|
2175
|
+
addAssistantMessage('assistant', m.content || '');
|
|
2176
|
+
}
|
|
2177
|
+
}
|
|
2178
|
+
} catch (err) {
|
|
2179
|
+
showToast('加载会话失败: ' + err.message, true);
|
|
2180
|
+
}
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
|
|
2184
|
+
async function compressAssistantContext() {
|
|
2185
|
+
if (!assistantProxyId || !assistantConversationId) return;
|
|
2186
|
+
const btn = document.getElementById('context-compress-btn');
|
|
2187
|
+
if (btn) { btn.disabled = true; btn.textContent = '压缩中...'; }
|
|
2188
|
+
try {
|
|
2189
|
+
const res = await fetch('/api/assistant/chat', {
|
|
2190
|
+
method: 'POST',
|
|
2191
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2192
|
+
body: JSON.stringify({
|
|
2193
|
+
proxyId: assistantProxyId, conversationId: assistantConversationId, message: '', compress: true,
|
|
2194
|
+
...(document.getElementById('assistant-provider-select')?.value && { providerId: document.getElementById('assistant-provider-select').value }),
|
|
2195
|
+
...(document.getElementById('assistant-model-select')?.value && { model: document.getElementById('assistant-model-select').value }),
|
|
2196
|
+
}),
|
|
2197
|
+
});
|
|
2198
|
+
const reader = res.body.getReader();
|
|
2199
|
+
const decoder = new TextDecoder();
|
|
2200
|
+
let buffer = '';
|
|
2201
|
+
let currentEvent = '';
|
|
2202
|
+
while (true) {
|
|
2203
|
+
const { done, value } = await reader.read();
|
|
2204
|
+
if (done) break;
|
|
2205
|
+
buffer += decoder.decode(value, { stream: true });
|
|
2206
|
+
const lines = buffer.split('\n');
|
|
2207
|
+
buffer = lines.pop();
|
|
2208
|
+
for (const line of lines) {
|
|
2209
|
+
const trimmed = line.trim();
|
|
2210
|
+
if (!trimmed) continue;
|
|
2211
|
+
if (trimmed.startsWith('event: ')) { currentEvent = trimmed.slice(7); continue; }
|
|
2212
|
+
if (!trimmed.startsWith('data: ')) continue;
|
|
2213
|
+
let data;
|
|
2214
|
+
try { data = JSON.parse(trimmed.slice(6)); } catch { continue; }
|
|
2215
|
+
if (currentEvent === 'compressed' && data.summary) applyCompression(data);
|
|
2216
|
+
if (currentEvent === 'context') {
|
|
2217
|
+
contextTokens = data.tokens;
|
|
2218
|
+
contextMaxTokens = data.maxTokens;
|
|
2219
|
+
contextPercent = data.percent;
|
|
2220
|
+
contextMessages = data.messages;
|
|
2221
|
+
updateContextBar();
|
|
2222
|
+
}
|
|
2223
|
+
}
|
|
2224
|
+
}
|
|
2225
|
+
} catch (err) {
|
|
2226
|
+
showToast('压缩失败: ' + err.message, true);
|
|
2227
|
+
} finally {
|
|
2228
|
+
if (btn) { btn.disabled = false; btn.textContent = '压缩'; }
|
|
2229
|
+
}
|
|
2230
|
+
}
|
|
2231
|
+
|
|
2232
|
+
function applyCompression(data) {
|
|
2233
|
+
// 后端已完成压缩,前端只更新显示
|
|
2234
|
+
if (data.tokens != null) contextTokens = data.tokens;
|
|
2235
|
+
if (data.maxTokens) contextMaxTokens = data.maxTokens;
|
|
2236
|
+
contextPercent = Math.round(contextTokens / contextMaxTokens * 1000) / 10;
|
|
2237
|
+
contextMessages = data.messages || 0;
|
|
2238
|
+
updateContextBar();
|
|
2239
|
+
if (data.summary) {
|
|
2240
|
+
showToast(`已压缩 ${data.removedCount} 条消息,摘要已保存`);
|
|
2241
|
+
}
|
|
2242
|
+
}
|
|
2243
|
+
|
|
2244
|
+
// 加载代理的候选供应商列表
|
|
2245
|
+
async function loadProxyProviders(proxyId) {
|
|
2246
|
+
assistantProviderId = '';
|
|
2247
|
+
proxyProviders = [];
|
|
2248
|
+
populateProviderSelect();
|
|
2249
|
+
populateModelSelect();
|
|
2250
|
+
if (!proxyId) return;
|
|
2251
|
+
try {
|
|
2252
|
+
const res = await fetch(`/api/assistant/proxy-providers/${proxyId}`);
|
|
2253
|
+
const data = await res.json();
|
|
2254
|
+
proxyProviders = data.providers || [];
|
|
2255
|
+
// 恢复保存的供应商选择
|
|
2256
|
+
if (savedAssistantProviderId && proxyProviders.find(p => p.id === savedAssistantProviderId)) {
|
|
2257
|
+
assistantProviderId = savedAssistantProviderId;
|
|
2258
|
+
savedAssistantProviderId = ''; // 只恢复一次
|
|
2259
|
+
}
|
|
2260
|
+
populateProviderSelect();
|
|
2261
|
+
populateModelSelect();
|
|
2262
|
+
// 恢复保存的模型选择
|
|
2263
|
+
if (savedAssistantModel) {
|
|
2264
|
+
const modelSelect = document.getElementById('assistant-model-select');
|
|
2265
|
+
if (modelSelect && modelSelect.querySelector(`option[value="${savedAssistantModel}"]`)) {
|
|
2266
|
+
modelSelect.value = savedAssistantModel;
|
|
2267
|
+
}
|
|
2268
|
+
savedAssistantModel = ''; // 只恢复一次
|
|
2269
|
+
}
|
|
2270
|
+
} catch (err) {
|
|
2271
|
+
console.warn('[assistant] 加载供应商列表失败:', err.message);
|
|
2272
|
+
}
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
function populateProviderSelect() {
|
|
2276
|
+
const select = document.getElementById('assistant-provider-select');
|
|
2277
|
+
if (!select) return;
|
|
2278
|
+
select.innerHTML = '<option value="">自动(跟随代理)</option>' +
|
|
2279
|
+
proxyProviders.map(p => `<option value="${escapeHtml(p.id)}">${escapeHtml(p.name)} (${escapeHtml(p.protocol)})</option>`).join('');
|
|
2280
|
+
select.value = assistantProviderId;
|
|
2281
|
+
}
|
|
2282
|
+
|
|
2283
|
+
function populateModelSelect() {
|
|
2284
|
+
const select = document.getElementById('assistant-model-select');
|
|
2285
|
+
if (!select) return;
|
|
2286
|
+
const provider = proxyProviders.find(p => p.id === assistantProviderId);
|
|
2287
|
+
const models = provider?.models || [];
|
|
2288
|
+
select.innerHTML = '<option value="">自动(跟随代理)</option>' +
|
|
2289
|
+
models.map(m => `<option value="${escapeHtml(m)}">${escapeHtml(m)}</option>`).join('');
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2292
|
+
// eslint-disable-next-line no-unused-vars
|
|
2293
|
+
function onAssistantProviderChange(value) {
|
|
2294
|
+
assistantProviderId = value;
|
|
2295
|
+
populateModelSelect();
|
|
2296
|
+
saveAssistantSelection();
|
|
2297
|
+
}
|
|
2298
|
+
|
|
2299
|
+
function saveAssistantSelection() {
|
|
2300
|
+
const modelVal = document.getElementById('assistant-model-select')?.value || '';
|
|
2301
|
+
fetch('/api/settings', {
|
|
2302
|
+
method: 'PUT',
|
|
2303
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2304
|
+
body: JSON.stringify({ assistantProxyId, assistantProviderId, assistantModel: modelVal }),
|
|
2305
|
+
}).catch(() => {});
|
|
2306
|
+
}
|
|
2307
|
+
|
|
2308
|
+
// 监听代理选择
|
|
2309
|
+
(function() {
|
|
2310
|
+
const select = document.getElementById('assistant-proxy-select');
|
|
2311
|
+
if (select) {
|
|
2312
|
+
select.addEventListener('change', function() {
|
|
2313
|
+
assistantProxyId = this.value;
|
|
2314
|
+
const btn = document.getElementById('assistant-send-btn');
|
|
2315
|
+
if (btn) btn.disabled = !this.value;
|
|
2316
|
+
loadProxyProviders(this.value);
|
|
2317
|
+
saveAssistantSelection();
|
|
2318
|
+
});
|
|
2319
|
+
}
|
|
2320
|
+
// 监听模型选择
|
|
2321
|
+
const modelSelect = document.getElementById('assistant-model-select');
|
|
2322
|
+
if (modelSelect) {
|
|
2323
|
+
modelSelect.addEventListener('change', function() {
|
|
2324
|
+
saveAssistantSelection();
|
|
2325
|
+
});
|
|
2326
|
+
}
|
|
2327
|
+
})();
|
|
2328
|
+
|
|
2329
|
+
// 点击外部关闭会话下拉
|
|
2330
|
+
document.addEventListener('click', (e) => {
|
|
2331
|
+
const dropdown = document.getElementById('conversation-dropdown');
|
|
2332
|
+
if (dropdown && !dropdown.contains(e.target)) {
|
|
2333
|
+
document.getElementById('conversation-dropdown-menu')?.classList.remove('open');
|
|
2334
|
+
}
|
|
2335
|
+
});
|
|
2336
|
+
|
|
1590
2337
|
init();
|