protocol-proxy 2.8.3 → 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/proxy-server.js +44 -5
- package/package.json +1 -1
- package/public/app.js +431 -66
- package/public/index.html +67 -6
- package/public/style.css +188 -0
- package/server.js +541 -52
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
|
}
|
|
@@ -95,7 +119,7 @@ function navigateTo(page) {
|
|
|
95
119
|
if (page === 'stats') loadStats();
|
|
96
120
|
if (page === 'system-logs') loadLogs();
|
|
97
121
|
if (page === 'request-logs') renderRequestLogs();
|
|
98
|
-
if (page === 'assistant') populateAssistantProxySelect();
|
|
122
|
+
if (page === 'assistant') { populateAssistantProxySelect(); loadConversations(); }
|
|
99
123
|
}
|
|
100
124
|
|
|
101
125
|
document.querySelectorAll('.nav-item[data-page]').forEach(item => {
|
|
@@ -1616,8 +1640,14 @@ function populateAssistantProxySelect() {
|
|
|
1616
1640
|
const current = select.value;
|
|
1617
1641
|
select.innerHTML = '<option value="">选择后端代理...</option>' +
|
|
1618
1642
|
running.map(p => `<option value="${escapeHtml(p.id)}">${escapeHtml(p.name)} (:${p.port})</option>`).join('');
|
|
1619
|
-
|
|
1620
|
-
|
|
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);
|
|
1621
1651
|
} else {
|
|
1622
1652
|
assistantProxyId = '';
|
|
1623
1653
|
document.getElementById('assistant-send-btn').disabled = true;
|
|
@@ -1642,10 +1672,12 @@ function buildSystemPrompt() {
|
|
|
1642
1672
|
- get_config_history: 获取配置快照历史
|
|
1643
1673
|
|
|
1644
1674
|
文件与命令:
|
|
1645
|
-
- read_file:
|
|
1675
|
+
- read_file: 读取任意文件内容(支持指定行范围,自动检测二进制文件)
|
|
1646
1676
|
- write_file: 写入文件(会覆盖已有内容)
|
|
1677
|
+
- edit_file: 精确替换文件中的字符串(比 write_file 更安全,只替换匹配内容)
|
|
1647
1678
|
- list_directory: 列出目录内容
|
|
1648
|
-
- search_files:
|
|
1679
|
+
- search_files: 按文件名 glob 模式搜索文件
|
|
1680
|
+
- grep_search: 按正则表达式搜索文件内容(用于查找代码、日志关键字等)
|
|
1649
1681
|
- execute_command: 执行 shell 命令
|
|
1650
1682
|
|
|
1651
1683
|
规则:
|
|
@@ -1666,6 +1698,7 @@ function buildSystemPrompt() {
|
|
|
1666
1698
|
}
|
|
1667
1699
|
|
|
1668
1700
|
async function sendAssistantMessage() {
|
|
1701
|
+
if (assistantAbortController) return; // 已有请求进行中,防连点
|
|
1669
1702
|
const input = document.getElementById('assistant-input');
|
|
1670
1703
|
const text = input.value.trim();
|
|
1671
1704
|
if (!text || !assistantProxyId) return;
|
|
@@ -1674,29 +1707,6 @@ async function sendAssistantMessage() {
|
|
|
1674
1707
|
input.value = '';
|
|
1675
1708
|
input.style.height = 'auto';
|
|
1676
1709
|
|
|
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
1710
|
const proxy = proxies.find(p => p.id === assistantProxyId);
|
|
1701
1711
|
if (!proxy) {
|
|
1702
1712
|
addAssistantMessage('assistant', '所选代理不存在或已停止,请重新选择。');
|
|
@@ -1704,13 +1714,20 @@ async function sendAssistantMessage() {
|
|
|
1704
1714
|
}
|
|
1705
1715
|
|
|
1706
1716
|
const thinkingId = addAssistantMessage('thinking', '');
|
|
1707
|
-
|
|
1717
|
+
const myController = new AbortController();
|
|
1718
|
+
assistantAbortController = myController;
|
|
1708
1719
|
|
|
1709
1720
|
try {
|
|
1721
|
+
const providerVal = document.getElementById('assistant-provider-select')?.value || '';
|
|
1722
|
+
const modelVal = document.getElementById('assistant-model-select')?.value || '';
|
|
1710
1723
|
const res = await fetch('/api/assistant/chat', {
|
|
1711
1724
|
method: 'POST',
|
|
1712
1725
|
headers: { 'Content-Type': 'application/json' },
|
|
1713
|
-
body: JSON.stringify({
|
|
1726
|
+
body: JSON.stringify({
|
|
1727
|
+
proxyId: proxy.id, conversationId: assistantConversationId, message: text,
|
|
1728
|
+
...(providerVal && { providerId: providerVal }),
|
|
1729
|
+
...(modelVal && { model: modelVal }),
|
|
1730
|
+
}),
|
|
1714
1731
|
signal: assistantAbortController.signal,
|
|
1715
1732
|
});
|
|
1716
1733
|
|
|
@@ -1727,9 +1744,7 @@ async function sendAssistantMessage() {
|
|
|
1727
1744
|
let fullContent = '';
|
|
1728
1745
|
let currentEvent = '';
|
|
1729
1746
|
let msgId = null;
|
|
1730
|
-
|
|
1731
|
-
removeAssistantMessage(thinkingId);
|
|
1732
|
-
console.log('[assistant] SSE stream started');
|
|
1747
|
+
let thinkingRemoved = false;
|
|
1733
1748
|
|
|
1734
1749
|
while (true) {
|
|
1735
1750
|
const { done, value } = await reader.read();
|
|
@@ -1752,33 +1767,22 @@ async function sendAssistantMessage() {
|
|
|
1752
1767
|
let data;
|
|
1753
1768
|
try { data = JSON.parse(trimmed.slice(6)); } catch { continue; }
|
|
1754
1769
|
|
|
1755
|
-
console.log('[assistant] SSE event:', currentEvent, data);
|
|
1756
1770
|
switch (currentEvent) {
|
|
1757
1771
|
case 'content': {
|
|
1758
|
-
if (!
|
|
1772
|
+
if (!thinkingRemoved) { removeAssistantMessage(thinkingId); thinkingRemoved = true; }
|
|
1773
|
+
if (!msgId) {
|
|
1774
|
+
msgId = addAssistantMessage('assistant', '');
|
|
1775
|
+
|
|
1776
|
+
}
|
|
1759
1777
|
fullContent += data.delta;
|
|
1760
1778
|
updateAssistantMessage(msgId, fullContent);
|
|
1761
1779
|
break;
|
|
1762
1780
|
}
|
|
1763
1781
|
|
|
1764
1782
|
case 'tool_calls': {
|
|
1783
|
+
if (!thinkingRemoved) { removeAssistantMessage(thinkingId); thinkingRemoved = true; }
|
|
1765
1784
|
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
1785
|
fullContent = '';
|
|
1781
|
-
// 创建显示用的 tool-calls 消息
|
|
1782
1786
|
const callHtml = calls.map(tc => {
|
|
1783
1787
|
const argsStr = Object.keys(tc.arguments || {}).length > 0
|
|
1784
1788
|
? `<span class="tool-call-args">${escapeHtml(JSON.stringify(tc.arguments))}</span>`
|
|
@@ -1791,37 +1795,62 @@ async function sendAssistantMessage() {
|
|
|
1791
1795
|
|
|
1792
1796
|
case 'tool_result': {
|
|
1793
1797
|
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
|
-
});
|
|
1798
|
+
addAssistantMessage('tool-result', { name: data.name, result: resultStr, tool_call_id: data.tool_call_id, is_error: data.is_error });
|
|
1803
1799
|
break;
|
|
1804
1800
|
}
|
|
1805
1801
|
|
|
1806
1802
|
case 'done': {
|
|
1803
|
+
if (!thinkingRemoved) { removeAssistantMessage(thinkingId); thinkingRemoved = true; }
|
|
1807
1804
|
if (msgId) {
|
|
1808
1805
|
const msgObj = assistantMessages.find(m => m.id === msgId);
|
|
1809
1806
|
if (msgObj) {
|
|
1807
|
+
// 将 reasoning_content 包装为 <think> 标签以便统一渲染
|
|
1808
|
+
if (data.reasoning_content && !fullContent.includes('<think>')) {
|
|
1809
|
+
fullContent = `<think>${data.reasoning_content}</think>` + fullContent;
|
|
1810
|
+
}
|
|
1810
1811
|
msgObj.content = fullContent;
|
|
1811
|
-
|
|
1812
|
+
updateAssistantMessage(msgId, fullContent);
|
|
1812
1813
|
}
|
|
1813
1814
|
}
|
|
1814
1815
|
break;
|
|
1815
1816
|
}
|
|
1816
1817
|
|
|
1817
1818
|
case 'error': {
|
|
1819
|
+
if (!thinkingRemoved) { removeAssistantMessage(thinkingId); thinkingRemoved = true; }
|
|
1818
1820
|
addAssistantMessage('assistant', `错误: ${data.message}`);
|
|
1819
1821
|
break;
|
|
1820
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
|
+
}
|
|
1821
1847
|
}
|
|
1822
1848
|
}
|
|
1823
1849
|
}
|
|
1824
1850
|
|
|
1851
|
+
// 流结束时确保 thinking 点被移除
|
|
1852
|
+
if (!thinkingRemoved) removeAssistantMessage(thinkingId);
|
|
1853
|
+
|
|
1825
1854
|
// 流结束但没有收到 done 事件时,确保最终内容被保存
|
|
1826
1855
|
if (msgId && fullContent) {
|
|
1827
1856
|
const msgObj = assistantMessages.find(m => m.id === msgId);
|
|
@@ -1836,7 +1865,7 @@ async function sendAssistantMessage() {
|
|
|
1836
1865
|
addAssistantMessage('assistant', `请求出错: ${err.message}`);
|
|
1837
1866
|
}
|
|
1838
1867
|
} finally {
|
|
1839
|
-
assistantAbortController = null;
|
|
1868
|
+
if (assistantAbortController === myController) assistantAbortController = null;
|
|
1840
1869
|
}
|
|
1841
1870
|
}
|
|
1842
1871
|
|
|
@@ -1862,13 +1891,14 @@ function addAssistantMessage(role, content) {
|
|
|
1862
1891
|
// content 已经是 HTML 字符串
|
|
1863
1892
|
div.innerHTML = `<div class="tool-calls-header">调用工具</div>${content}`;
|
|
1864
1893
|
} else if (role === 'tool-result') {
|
|
1865
|
-
// content 是 {name, result, tool_call_id}
|
|
1894
|
+
// content 是 {name, result, tool_call_id, is_error}
|
|
1866
1895
|
const toolData = typeof content === 'object' ? content : { name: 'unknown', result: content };
|
|
1867
1896
|
const resultId = 'result-' + id;
|
|
1897
|
+
if (toolData.is_error) div.classList.add('tool-error');
|
|
1868
1898
|
div.innerHTML = `
|
|
1869
1899
|
<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"
|
|
1900
|
+
<span class="tool-result-name">${toolData.is_error ? '⚠ ' : ''}${escapeHtml(toolData.name)}</span>
|
|
1901
|
+
<span class="tool-result-toggle">${toolData.is_error ? '错误详情 ▾' : '展开结果 ▾'}</span>
|
|
1872
1902
|
</div>
|
|
1873
1903
|
<div class="tool-result-body" id="${resultId}">
|
|
1874
1904
|
<pre>${escapeHtml(toolData.result)}</pre>
|
|
@@ -1908,7 +1938,19 @@ function removeAssistantMessage(id) {
|
|
|
1908
1938
|
|
|
1909
1939
|
function formatAssistantContent(text) {
|
|
1910
1940
|
if (!text) return '';
|
|
1911
|
-
|
|
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
|
+
});
|
|
1912
1954
|
// 代码块
|
|
1913
1955
|
html = html.replace(/```([\s\S]*?)```/g, '<pre><code>$1</code></pre>');
|
|
1914
1956
|
// 行内代码
|
|
@@ -1937,6 +1979,17 @@ function formatAssistantContent(text) {
|
|
|
1937
1979
|
|
|
1938
1980
|
function clearAssistantChat() {
|
|
1939
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();
|
|
1940
1993
|
const chat = document.getElementById('assistant-chat');
|
|
1941
1994
|
if (!chat) return;
|
|
1942
1995
|
chat.innerHTML = `
|
|
@@ -1957,6 +2010,301 @@ function clearAssistantChat() {
|
|
|
1957
2010
|
`;
|
|
1958
2011
|
}
|
|
1959
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
|
+
|
|
1960
2308
|
// 监听代理选择
|
|
1961
2309
|
(function() {
|
|
1962
2310
|
const select = document.getElementById('assistant-proxy-select');
|
|
@@ -1965,8 +2313,25 @@ function clearAssistantChat() {
|
|
|
1965
2313
|
assistantProxyId = this.value;
|
|
1966
2314
|
const btn = document.getElementById('assistant-send-btn');
|
|
1967
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();
|
|
1968
2325
|
});
|
|
1969
2326
|
}
|
|
1970
2327
|
})();
|
|
1971
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
|
+
|
|
1972
2337
|
init();
|