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/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
- if (current && running.find(p => p.id === current)) {
1620
- select.value = current;
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: glob 模式搜索文件
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
- assistantAbortController = new AbortController();
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({ proxyId: proxy.id, messages }),
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 (!msgId) msgId = addAssistantMessage('assistant', '');
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
- if (data.reasoning_content) msgObj.reasoning_content = data.reasoning_content;
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">展开结果 ▾</span>
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
- let html = escapeHtml(text);
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();