diff --git a/cmd/gui/icon.ico b/cmd/gui/icon.ico index e121e63..9d49a0f 100644 Binary files a/cmd/gui/icon.ico and b/cmd/gui/icon.ico differ diff --git a/cmd/gui/main.js b/cmd/gui/main.js index 4270e12..9843a86 100644 --- a/cmd/gui/main.js +++ b/cmd/gui/main.js @@ -12,16 +12,28 @@ let mainWindow; function loadConnections() { try { if (fs.existsSync(CONNECTIONS_FILE)) { - return JSON.parse(fs.readFileSync(CONNECTIONS_FILE, 'utf-8')); + const data = JSON.parse(fs.readFileSync(CONNECTIONS_FILE, 'utf-8')); + normalizeConnections(data); + return data; } } catch (e) { console.error('Failed to load connections:', e); + // 配置损坏:备份后重建,避免应用一直处于"无连接"状态 + try { + const backup = CONNECTIONS_FILE + '.bak'; + fs.copyFileSync(CONNECTIONS_FILE, backup); + fs.writeFileSync(CONNECTIONS_FILE, '{"connections":[],"currentId":null}', 'utf-8'); + console.error('Backed up corrupt connections to', backup); + } catch (e2) { + console.error('Failed to recover connections file:', e2); + } } // Fallback: check app resource dir (installer writes fallback copy there) try { const fallback = path.join(__dirname, 'connections.json'); if (fs.existsSync(fallback)) { const data = JSON.parse(fs.readFileSync(fallback, 'utf-8')); + normalizeConnections(data); saveConnections(data); console.log('Imported connections from app resource dir'); return data; @@ -32,6 +44,15 @@ function loadConnections() { return { connections: [], currentId: null }; } +// 兼容旧数据:缺失的 type 默认为 webui(HTTP) +function normalizeConnections(data) { + if (!data || !Array.isArray(data.connections)) return; + data.connections.forEach((c) => { + if (!c.type) c.type = 'webui'; + if (c.type !== 'cli' && c.type !== 'webui') c.type = 'webui'; + }); +} + function saveConnections(data) { try { fs.writeFileSync(CONNECTIONS_FILE, JSON.stringify(data, null, 2), 'utf-8'); @@ -109,7 +130,8 @@ function createWindow() { minWidth: 900, minHeight: 600, title: 'HomeAgent', - icon: path.join(__dirname, 'icon.svg'), + frame: false, + icon: path.join(__dirname, 'icon.ico'), webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, @@ -128,6 +150,18 @@ function createWindow() { }); } +ipcMain.handle('window:minimize', (e) => { + BrowserWindow.fromWebContents(e.sender)?.minimize(); +}); +ipcMain.handle('window:toggleMaximize', (e) => { + const win = BrowserWindow.fromWebContents(e.sender); + if (!win) return; + if (win.isMaximized()) win.unmaximize(); else win.maximize(); +}); +ipcMain.handle('window:close', (e) => { + BrowserWindow.fromWebContents(e.sender)?.close(); +}); + ipcMain.handle('connections:list', () => { return loadConnections(); }); @@ -135,7 +169,14 @@ ipcMain.handle('connections:list', () => { ipcMain.handle('connections:add', (_, conn) => { const data = loadConnections(); const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 6); - data.connections.push({ id, name: conn.name, url: conn.url, apiKey: conn.apiKey }); + data.connections.push({ + id, + name: conn.name, + url: conn.url || '', + apiKey: conn.apiKey || '', + type: conn.type === 'cli' ? 'cli' : 'webui', + socketPath: conn.socketPath || '', + }); if (!data.currentId) data.currentId = id; saveConnections(data); return data; @@ -170,6 +211,52 @@ ipcMain.handle('connections:setCurrent', (_, id) => { return data; }); +// CLI 传输:通过 homed 的 unix socket(逐行 JSON 协议)发起请求。 +// 认证行:/auth (若配置了密钥)。返回 JSON 响应行。 +ipcMain.handle('cli:request', (_, { socketPath, apiKey, line }) => { + return new Promise((resolve) => { + const net = require('net'); + let client; + try { + client = net.createConnection({ path: socketPath }); + } catch (e) { + return resolve({ error: 'create connection: ' + e.message }); + } + const timeout = setTimeout(() => { + try { client.destroy(); } catch (_) {} + resolve({ error: 'timeout waiting for cli response' }); + }, 30000); + + let buf = ''; + const onData = (chunk) => { + buf += chunk.toString('utf8'); + const idx = buf.indexOf('\n'); + if (idx === -1) return; + const lineOut = buf.slice(0, idx); + clearTimeout(timeout); + try { client.destroy(); } catch (_) {} + try { + resolve(JSON.parse(lineOut)); + } catch (e) { + resolve({ error: 'bad response: ' + lineOut }); + } + }; + const onError = (err) => { + clearTimeout(timeout); + try { client.destroy(); } catch (_) {} + resolve({ error: err.message }); + }; + + client.on('error', onError); + client.on('data', onData); + client.on('connect', () => { + let next = line; + if (apiKey) next = '/auth ' + apiKey + '\n' + next; + client.write(next + '\n'); + }); + }); +}); + app.whenReady().then(async () => { const running = await isServerRunning(); if (!running) { diff --git a/cmd/gui/preload.js b/cmd/gui/preload.js index 63ed161..9a1aa43 100644 --- a/cmd/gui/preload.js +++ b/cmd/gui/preload.js @@ -1,6 +1,11 @@ const { contextBridge, ipcRenderer } = require('electron'); contextBridge.exposeInMainWorld('homeagent', { + win: { + minimize: () => ipcRenderer.invoke('window:minimize'), + toggleMaximize: () => ipcRenderer.invoke('window:toggleMaximize'), + close: () => ipcRenderer.invoke('window:close'), + }, connections: { list: () => ipcRenderer.invoke('connections:list'), add: (conn) => ipcRenderer.invoke('connections:add', conn), @@ -8,4 +13,7 @@ contextBridge.exposeInMainWorld('homeagent', { delete: (id) => ipcRenderer.invoke('connections:delete', id), setCurrent: (id) => ipcRenderer.invoke('connections:setCurrent', id), }, + cli: { + request: (socketPath, apiKey, line) => ipcRenderer.invoke('cli:request', { socketPath, apiKey, line }), + }, }); diff --git a/cmd/gui/renderer/app.js b/cmd/gui/renderer/app.js index 38011b0..d471b35 100644 --- a/cmd/gui/renderer/app.js +++ b/cmd/gui/renderer/app.js @@ -6,6 +6,7 @@ let state = { meta: {}, pluginMeta: {}, settingsPlugins: ['core'], + currentView: 'chat', selectedSection: 'core', messages: [], chatLoading: false, @@ -17,6 +18,9 @@ let state = { chatHistory: [], terminals: [], cmdHistory: [], + termScreens: {}, + chatStick: true, + pendingTools: [], eventSource: null, lang: localStorage.getItem('ha-lang') || 'zh', connections: [], currentConn: null, @@ -111,10 +115,16 @@ function applyI18n() { } // ===== Theme ===== +var ICON_SUN_GUI = + ''; +var ICON_MOON_GUI = + ''; + function setTheme(name) { document.documentElement.setAttribute('data-theme', name); localStorage.setItem('ha-theme', name); - document.getElementById('theme-btn').textContent = name === 'light' ? '☀️' : '🌙'; + var btn = document.getElementById('theme-btn'); + if (btn) btn.innerHTML = name === 'light' ? ICON_SUN_GUI : ICON_MOON_GUI; } function toggleTheme() { @@ -149,8 +159,36 @@ function toast(m, isError) { } // ===== API ===== +async function cliRequest(line) { + var conn = state.currentConn; + if (!conn) throw new Error(__('未选择连接','No connection selected')); + if (!window.homeagent || !window.homeagent.cli) throw new Error('cli bridge unavailable'); + var resp = await window.homeagent.cli.request(conn.socketPath || conn.url, conn.apiKey, line); + if (resp && resp.error) throw new Error(resp.error); + return resp; +} + +// CLI 传输映射:将 REST 路径转换为 cli 内置命令或直接对话 +function cliMap(path, o) { + o = o || {}; + var m = o.method || 'GET'; + if (m === 'POST' && path.indexOf('/chat') !== -1) { + var body = {}; + try { body = JSON.parse(o.body || '{}'); } catch (e) {} + return cliRequest(body.message || ''); + } + if (path === '/status') return cliRequest('/status'); + if (path === '/kernel') return cliRequest('/kernel'); + if (path === '/settings') return cliRequest('/settings'); + if (path === '/chat/history') return Promise.resolve({ messages: [] }); + return Promise.reject(new Error(__('CLI 连接不支持此功能','Not supported on CLI connection'))); +} + async function api(p, o) { if (!state.currentConn) throw new Error(__('未选择连接','No connection selected')); + if (state.currentConn.type === 'cli') { + return cliMap(p, o); + } var opts = o || {}; var headers = { 'Content-Type': 'application/json', ...(opts.headers || {}) }; if (state.currentConn.apiKey) headers['X-API-Key'] = state.currentConn.apiKey; @@ -163,13 +201,19 @@ async function api(p, o) { } // ===== Navigation ===== -function switchTab(n) { - document.querySelectorAll('.tab-content').forEach(function(e) { e.classList.remove('active') }); - var el = document.getElementById('tab-' + n); +function switchView(n) { + document.querySelectorAll('.view').forEach(function(e) { e.classList.remove('active') }); + var el = document.getElementById('view-' + n); if (el) el.classList.add('active'); - document.querySelectorAll('nav a').forEach(function(e) { e.classList.remove('active') }); - var match = document.querySelector('nav a[onclick*="' + n + '"]'); - if (match) match.classList.add('active'); + document.querySelectorAll('.rail-btn').forEach(function(e) { e.classList.remove('active') }); + var rb = document.getElementById('rail-' + n); + if (rb) rb.classList.add('active'); + state.currentView = n; + if (n === 'chat') { + state.chatStick = true; + var msgsEl = document.getElementById('chat-msgs'); + if (msgsEl) { try { msgsEl.scrollTo({ top: msgsEl.scrollHeight, behavior: 'smooth' }) } catch(e) { msgsEl.scrollTop = msgsEl.scrollHeight } } + } renderAll(); } @@ -270,17 +314,41 @@ function renderOverview() { + statCard(__('内存','Memory'), k?.runtime?.memory_mb ? k.runtime.memory_mb + ' MB' : '-', '') + statCard('Go ' + __('版本','Version'), k?.runtime?.go_version || '-', '') + ''; - document.getElementById('tab-overview').innerHTML = html; + document.getElementById('view-overview').innerHTML = html; } // ===== Chat ===== var _chatLayoutBuilt = false; function buildChatLayout() { - var cont = document.getElementById('tab-chat'); + var cont = document.getElementById('view-chat'); var k = state.kernel || {}; - var html = '
'; - html += '

' + __('对话','Chat') + ' ' + escHtml(state.chatStage || '') + '

'; + var html = '
'; + html += '
' + + '' + __('对话','Chat') + '' + + '' + __('星图','Star Map') + '' + + '' + __('终端','Terminal') + '' + + '' + __('运行中命令','Running Commands') + '' + + '' + __('记忆','Memory') + '' + + '' + __('上下文','Context') + '' + + '' + __('知识','Knowledge') + '' + + '
'; + if (!state.currentConn) { + html += '
' + + '' + + '

' + __('未配置后端','No backend configured') + '

' + + '

' + __('连接 HomeAgent 服务端后即可开始对话。请在设置中添加后端连接。','Connect to a HomeAgent server to start chatting. Add a backend connection in Settings.') + '

' + + '' + + '
'; + for (var p2 = 0; p2 < 6; p2++) { + html += '

' + __('请先在设置中添加后端连接','Add a backend connection in Settings first') + '

'; + } + cont.innerHTML = html; + _chatLayoutBuilt = true; + return; + } + html += '
'; + html += '

' + __('对话','Chat') + '

'; if (state.messages.length === 0) { html += '

' + __('开始对话以测试 Agent 回复','Start a conversation to test Agent replies') + '

'; } @@ -288,35 +356,28 @@ function buildChatLayout() { + '
' + '' + '' - + '
'; - html += '
' - + '

' + __('星图','Star Map') + '

' - + '
' - + '

' + __('终端','Terminal') + ' 0

' - + '
' - + '

' + __('命令历史','Command History') + ' 0

' - + '
' - + '
' - + '' - + '
' + + '
'; + html += '

' + __('星图','Star Map') + '

' + + '
'; + html += '

' + __('终端','Terminal') + ' 0

' + + '
'; + html += '

' + __('运行中命令','Running Commands') + ' 0

' + + '
'; + html += '

' + __('记忆','Memory') + '

' + '
' + __('实体','Entities') + '' + (k?.memory?.entity_count || '-') + '
' + '
' + __('关系','Relations') + '' + (k?.memory?.relation_count || '-') + '
' + '
' + '' + '' + '
' - + '
' - + '
'; + html += '

' + __('上下文','Context') + '

' + '
' + '' + '' + '
' - + '
' - + '
'; + html += '

' + __('知识','Knowledge') + '

' + '
' + __('项目','Items') + '' + (k?.knowledge?.item_count || '-') + '
' + '
' + '' @@ -326,16 +387,67 @@ function buildChatLayout() { + '' + '' + '' - + '
'; + + '
'; cont.innerHTML = html; _chatLayoutBuilt = true; } +var CHAN_COLORS = ['#e08a5f', '#5f9fe0', '#6bbf8f', '#c06bbf', '#d9a13b', '#5fb3bf', '#b06b6b', '#7f8ce0']; + +function chanColor(src) { + var h = 0; + for (var i = 0; i < src.length; i++) h = (h * 31 + src.charCodeAt(i)) >>> 0; + return CHAN_COLORS[h % CHAN_COLORS.length]; +} + +function chanLetter(src) { + var s = (src || '').trim(); + if (!s) return 'C'; + var ch = s.charAt(0).toUpperCase(); + return /[A-Za-z0-9]/.test(ch) ? ch : 'C'; +} + function renderChat() { if (!_chatLayoutBuilt) { buildChatLayout(); renderChatStarmap(); renderTerminals(); renderCmdHistory() } var msgsEl = document.getElementById('chat-msgs'); if (!msgsEl) return; + if (!msgsEl._stickBound) { + msgsEl._stickBound = true; + msgsEl.addEventListener('scroll', function() { + state.chatStick = msgsEl.scrollHeight - msgsEl.scrollTop - msgsEl.clientHeight < 80; + }, { passive: true }); + } var msgs = state.messages; + var sig = msgs.map(function(m) { + var c = m.content || ''; + return (m.role || '') + ':' + c.length + ':' + c.slice(-40) + ':' + (m.tool_calls || []).map(function(t) { return (t.tool || t.name || '') + '/' + (t.status || '') }).join(','); + }).join('|') + '|L' + (state.chatLoading ? '1' : '0') + '|P' + (state.pendingTools || []).join(','); + if (msgsEl._chatSig === sig && msgsEl.childElementCount > 0) { return; } + msgsEl._chatSig = sig; + var prevPending = msgsEl._lastPending || []; + var newPending = (state.pendingTools || []).slice(); + var lastM = msgs.length ? msgs[msgs.length - 1] : null; + if (state.chatLoading && lastM && lastM.role === 'assistant') { + (lastM.tool_calls || []).forEach(function(tc) { + if (!tc.result && tc.status !== 'denied') { + var nm = tc.tool || tc.name || ''; + if (newPending.indexOf(nm) === -1) newPending.push(nm); + } + }); + } + var newlyDone = prevPending.filter(function(n) { return newPending.indexOf(n) === -1; }); + msgsEl._lastPending = newPending; + var streamingLast = !!(state.chatLoading && lastM && lastM.role === 'assistant' && !lastM._final); + function pillHtml() { + var s = ''; + newPending.forEach(function(nm) { + var anim = prevPending.indexOf(nm) !== -1 ? '' : ' pill-in'; + s += '' + + '' + + escHtml(nm) + ''; + }); + return s; + } var html = ''; if (msgs.length === 0) { html = '

' + __('开始对话以测试 Agent 回复','Start a conversation to test Agent replies') + '

'; @@ -350,44 +462,77 @@ function renderChat() { } else { c = escHtml(c); } + var isChan = !!(m.source && m.source !== 'webui'); var rc = ''; if (m.reasoning_content) { var rcBody = (typeof marked !== 'undefined' ? marked.parse(m.reasoning_content) : escHtml(m.reasoning_content)); - rc = '
' - + '
' + __('收起思考','Collapse') + '
' - + '
'; + rc = '
' + + '
' + __('展开思考','Expand') + '
' + + '
'; } var tcs = ''; if (m.tool_calls && m.tool_calls.length > 0) { m.tool_calls.forEach(function(tc) { var argsStr = typeof tc.args === 'object' ? JSON.stringify(tc.args, null, 1) : (tc.args || ''); - var resultStr = tc.result ? (typeof tc.result === 'object' ? JSON.stringify(tc.result, null, 1).substring(0, 200) : String(tc.result).substring(0, 200)) : ''; - var statusIcon = tc.status === 'denied' ? '⛔' : '🔧'; - tcs += '
' - + '
' + statusIcon + ' ' + escHtml(tc.tool || tc.name || '') + '
' + var resultStr = tc.result ? (typeof tc.result === 'object' ? JSON.stringify(tc.result, null, 1) : String(tc.result)) : ''; + var statusIcon = tc.status === 'denied' + ? '' + : ''; + tcs += '
' + + '
' + statusIcon + '' + escHtml(tc.tool || tc.name || '') + '' + + (tc.status === 'denied' + ? '' + __('已拒绝','Denied') + '' + : (resultStr + ? '' + __('完成','Done') + '' + : '' + __('调用中','Running') + '')) + + '
' + + ''; + + (resultStr ? '
' + escHtml(resultStr) + '
' : '') + + '
'; }); } - var body = rc + tcs + '
' + c + '
'; - if (m.source && m.source !== 'webui') { - body = '
' + escHtml(__('通道','Channel')) + ': ' + escHtml(m.source) + '
' + body; + var body = rc + tcs; + var growCls = m._grow ? ' grow-in' : ''; + if (m._grow) m._grow = false; + var isStreamingLast = i === msgs.length - 1 && streamingLast; + if (isStreamingLast) { + var liveRow = '' + (newPending.length ? '' + pillHtml() + '' : ''); + if (c) { + body += '
' + liveRow + '
' + c + '
'; + c = ''; + } else { + body += '
' + liveRow + '
'; + } + } else if (c) { + body += '
' + c + '
'; } if (role === 'system') { - html += '
' + body + '
'; + html += '
' + (c || '') + '
'; + } else if (isChan) { + html += '
' + + '
' + chanLetter(m.source) + '
' + + '
' + escHtml(m.source) + '
' + body + '
' + + '
'; } else { var userAvatar = ''; - var aiAvatar = '' + __('小宅','Agent') + ''; + var aiAvatar = '' + __('小宅','Agent') + ''; html += '
' + '
' + (role === 'user' ? userAvatar : aiAvatar) + '
' - + '
' + body + '
' + + '
' + body + '
' + '
'; } }); } + if (state.chatLoading && !streamingLast) { + var aiAvatar2 = '' + __('小宅','Agent') + ''; + html += '
' + aiAvatar2 + '
' + + '' + + (newPending.length ? '' + pillHtml() + '' : '') + + '
'; + } msgsEl.innerHTML = html; - msgsEl.scrollTop = msgsEl.scrollHeight; + if (state.chatStick !== false) { try { msgsEl.scrollTo({ top: msgsEl.scrollHeight, behavior: 'smooth' }) } catch(e) { msgsEl.scrollTop = msgsEl.scrollHeight } } updateChatBadge(); } @@ -395,11 +540,19 @@ function updateChatBadge() { var badge = document.getElementById('chat-stage'); if (!badge) return; badge.textContent = state.chatStage || ''; - badge.style.display = state.chatLoading ? 'inline' : 'none'; + badge.style.display = 'none'; } function rerenderChat() { renderChat(); renderChatStarmap(); renderTerminals(); renderCmdHistory() } +function toggleToolCall(el) { + var d = el.querySelector('.tc-detail'); + if (!d) return; + var open = d.style.display !== 'none'; + d.style.display = open ? 'none' : 'block'; + if (open) { el.classList.remove('open'); } else { el.classList.add('open'); } +} + function renderChatStarmap() { var cont = document.getElementById('sm-container-chat'); if (!cont) return; @@ -674,6 +827,7 @@ async function sendChat() { var btn = document.getElementById('chat-send-btn'); var text = inp.value.trim(); if (!text || state.chatLoading) return; + state.chatStick = true; state.messages.push({ role: 'user', content: text }); inp.value = ''; rerenderChat(); @@ -690,7 +844,8 @@ async function sendChat() { if (last && last.role === 'assistant' && last._streaming) { console.log('[sendChat] updating existing streaming msg, tool_calls before:', last.tool_calls?.length); last.content = r.response || __('(无响应)','(no response)'); - last.reasoning_content = r.reasoning_content || ''; + last._grow = true; + if (!last.reasoning_content) last.reasoning_content = r.reasoning_content || ''; last._final = true; delete last._streaming; } else { @@ -699,7 +854,8 @@ async function sendChat() { content: r.response || __('(无响应)','(no response)'), reasoning_content: r.reasoning_content, tool_calls: last && last.role === 'assistant' && last.tool_calls ? last.tool_calls : [], - _final: true + _final: true, + _grow: true }); } rerenderChat(); @@ -786,15 +942,19 @@ async function createKnowledgeChat() { } } -function switchChatSub(tab, el) { - var cards = { - 'memory': document.getElementById('chat-sub-memory'), - 'context': document.getElementById('chat-sub-context'), - 'knowledge': document.getElementById('chat-sub-knowledge') +function switchChatPanel(tab, el) { + var panels = { + 'chat': document.getElementById('chat-panel-chat'), + 'starmap': document.getElementById('chat-panel-starmap'), + 'terminal': document.getElementById('chat-panel-terminal'), + 'cmd': document.getElementById('chat-panel-cmd'), + 'memory': document.getElementById('chat-panel-memory'), + 'context': document.getElementById('chat-panel-context'), + 'knowledge': document.getElementById('chat-panel-knowledge') }; - Object.keys(cards).forEach(function(k) { - var c = cards[k]; - if (c) c.style.display = k === tab ? 'block' : 'none'; + Object.keys(panels).forEach(function(k) { + var p = panels[k]; + if (p) p.classList.toggle('active', k === tab); }); if (el) { var parent = el.parentElement; @@ -803,7 +963,12 @@ function switchChatSub(tab, el) { el.classList.add('active'); } } + if (tab === 'starmap') { renderChatStarmap(); onStarmapResize(); } + if (tab === 'terminal') renderTerminals(); + if (tab === 'cmd') renderCmdHistory(); + if (tab === 'memory') queryMemoryChat(); if (tab === 'context') queryMemoryContext(); + if (tab === 'knowledge') searchKnowledgeChat(); } async function loadChatHistory() { @@ -818,6 +983,15 @@ async function loadCmdHistory() { try { var data = await api('/cmd/history'); if (data && data.history) state.cmdHistory = data.history } catch(e) {} } +function appendTermBuf(el, text) { + if (!text) return; + el.textContent += text; + if (el.textContent.length > 262144) { + el.textContent = el.textContent.slice(el.textContent.length - 262144); + } + el.scrollTop = el.scrollHeight; +} + function renderTerminals() { var r = document.getElementById('term-list'); var cnt = document.getElementById('term-count-badge'); @@ -831,23 +1005,26 @@ function renderTerminals() { var html = ''; list.forEach(function(t, i) { var detailId = 'term-detail-' + i; + var scr = (state.termScreens && state.termScreens[t.id]) || null; + var running = scr ? scr.running : !!t.running; + var fullOut = scr ? scr.output : t.output || ''; + if (!fullOut) { + fullOut = '' + __('[终端暂无输出]','[No terminal output]') + ''; + } else { + fullOut = escHtml(fullOut); + } html += '
'; html += '
'; html += '' + escHtml(t.id || '-') + ''; - html += '' + escHtml(t.command || '') + ''; - html += '' + (t.running ? __('运行中','Running') : __('已关闭','Closed')) + ''; + html += '' + escHtml(t.command || '') + ''; + html += '' + (running ? __('运行中','Running') : __('已关闭','Closed')) + ''; html += '' + escHtml(t.created_at || '') + ''; html += '
'; html += '
'; + html += '
'; + html += '
' + escHtml(t.id) + '' + escHtml(t.command || '') + '' + escHtml(t.uptime || '') + '
'; + html += '
' + fullOut + '
'; + html += '
'; }); r.innerHTML = html; } @@ -856,19 +1033,24 @@ function renderCmdHistory() { var r = document.getElementById('cmd-list'); var cnt = document.getElementById('cmd-count-badge'); if (!r) return; - var list = state.cmdHistory || []; - if (cnt) cnt.textContent = list.length; - if (list.length === 0) { - r.innerHTML = '

' + __('暂无命令记录','No command history') + '

'; + var running = (state.terminals || []).filter(function(t) { return t.running; }); + if (cnt) cnt.textContent = running.length; + if (running.length === 0) { + r.innerHTML = '

' + __('暂无运行中的命令','No running commands') + '

'; return; } - var html = ''; - list.slice().reverse().slice(0, 50).forEach(function(c) { + var html = '
' + __('命令','Command') + '' + __('状态','Status') + '' + __('时间','Time') + '
'; + running.forEach(function(t) { + var scr = (state.termScreens && state.termScreens[t.id]) || null; + var out = scr ? scr.output : t.output || ''; html += '' - + '' - + '' - + '' + + '' + + '' + + '' + ''; + if (out) { + html += ''; + } }); html += '
' + __('命令','Command') + '' + __('状态','Status') + '' + __('运行时长','Uptime') + '
' + escHtml(c.command || '') + '' + escHtml(c.status || '') + '' + escHtml((c.time || '').substring(0, 19)) + '' + escHtml(t.command || t.id || '') + '' + __('运行中','Running') + '' + escHtml(t.uptime || '-') + '
' + escHtml(out.substring(0, 2000)) + '
'; r.innerHTML = html; @@ -936,7 +1118,7 @@ function renderPlugins() { html += '

' + __('点击上方按钮运行','Click the button above to run') + '

'; } html += ''; - document.getElementById('tab-plugins').innerHTML = html; + document.getElementById('view-plugins').innerHTML = html; } async function loadInstalledPlugins() { @@ -1029,7 +1211,7 @@ function renderHealthResult(r) { // ===== Kernel ===== function renderKernel() { var k = state.kernel; - if (!k) { document.getElementById('tab-kernel').innerHTML = '

' + __('内核未响应','Kernel not responding') + '

'; return } + if (!k) { document.getElementById('view-kernel').innerHTML = '

' + __('内核未响应','Kernel not responding') + '

'; return } var html = '

' + __('运行时','Runtime') + '

' + statCard('Goroutines', k?.runtime?.goroutines || '-', '') + statCard(__('内存','Memory'), k?.runtime?.memory_mb ? k.runtime.memory_mb + ' MB' : '-', '') @@ -1053,7 +1235,7 @@ function renderKernel() { html += '

' + __('无','None') + '

'; } html += '
'; - document.getElementById('tab-kernel').innerHTML = html; + document.getElementById('view-kernel').innerHTML = html; } // ===== Star Map ===== @@ -1224,16 +1406,16 @@ function pluginDisplayName(p) { return name; } -function renderSettingsSidebar() { - var el = document.querySelector('.settings-sidebar'); +function renderSettingsTabs() { + var el = document.getElementById('settings-tabs'); if (!el) return; el.innerHTML = ''; state.settingsPlugins.forEach(function(p) { - var a = document.createElement('a'); - a.textContent = pluginDisplayName(p); - if (p === state.selectedSection) a.className = 'active'; - a.onclick = function() { state.selectedSection = p; renderOneSettings() }; - el.appendChild(a); + var s = document.createElement('span'); + s.textContent = pluginDisplayName(p); + if (p === state.selectedSection) s.className = 'active'; + s.onclick = function() { state.selectedSection = p; renderOneSettings() }; + el.appendChild(s); }); } @@ -1262,7 +1444,10 @@ function renderOneSettings() { var regularKeys = filtered.filter(function(k) { return !k.startsWith('core.llm.sources.') && hideTopLlms.indexOf(k) === -1 && !k.startsWith('plugin.mcp.servers.') && k !== 'plugin.mcp.servers'; }); - var html = '
'; + var html = '

' + __('后端连接','Backend Connections') + '

' + + '
' + + '
' + + '
'; if (regularKeys.length === 0 && Object.keys(sourceMap).length === 0 && Object.keys(mcpServerMap).length === 0 && state.selectedSection !== 'plugin.mcp') { html += '

' + escHtml(state.selectedSection) + '

' + __('暂无设置项','No settings') + '

'; } else { @@ -1371,8 +1556,9 @@ function renderOneSettings() { } } html += '
'; - document.getElementById('tab-settings').innerHTML = html; - renderSettingsSidebar(); + document.getElementById('view-settings').innerHTML = html; + renderSettingsTabs(); + renderConnSection(); } function markDirty(k) { @@ -1401,7 +1587,7 @@ async function saveSetting(k) { } function renderConfigDisabled() { - document.getElementById('tab-settings').innerHTML = '

' + __('设置','Settings') + '

' + __('设置面板已加载','Settings panel loaded') + '

'; + document.getElementById('view-settings').innerHTML = '

' + __('设置','Settings') + '

' + __('设置面板已加载','Settings panel loaded') + '

'; renderOneSettings(); } @@ -1458,8 +1644,7 @@ async function deleteMCPServer(name) { // ===== Adapters ===== async function renderAdapters() { - var html = '

' + __('Lua 适配器管理','Lua Adapter Management') + '

' - + '

' + __('上传自定义 Lua 适配器脚本以支持新的 LLM 提供商。脚本文件将保存到适配器目录并自动加载到 Lua VM。','Upload custom Lua adapter scripts to support new LLM providers. Scripts are saved to the adapter directory and auto-loaded into the Lua VM.') + '

'; + var html = ''; try { var r = await api('/adapters'); var adapters = r.adapters || []; @@ -1485,7 +1670,7 @@ async function renderAdapters() { } catch(e) { html += '

' + __('加载适配器失败: ','Failed to load adapters: ') + escHtml(e.message) + '

'; } - document.getElementById('tab-adapters').innerHTML = html; + document.getElementById('view-adapters').innerHTML = html; } async function uploadAdapter() { @@ -1515,32 +1700,125 @@ async function deleteAdapter(name) { state.connections = data.connections || []; if (data.currentId) state.currentConn = state.connections.find(function(c) { return c.id === data.currentId }) || null; if (state.currentConn) { - document.getElementById('app').style.display = 'block'; connectSSE(); await loadChatHistory(); doRenderAll(); startUptimeTicker(); setInterval(doRenderAll, 15000); } else { - document.getElementById('conn-overlay').style.display = 'flex'; + renderAll(); + updateConnIndicator(); } - renderConnList(); })(); // ===== Connection Management ===== function updateConnIndicator() { var el = document.getElementById('conn-name-display'); var dot = document.getElementById('conn-dot'); + var rdot = document.getElementById('rail-conn-dot'); if (state.currentConn) { el.textContent = state.currentConn.name; - dot.className = 'status-dot ' + (state.status.status === 'running' ? 'dot-green pulse' : 'dot-yellow'); + var cls = state.status.status === 'running' ? 'dot-green pulse' : 'dot-yellow'; + dot.className = 'status-dot ' + cls; + if (rdot) rdot.className = 'conn-dot ' + (state.status.status === 'running' ? 'dot-green' : 'dot-yellow'); } else { - el.textContent = '未连接'; + el.textContent = __('未连接','Not connected'); dot.className = 'status-dot dot-gray'; + if (rdot) rdot.className = 'conn-dot'; } } -function openConnManager() { renderConnList(); document.getElementById('conn-overlay').style.display = 'flex'; } +function goSettingsConn() { + switchView('settings'); + renderConnSection(); +} + +function openConnManager() { renderConnSection(); switchView('settings'); } + +function renderConnSection() { + var cont = document.getElementById('conn-manager'); + if (!cont) return; + cont.innerHTML = ''; + if (state.connections.length === 0) { + cont.innerHTML += '

' + __('暂无后端连接,添加一个以开始使用','No backend connections yet. Add one to get started.') + '

'; + } else { + state.connections.forEach(function(c) { + var div = document.createElement('div'); + div.className = 'conn-item ' + (state.currentConn && state.currentConn.id === c.id ? 'active' : ''); + div.innerHTML = '' + + '
' + escHtml(c.name) + '
' + escHtml(c.url) + '
' + + '
' + + ' ' + + ' ' + + '
'; + cont.appendChild(div); + }); + } + var form = document.createElement('div'); + form.className = 'conn-form'; + form.id = 'conn-form'; + form.style.display = 'none'; + form.innerHTML = '

' + __('添加连接','Add Connection') + '

' + + '' + + '' + + '
' + + '' + + '' + + '' + + '
' + + '' + + '
'; + cont.appendChild(form); + var addBtn = document.createElement('button'); + addBtn.className = 'btn btn-primary'; + addBtn.id = 'conn-add-btn'; + addBtn.textContent = '+ ' + __('添加连接','Add Connection'); + addBtn.style.marginTop = '8px'; + addBtn.onclick = showConnForm; + cont.appendChild(addBtn); +} + +function toggleConnType() { + var t = document.getElementById('conn-type').value; + document.getElementById('conn-addr-webui').style.display = t === 'cli' ? 'none' : 'block'; + document.getElementById('conn-addr-cli').style.display = t === 'cli' ? 'block' : 'none'; +} + +function showConnForm() { + editingConnId = null; + document.getElementById('conn-form-title').textContent = __('添加连接','Add Connection'); + document.getElementById('conn-name').value = ''; + document.getElementById('conn-url').value = 'http://localhost:18080'; + document.getElementById('conn-sock').value = ''; + document.getElementById('conn-key').value = ''; + document.getElementById('conn-type').value = 'webui'; + toggleConnType(); + document.getElementById('conn-form').style.display = 'block'; + document.getElementById('conn-add-btn').style.display = 'none'; +} + +function editConnection(id, e) { + if (e) e.stopPropagation(); + var c = state.connections.find(function(x) { return x.id === id }); + if (!c) return; + editingConnId = id; + document.getElementById('conn-form-title').textContent = __('编辑连接','Edit Connection'); + document.getElementById('conn-name').value = c.name; + document.getElementById('conn-url').value = c.url || 'http://localhost:18080'; + document.getElementById('conn-sock').value = c.socketPath || ''; + document.getElementById('conn-key').value = c.apiKey; + document.getElementById('conn-type').value = c.type === 'cli' ? 'cli' : 'webui'; + toggleConnType(); + document.getElementById('conn-form').style.display = 'block'; + document.getElementById('conn-add-btn').style.display = 'none'; +} + +function cancelConnForm() { + document.getElementById('conn-form').style.display = 'none'; + document.getElementById('conn-add-btn').style.display = 'block'; +} async function selectConnection(id) { if (state.eventSource) { state.eventSource.close(); state.eventSource = null; } @@ -1548,18 +1826,18 @@ async function selectConnection(id) { state.currentConn = data.connections.find(function(c) { return c.id === id }) || null; state.connections = data.connections; state.messages = []; - document.getElementById('app').style.display = 'block'; - document.getElementById('conn-overlay').style.display = 'none'; updateConnIndicator(); connectSSE(); await loadChatHistory(); doRenderAll(); startUptimeTicker(); + switchView('chat'); + renderConnSection(); } async function deleteConnection(id, e) { - e.stopPropagation(); - if (!confirm('确定删除此连接?')) return; + if (e) e.stopPropagation(); + if (!confirm(__('确定删除此连接?','Delete this connection?'))) return; var wasCurrent = state.currentConn && state.currentConn.id === id; var data = await window.homeagent.connections.delete(id); state.connections = data.connections; @@ -1568,89 +1846,67 @@ async function deleteConnection(id, e) { if (state.currentConn) { updateConnIndicator(); doRenderAll(); connectSSE(); } else { - document.getElementById('app').style.display = 'none'; - document.getElementById('conn-overlay').style.display = 'flex'; + updateConnIndicator(); } - renderConnList(); -} - -function renderConnList() { - var list = document.getElementById('conn-list'); - if (!list) return; - list.innerHTML = state.connections.map(function(c) { - return '
' - + '' - + '
' + escHtml(c.name) + '
' + escHtml(c.url) + '
' - + '
' - + '' - + '
'; - }).join(''); + renderConnSection(); } var editingConnId = null; -function showConnForm() { - editingConnId = null; - document.getElementById('conn-form-title').textContent = __('添加连接','Add Connection'); - document.getElementById('conn-name').value = ''; - document.getElementById('conn-url').value = 'http://localhost:8080'; - document.getElementById('conn-key').value = ''; - document.getElementById('conn-form').style.display = 'block'; - document.getElementById('conn-add-btn').style.display = 'none'; -} - -function editConnection(id, e) { - e.stopPropagation(); - var c = state.connections.find(function(x) { return x.id === id }); - if (!c) return; - editingConnId = id; - document.getElementById('conn-form-title').textContent = __('编辑连接','Edit Connection'); - document.getElementById('conn-name').value = c.name; - document.getElementById('conn-url').value = c.url; - document.getElementById('conn-key').value = c.apiKey; - document.getElementById('conn-form').style.display = 'block'; - document.getElementById('conn-add-btn').style.display = 'none'; - document.querySelectorAll('.conn-item').forEach(function(el) { el.style.opacity = '0.4' }); -} - -function cancelConnForm() { - document.getElementById('conn-form').style.display = 'none'; - document.getElementById('conn-add-btn').style.display = 'block'; - document.querySelectorAll('.conn-item').forEach(function(el) { el.style.opacity = '1' }); -} - async function saveConnForm() { var name = document.getElementById('conn-name').value.trim(); + var ctype = document.getElementById('conn-type').value; var url = document.getElementById('conn-url').value.trim().replace(/\/+$/, ''); + var sock = document.getElementById('conn-sock').value.trim(); var apiKey = document.getElementById('conn-key').value.trim(); - if (!name || !url) { toast(__('名称和地址不能为空','Name and URL required'), true); return; } + if (ctype === 'cli') { + if (!name || !sock) { toast(__('名称和 Socket 路径不能为空','Name and Socket Path required'), true); return; } + } else { + if (!name || !url) { toast(__('名称和地址不能为空','Name and URL required'), true); return; } + } var testBtn = document.querySelector('#conn-form .btn-primary'); testBtn.textContent = __('测试中...','Testing...'); testBtn.disabled = true; try { - var testR = await fetch(url + '/api/v1/status', { headers: apiKey ? { 'X-API-Key': apiKey } : {} }); - if (!testR.ok) { toast(__('连接测试失败: HTTP ','Connection test failed: HTTP ') + testR.status, true); testBtn.textContent = __('保存 / Save','Save'); testBtn.disabled = false; return; } + if (ctype === 'cli') { + if (!window.homeagent.cli) throw new Error('cli bridge unavailable'); + var testR = await window.homeagent.cli.request(sock, apiKey, '/status'); + if (testR.error || testR.type === 'error') { + toast(__('CLI 连接测试失败: ','CLI test failed: ') + (testR.error || testR.type), true); + testBtn.textContent = __('保存','Save'); testBtn.disabled = false; return; + } + } else { + var testR = await fetch(url + '/api/v1/status', { headers: apiKey ? { 'X-API-Key': apiKey } : {} }); + if (!testR.ok) { toast(__('连接测试失败: HTTP ','Connection test failed: HTTP ') + testR.status, true); testBtn.textContent = __('保存','Save'); testBtn.disabled = false; return; } + } } catch(e) { - toast(__('无法连接到 ','Cannot connect to ') + url + ': ' + e.message, true); - testBtn.textContent = __('保存 / Save','Save'); testBtn.disabled = false; return; + toast(__('无法连接到 ','Cannot connect to ') + (ctype === 'cli' ? sock : url) + ': ' + e.message, true); + testBtn.textContent = __('保存','Save'); testBtn.disabled = false; return; } - testBtn.textContent = __('保存 / Save','Save'); testBtn.disabled = false; + testBtn.textContent = __('保存','Save'); testBtn.disabled = false; + var connData = ctype === 'cli' + ? { name: name, type: 'cli', socketPath: sock, url: '', apiKey: apiKey } + : { name: name, type: 'webui', url: url, apiKey: apiKey }; var data; if (editingConnId) { - data = await window.homeagent.connections.update(editingConnId, { name: name, url: url, apiKey: apiKey }); + data = await window.homeagent.connections.update(editingConnId, connData); } else { - data = await window.homeagent.connections.add({ name: name, url: url, apiKey: apiKey }); + data = await window.homeagent.connections.add(connData); } state.connections = data.connections; var cur = data.connections.find(function(c) { return c.id === data.currentId }); + var switched = !!cur && (!state.currentConn || state.currentConn.id !== cur.id); if (cur) { state.currentConn = cur; - if (!document.getElementById('app').style.display || document.getElementById('app').style.display === 'none') { - document.getElementById('app').style.display = 'block'; - document.getElementById('conn-overlay').style.display = 'none'; + if (switched) { + if (state.eventSource) { state.eventSource.close(); state.eventSource = null; } + state.messages = []; updateConnIndicator(); connectSSE(); await loadChatHistory(); doRenderAll(); startUptimeTicker(); - } else { updateConnIndicator(); if (editingConnId) doRenderAll(); } + switchView('chat'); + } else { + updateConnIndicator(); doRenderAll(); + } } - cancelConnForm(); renderConnList(); + cancelConnForm(); renderConnSection(); } document.addEventListener('keydown', function(e) { @@ -1661,6 +1917,8 @@ document.addEventListener('keydown', function(e) { connectSSE = function() { if (state.eventSource) { state.eventSource.close(); state.eventSource = null; } if (!state.currentConn) return; + // CLI 连接无 SSE 通道,聊天走同步 cli:request + if (state.currentConn.type === 'cli') return; connectFetchSSE(state.currentConn.url + '/api/v1/chat/events'); }; @@ -1688,39 +1946,72 @@ async function connectFetchSSE(url) { var ev = JSON.parse(raw); var p = ev.payload || {}; if (type === 'agent_output') { state.chatStage = __('AI 回复中...','AI replying...'); - if (state.messages.length > 0 && state.messages[state.messages.length - 1].role === 'assistant' && !state.messages[state.messages.length - 1]._final) { - state.messages[state.messages.length - 1].content += (p.content || ''); + if (p.kind === 'channel_output') { + state.messages.push({ role: 'assistant', content: p.content || '', source: p.channel || '', _final: true, _grow: true }); rerenderChatIfActive(); return; } - state.messages.push({ role: 'assistant', content: p.content || '', _streaming: true }); + var last = state.messages.length > 0 ? state.messages[state.messages.length - 1] : null; + if (last && last.role === 'assistant' && !last._final) { + last._grow = true; + last.content += (p.content || ''); + rerenderChatIfActive(); return; + } + if (last && last.role === 'assistant' && last._final) { return; } + state.messages.push({ role: 'assistant', content: p.content || '', _streaming: true, _grow: true }); rerenderChatIfActive(); } else if (type === 'reasoning') { - if (p.content && state.messages.length > 0) { - var last = state.messages[state.messages.length - 1]; - if (last.role === 'assistant') { - state.chatStage = __('AI 思考中...','AI thinking...'); - last.reasoning_content = (last.reasoning_content || '') + (p.content || ''); - rerenderChatIfActive(); + if (p.content) { + state.chatStage = __('AI 思考中...','AI thinking...'); + var last = state.messages.length > 0 ? state.messages[state.messages.length - 1] : null; + if (!last || last.role !== 'assistant' || last._final) { + state.messages.push({ role: 'assistant', content: '', reasoning_content: '', tool_calls: [], _streaming: true }); + last = state.messages[state.messages.length - 1]; } + last.reasoning_content = (last.reasoning_content || '') + (p.content || ''); + rerenderChatIfActive(); } } else if (type === 'tool_call') { if (!p.tool) return; var last = state.messages.length > 0 ? state.messages[state.messages.length - 1] : null; - if (!last || last.role !== 'assistant') { + if (!last || last.role !== 'assistant' || last._final) { state.messages.push({ role: 'assistant', content: '', tool_calls: [], _streaming: true }); last = state.messages[state.messages.length - 1]; } if (!last.tool_calls) last.tool_calls = []; last.tool_calls.push({ tool: p.tool, name: p.tool, args: p.args || {}, result: p.result || '', status: p.status || 'ok', plugin: p.plugin || '' }); + var pidx = (state.pendingTools || []).indexOf(p.tool); + if (pidx !== -1) state.pendingTools.splice(pidx, 1); state.chatStage = __('工具调用: ','Tool: ') + (p.tool || ''); rerenderChatIfActive(); + } else if (type === 'terminal_output') { + if (!p.terminal_id) return; + var tid = p.terminal_id; + if (!state.termScreens) state.termScreens = {}; + var scr = state.termScreens[tid] || (state.termScreens[tid] = { output: '', running: true }); + if (p.output) scr.output += p.output; + if (typeof p.running === 'boolean') scr.running = p.running; + var bufel = document.getElementById('term-buf-' + tid); + if (bufel) { + appendTermBuf(bufel, p.output || ''); + var dot = document.getElementById('term-dot-' + tid); + if (dot) dot.className = 'term-dot' + (scr.running ? '' : ' stopped'); + } } else if (type === 'stage') { var phase = p.phase || ''; var tool = p.tool || ''; - if (phase === 'pre_action') state.chatStage = __('AI 思考中...','AI thinking...'); - else if (phase === 'before_toolcall') state.chatStage = __('工具调用: ','Tool: ') + (tool || ''); - else if (phase === 'before_output') state.chatStage = __('生成回复中...','Generating response...'); + if (p.channel !== '_consolidation_') { + if (phase === 'pre_action') state.chatStage = __('AI 思考中...','AI thinking...'); + else if (phase === 'before_toolcall') { + state.chatStage = __('工具调用: ','Tool: ') + (tool || ''); + if (tool && (state.pendingTools || []).indexOf(tool) === -1) { + if (!state.pendingTools) state.pendingTools = []; + state.pendingTools.push(tool); + rerenderChatIfActive(); + } + } + else if (phase === 'before_output') state.chatStage = __('生成回复中...','Generating response...'); + } var badge = document.getElementById('chat-stage'); - if (badge) { badge.textContent = state.chatStage || ''; badge.style.display = state.chatLoading ? 'inline' : 'none' } + if (badge) { badge.textContent = state.chatStage || ''; badge.style.display = 'none'; } } } catch(err) {} } @@ -1735,7 +2026,7 @@ async function connectFetchSSE(url) { } function rerenderChatIfActive() { - var tab = document.getElementById('tab-chat'); + var tab = document.getElementById('view-chat'); if (tab && tab.classList.contains('active')) { renderChat(); renderChatStarmap(); renderTerminals(); renderCmdHistory(); } } diff --git a/cmd/gui/renderer/icon.svg b/cmd/gui/renderer/icon.svg new file mode 100644 index 0000000..66dfe03 --- /dev/null +++ b/cmd/gui/renderer/icon.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/cmd/gui/renderer/index.html b/cmd/gui/renderer/index.html index d08778d..6a215ad 100644 --- a/cmd/gui/renderer/index.html +++ b/cmd/gui/renderer/index.html @@ -4,6 +4,7 @@ HomeAgent + @@ -12,56 +13,67 @@ - -
-
-

连接管理 / Connections

-
- - +
+
+ + +
- - diff --git a/cmd/gui/renderer/mascot.svg b/cmd/gui/renderer/mascot.svg deleted file mode 100644 index 838dd1b..0000000 --- a/cmd/gui/renderer/mascot.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/cmd/gui/renderer/mascot.webp b/cmd/gui/renderer/mascot.webp new file mode 100644 index 0000000..9c00640 Binary files /dev/null and b/cmd/gui/renderer/mascot.webp differ diff --git a/cmd/gui/renderer/style.css b/cmd/gui/renderer/style.css index 6988baa..4064ea4 100644 --- a/cmd/gui/renderer/style.css +++ b/cmd/gui/renderer/style.css @@ -1,316 +1,496 @@ :root { - --bg-primary: #0f172a; - --bg-secondary: #1e293b; - --bg-card: #1e293b; - --bg-input: #0f172a; - --bg-hover: rgba(15,23,42,0.25); - --text-primary: #e2e8f0; - --text-secondary: #94a3b8; - --text-muted: #64748b; - --border-color: #334155; - --accent: #38bdf8; - --accent-bg: #1e3a5f; - --toast-bg: #166534; - --toast-color: #86efac; - --toast-error-bg: #7f1d1d; - --toast-error-color: #fca5a5; - --pre-color: #a5b4fc; - --pre-bg: #0f172a; - --chat-bg: #0f172a; - --msg-user-bg: #1e3a5f; - --msg-user-color: #93c5fd; - --msg-assistant-bg: #1a3a2a; - --msg-assistant-color: #86efac; - --msg-system-bg: #3b1a3a; - --msg-system-color: #f0abfc; - --kv-border: #1e293b; - --btn-ghost-border: #334155; - --btn-ghost-hover-bg: #1e293b; - --save-btn-border: #eab308; - --loading-border: #334155; - --loading-top: #38bdf8; + --sakura-100: #ffe4e9; + --sakura-200: #ffcdd9; + --sakura-300: #ff9eb5; + --sakura-400: #ff7fac; + --sakura-500: #f33b7c; + --sakura-600: #c92462; + --sakura-700: #991b4b; + --frost-100: #d7e8ee; + --frost-200: #a9d6e3; + --frost-300: #88c0d0; + --frost-400: #5ea8bf; + --frost-500: #3f88a3; + --success: #17a964; + --warning: #d99a2b; + --error: #db3694; + --info: #3f6ef5; + --glass-bg: rgba(13, 18, 34, 0.6); + --glass-bg-strong: rgba(13, 18, 34, 0.82); + --glass-blur: 14px; + --glass-border: rgba(255, 255, 255, 0.08); + --glass-hover: rgba(255, 255, 255, 0.05); + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.25); + --shadow-md: 0 4px 16px rgba(0, 0, 0, 0.28); + --shadow-lg: 0 12px 36px rgba(0, 0, 0, 0.38); + --shadow-glow: 0 0 0 1px rgba(255, 127, 172, 0.4), 0 4px 20px rgba(255, 127, 172, 0.18); + --radius-sm: 6px; + --radius-md: 10px; + --radius-lg: 14px; + --radius-pill: 999px; + --font-sans: "Segoe UI", "Segoe UI Variable Text", -apple-system, BlinkMacSystemFont, Roboto, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei UI", "Microsoft YaHei", sans-serif; + --font-mono: "JetBrains Mono", ui-monospace, "SFMono-Regular", "SF Mono", "Menlo", "Consolas", monospace; + --ease-out: cubic-bezier(0.22, 1, 0.36, 1); + --dur-micro: 150ms; + --dur-normal: 300ms; + --bg-primary: #0b1020; + --bg-secondary: rgba(17, 24, 44, 0.72); + --bg-card: rgba(17, 24, 44, 0.6); + --bg-input: rgba(13, 18, 34, 0.75); + --bg-hover: rgba(255, 255, 255, 0.06); + --text-primary: #eef1f8; + --text-secondary: #a7b0c4; + --text-muted: #77809a; + --border-color: rgba(255, 255, 255, 0.09); + --accent: #ff7fac; + --accent-bg: rgba(255, 127, 172, 0.14); + --toast-bg: rgba(23, 169, 100, 0.16); + --toast-color: #6ee7a8; + --toast-error-bg: rgba(219, 54, 148, 0.18); + --toast-error-color: #ff9ec6; + --pre-color: #cdd3f5; + --pre-bg: rgba(10, 14, 28, 0.85); + --chat-bg: rgba(10, 14, 28, 0.7); + --msg-user-bg: rgba(255, 127, 172, 0.16); + --msg-user-color: #ffb9d0; + --msg-assistant-bg: rgba(136, 192, 208, 0.14); + --msg-assistant-color: #a9d6e3; + --msg-system-bg: rgba(63, 110, 245, 0.16); + --msg-system-color: #a3b8ff; + --msg-bubble-bg: #161b2e; + --msg-bubble-color: #e9edf6; + --msg-bubble-border: rgba(255, 255, 255, 0.1); + --kv-border: rgba(255, 255, 255, 0.07); + --btn-ghost-border: rgba(255, 255, 255, 0.14); + --btn-ghost-hover-bg: rgba(255, 255, 255, 0.07); + --save-btn-border: #d99a2b; + --loading-border: rgba(255, 255, 255, 0.12); + --loading-top: #ff7fac; } [data-theme=light] { - --bg-primary: #f8fafc; - --bg-secondary: #ffffff; - --bg-card: #ffffff; - --bg-input: #f1f5f9; - --bg-hover: rgba(241,245,249,0.8); - --text-primary: #1e293b; - --text-secondary: #64748b; - --text-muted: #94a3b8; - --border-color: #e2e8f0; - --accent: #2563eb; - --accent-bg: #dbeafe; - --toast-bg: #166534; - --toast-color: #86efac; - --toast-error-bg: #7f1d1d; - --toast-error-color: #fca5a5; - --pre-color: #1e293b; - --pre-bg: #f1f5f9; - --chat-bg: #f1f5f9; - --msg-user-bg: #dbeafe; - --msg-user-color: #1e40af; - --msg-assistant-bg: #dcfce7; - --msg-assistant-color: #166534; - --msg-system-bg: #f3e8ff; - --msg-system-color: #7c3aed; - --kv-border: #e2e8f0; - --btn-ghost-border: #e2e8f0; - --btn-ghost-hover-bg: #f1f5f9; - --save-btn-border: #eab308; - --loading-border: #e2e8f0; - --loading-top: #2563eb; + --glass-bg: rgba(255, 255, 255, 0.66); + --glass-bg-strong: rgba(255, 255, 255, 0.88); + --glass-border: rgba(255, 127, 172, 0.22); + --glass-hover: rgba(255, 127, 172, 0.07); + --shadow-sm: 0 1px 3px rgba(153, 27, 75, 0.08); + --shadow-md: 0 6px 20px rgba(153, 27, 75, 0.1); + --shadow-lg: 0 16px 40px rgba(153, 27, 75, 0.14); + --shadow-glow: 0 0 0 1px rgba(243, 59, 124, 0.3), 0 6px 22px rgba(243, 59, 124, 0.14); + --bg-primary: #fdf3f7; + --bg-secondary: rgba(255, 255, 255, 0.78); + --bg-card: rgba(255, 255, 255, 0.72); + --bg-input: rgba(255, 224, 233, 0.55); + --bg-hover: rgba(255, 127, 172, 0.08); + --text-primary: #3b2030; + --text-secondary: #7a5c6b; + --text-muted: #a48a96; + --border-color: rgba(201, 36, 98, 0.14); + --accent: #c92462; + --accent-bg: #ffe4e9; + --toast-bg: rgba(23, 169, 100, 0.14); + --toast-color: #128a52; + --toast-error-bg: rgba(219, 54, 148, 0.12); + --toast-error-color: #c2185b; + --pre-color: #5c4060; + --pre-bg: #fff0f5; + --chat-bg: #fff0f5; + --msg-user-bg: #ffe4e9; + --msg-user-color: #c2185b; + --msg-assistant-bg: #e3f2f6; + --msg-assistant-color: #2f7188; + --msg-bubble-bg: #ffffff; + --msg-bubble-color: #262a33; + --msg-bubble-border: rgba(201, 36, 98, 0.14); + --msg-system-bg: #e6ecfe; + --msg-system-color: #3f6ef5; + --kv-border: rgba(201, 36, 98, 0.1); + --btn-ghost-border: rgba(201, 36, 98, 0.22); + --btn-ghost-hover-bg: #ffe4e9; + --save-btn-border: #d99a2b; + --loading-border: rgba(201, 36, 98, 0.18); + --loading-top: #c92462; } -* { margin:0; padding:0; box-sizing:border-box; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif } -body { background:var(--bg-primary); color:var(--text-primary); min-height:100vh; overflow-x:hidden; transition:background .2s,color .2s } -nav { background:var(--bg-secondary); padding:0 24px; display:flex; align-items:center; gap:4px; border-bottom:1px solid var(--border-color); height:48px; position:sticky; top:0; z-index:100; transition:background .2s,border .2s } -nav h1 { font-size:16px; font-weight:700; color:var(--accent); margin-right:24px; white-space:nowrap } -nav a { padding:12px 16px; color:var(--text-secondary); text-decoration:none; font-size:13px; cursor:pointer; border-bottom:2px solid transparent; transition:color .12s,border-color .12s } -nav a:hover { color:var(--text-primary) } -nav a.active { color:var(--accent); border-bottom-color:var(--accent) } -.theme-btn { background:none; border:1px solid var(--border-color); color:var(--text-secondary); cursor:pointer; padding:4px 8px; border-radius:6px; font-size:14px; line-height:1; margin-right:8px; transition:all .15s } -.theme-btn:hover { color:var(--accent); border-color:var(--accent) } -.container { padding:20px 24px; max-width:1440px; margin:0 auto } -.card { background:var(--bg-card); border:1px solid var(--border-color); border-radius:10px; padding:20px; margin-bottom:16px; transition:background .2s,border .2s } -.card h2 { font-size:15px; font-weight:600; margin-bottom:12px; color:var(--text-primary) } -.card h3 { font-size:13px; font-weight:600; color:var(--text-secondary); margin:16px 0 8px } +* { margin:0; padding:0; box-sizing:border-box; font-family:var(--font-sans) } +.selectable, .msg .text, .msg .tc-args, .msg .tc-result, .msg .reasoning-body, pre, input, textarea, .term-buf { + user-select: text; + -webkit-user-select: text; +} +body { + background: + radial-gradient(900px 700px at 85% -10%, rgba(255,127,172,0.14), transparent 60%), + radial-gradient(800px 600px at -10% 20%, rgba(136,192,208,0.12), transparent 60%), + radial-gradient(700px 500px at 50% 110%, rgba(243,59,124,0.1), transparent 60%), + var(--bg-primary); + background-attachment: fixed; + color: var(--text-primary); + min-height: 100vh; + overflow-x: hidden; + transition: background .2s, color .2s; + font-size: 14px; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; + user-select: none; + -webkit-user-select: none; +} +#app { display:flex; height:100vh; overflow:hidden } + +/* ===== 沉浸式标题栏 ===== */ +.titlebar { + position: fixed; top: 0; left: 0; right: 0; height: 36px; z-index: 1100; + display: flex; align-items: center; + background: var(--bg-secondary); + backdrop-filter: blur(var(--glass-blur)) saturate(1.4); + -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(1.4); + border-bottom: 1px solid var(--glass-border); + -webkit-app-region: drag; user-select: none; +} +.titlebar-title { font-size: 12px; font-weight: 600; color: var(--text-muted); letter-spacing: .02em; padding-left: 12px; display: flex; align-items: center; gap: 8px } +.titlebar-logo { width: 18px; height: 18px; border-radius: 50%; object-fit: cover; flex-shrink: 0 } +.titlebar-controls { margin-left: auto; display: flex; height: 36px; -webkit-app-region: no-drag } +.tb-btn { + width: 46px; height: 36px; border: none; background: transparent; + color: var(--text-secondary); display: flex; align-items: center; justify-content: center; + cursor: pointer; transition: background .13s; +} +.tb-btn:hover { background: var(--glass-hover); color: var(--text-primary) } +.tb-btn.tb-close:hover { background: #e81123; color: #fff } +body.maximized .tb-max svg { transform: scale(.85) } + +/* ===== Icon Rail (主视图导航) ===== */ +.rail { + width: 56px; + flex-shrink: 0; + background: var(--bg-secondary); + backdrop-filter: blur(var(--glass-blur)) saturate(1.4); + -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(1.4); + border-right: 1px solid var(--glass-border); + display: flex; + flex-direction: column; + align-items: center; + padding: 12px 0; + gap: 4px; + position: sticky; + top: 36px; + height: calc(100% - 36px); + z-index: 100; +} +.rail .rail-logo { width:34px; height:34px; margin-bottom:10px; overflow:hidden } +.rail .rail-logo img { width:100%; height:100%; border-radius:50%; object-fit:cover } +.rail .rail-btn { + width: 38px; height: 38px; + display: flex; align-items: center; justify-content: center; + border: none; background: transparent; color: var(--text-muted); + cursor: pointer; border-radius: var(--radius-md); + transition: all .15s; position: relative; +} +.rail .rail-btn svg { width:20px; height:20px } +.rail .rail-btn:hover { color: var(--text-primary); background: var(--glass-hover) } +.rail .rail-btn.active { color: var(--accent); background: var(--accent-bg) } +.rail .rail-btn.active::before { + content: ""; position: absolute; left: -8px; top: 8px; bottom: 8px; width: 3px; + background: var(--accent); border-radius: var(--radius-pill); +} +.rail .rail-btn[title]:hover::after { + content: attr(title); + position: absolute; left: 46px; top: 50%; transform: translateY(-50%); + background: var(--glass-bg-strong); backdrop-filter: blur(8px); + color: var(--text-primary); font-size: 12px; padding: 4px 10px; + border-radius: var(--radius-sm); border: 1px solid var(--glass-border); + white-space: nowrap; z-index: 200; pointer-events: none; +} +.rail .rail-spacer { flex: 1 } +.rail .conn-dot { + width: 10px; height: 10px; border-radius: 50%; + background: var(--text-muted); margin-bottom: 4px; +} +.rail .conn-dot.dot-green { background: var(--success); box-shadow: 0 0 8px rgba(23,169,100,.6) } +.rail .conn-dot.dot-red { background: var(--error) } +.rail .conn-dot.dot-yellow { background: var(--warning) } + +/* ===== Main ===== */ +.main { + flex: 1; min-width: 0; min-height: 0; + display: flex; flex-direction: column; + overflow: hidden; + padding-top: 36px; +} +.topbar { + display: flex; align-items: center; gap: 12px; + padding: 10px 24px; + background: var(--bg-secondary); + backdrop-filter: blur(var(--glass-blur)) saturate(1.4); + -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(1.4); + border-bottom: 1px solid var(--glass-border); + position: sticky; top: 0; z-index: 50; + flex-shrink: 0; +} +.topbar h1 { font-size: 16px; font-weight: 700; color: var(--accent); white-space: nowrap } +.topbar .conn-indicator { + display: flex; align-items: center; gap: 6px; cursor: pointer; + padding: 4px 10px; border-radius: var(--radius-pill); + font-size: 12px; color: var(--text-secondary); + border: 1px solid var(--glass-border); + transition: all .15s; user-select: none; +} +.topbar .conn-indicator:hover { background: var(--glass-hover); color: var(--text-primary) } +.topbar .spacer { flex: 1 } +.theme-btn { + background: none; border: 1px solid var(--border-color); + color: var(--text-secondary); cursor: pointer; + padding: 4px 8px; border-radius: var(--radius-sm); font-size: 14px; line-height: 1; + transition: all .15s; +} +.theme-btn:hover { color: var(--accent); border-color: var(--accent) } +.lang-btn { font-size: 13px; min-width: 28px; cursor: pointer; padding: 4px 8px; border: 1px solid var(--border-color); border-radius: var(--radius-sm); text-align: center; background:none; color:var(--text-secondary) } +.lang-btn:hover { color: var(--accent); border-color: var(--accent) } +.container { padding: 16px 24px; flex: 1; min-width: 0; min-height: 0; overflow: hidden } +.view { display: none; height: 100%; overflow-y: auto } +.view.active { display: block; animation: viewIn .18s var(--ease-out) } +@keyframes viewIn { from { opacity: 0; transform: translateY(6px) } to { opacity: 1; transform: translateY(0) } } + +/* ===== Cards ===== */ +.card { + background: var(--bg-card); + backdrop-filter: blur(var(--glass-blur)) saturate(1.3); + -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(1.3); + border: 1px solid var(--glass-border); + border-radius: var(--radius-lg); + padding: 20px; + margin-bottom: 16px; + box-shadow: var(--shadow-sm); + transition: background .2s, border .2s, box-shadow .2s; +} +.card:hover { box-shadow: var(--shadow-md) } +.card h2 { font-size: 15px; font-weight: 600; margin-bottom: 12px; color: var(--text-primary) } +.card h3 { font-size: 13px; font-weight: 600; color: var(--text-secondary); margin: 16px 0 8px } .grid-2 { display:grid; grid-template-columns:1fr 1fr; gap:16px } .grid-3 { display:grid; grid-template-columns:1fr 1fr 1fr; gap:16px } .grid-4 { display:grid; grid-template-columns:repeat(4,1fr); gap:16px } -.stat-value { font-size:26px; font-weight:700; color:var(--accent) } -.stat-label { font-size:11px; color:var(--text-muted); margin-top:2px } -.stat-card { padding:16px 20px; transition:transform .12s } -.stat-card:hover { transform:translateY(-2px) } -.card:hover h2 { transition:color .12s } +.stat-value { font-size: 26px; font-weight: 700; color: var(--accent) } +.stat-label { font-size: 11px; color: var(--text-muted); margin-top: 2px } +.stat-card { padding: 16px 20px; transition: transform .12s var(--ease-out) } +.stat-card:hover { transform: translateY(-2px) } .status-dot { display:inline-block; width:8px; height:8px; border-radius:50%; margin-right:6px } -.dot-green { background:#22c55e } -.dot-green.pulse { animation:pulseDot 2s ease-in-out infinite } -.dot-yellow { background:#eab308 } -.dot-red { background:#ef4444 } -.dot-gray { background:#475569 } -@keyframes pulseDot { - 0%,100% { opacity:1; transform:scale(1) } - 50% { opacity:.6; transform:scale(1.3) } -} +.dot-green { background: var(--success) } +.dot-green.pulse { animation: pulseDot 2s ease-in-out infinite } +.dot-yellow { background: var(--warning) } +.dot-red { background: var(--error) } +.dot-gray { background: #475569 } +@keyframes pulseDot { 0%,100% { opacity:1; transform:scale(1) } 50% { opacity:.6; transform:scale(1.3) } } table { width:100%; border-collapse:collapse; font-size:13px } th { text-align:left; padding:8px 10px; color:var(--text-muted); font-weight:500; border-bottom:1px solid var(--border-color); font-size:11px; text-transform:uppercase; letter-spacing:.5px } td { padding:8px 10px; border-bottom:1px solid var(--kv-border) } -tr:hover td { background:var(--bg-hover) } +tr:hover td { background: var(--bg-hover) } .badge { display:inline-block; padding:2px 8px; border-radius:4px; font-size:11px; font-weight:500 } -.badge-green { background:#166534; color:#86efac } -.badge-red { background:#7f1d1d; color:#fca5a5 } -.badge-yellow { background:#713f12; color:#fde68a } -.badge-blue { background:#1e3a5f; color:#93c5fd } -.btn { padding:6px 14px; border-radius:6px; border:none; font-size:12px; cursor:pointer; font-weight:500; transition:background .12s,color .12s,border-color .12s } -.btn:active { transform:scale(.97) } -.btn-primary { background:var(--accent); color:#fff } -.btn-primary:hover { background:#60c8f8 } -.btn-danger { background:#dc2626; color:#fff } -.btn-danger:hover { background:#b91c1c } -.btn-sm { padding:4px 10px; font-size:11px } -.btn-ghost { background:transparent; border:1px solid var(--btn-ghost-border); color:var(--text-secondary) } -.btn-ghost:hover { background:var(--btn-ghost-hover-bg); color:var(--text-primary) } -.tab-content { display:none } -.tab-content.active { display:block; animation:tabIn .15s ease } -@keyframes tabIn { from { opacity:0 } to { opacity:1 } } -input,textarea,select { background:var(--bg-input); border:1px solid var(--border-color); border-radius:6px; padding:8px 12px; color:var(--text-primary); font-size:13px; width:100%; margin-bottom:10px; outline:none; transition:border .15s,background .2s,color .2s } -input:focus,textarea:focus,select:focus { border-color:var(--accent) } -textarea { resize:vertical; min-height:80px; font-family:monospace; font-size:12px } -label { display:block; font-size:11px; color:var(--text-secondary); margin-bottom:3px; font-weight:500 } -pre { background:var(--pre-bg); border-radius:6px; padding:12px; font-size:12px; overflow-x:auto; color:var(--pre-color); font-family:monospace; max-height:400px; overflow-y:auto } -code { font-family:monospace; font-size:12px; color:var(--pre-color) } -.settings-layout { display:flex; gap:20px; min-height:60vh } -.settings-sidebar { width:200px; flex-shrink:0; background:var(--bg-card); border:1px solid var(--border-color); border-radius:10px; padding:8px 0; overflow-y:auto; max-height:70vh } -.settings-sidebar a { display:block; padding:9px 16px; color:var(--text-secondary); font-size:13px; cursor:pointer; text-decoration:none; border-left:3px solid transparent; transition:all .1s } -.settings-sidebar a:hover { background:var(--bg-primary); color:var(--text-primary) } -.settings-sidebar a.active { background:var(--bg-primary); color:var(--accent); border-left-color:var(--accent) } -.settings-content { flex:1; min-width:0 } -.settings-key { font-family:monospace; font-size:11px; color:var(--text-muted); margin-bottom:2px } -.reasoning { border-left:2px solid #888; padding-left:12px; margin:8px 0; font-size:12px; color:#999 } -.reasoning-title { cursor:pointer; font-size:11px; color:#666; font-weight:600; user-select:none; margin-bottom:4px } -.reasoning-body { color:#999; line-height:1.5 } -.reasoning-body p { margin:4px 0 } -.msg-content .text h1, -.msg-content .text h2, -.msg-content .text h3 { font-size:1em; margin:8px 0 4px; color:var(--text-primary) } -.msg-content .text p { margin:4px 0; line-height:1.5 } -.msg-content .text ul, -.msg-content .text ol { padding-left:20px; margin:4px 0 } -.msg-content .text li { margin:2px 0 } -.msg-content .text code { background:var(--pre-bg); padding:1px 4px; border-radius:3px; font-size:11px } -.msg-content .text pre { background:var(--pre-bg); border-radius:6px; padding:10px; margin:8px 0; overflow-x:auto; font-size:11px; max-height:300px } -.msg-content .text pre code { background:none; padding:0 } -.msg-content .text blockquote { border-left:3px solid var(--border-color); padding-left:10px; margin:8px 0; color:var(--text-secondary) } -.msg-content .text table { border-collapse:collapse; margin:8px 0; font-size:12px; width:100% } -.msg-content .text th, -.msg-content .text td { border:1px solid var(--border-color); padding:4px 8px; text-align:left } -.msg-content .text img { max-width:100%; border-radius:6px } -.toast { position:fixed; bottom:20px; right:20px; background:var(--toast-bg); color:var(--toast-color); padding:10px 20px; border-radius:8px; font-size:13px; display:none; z-index:100; box-shadow:0 4px 12px rgba(0,0,0,.3); animation:toastIn .15s ease } -.toast.error { background:var(--toast-error-bg); color:var(--toast-error-color) } -@keyframes toastIn { - from { opacity:0; transform:translateX(30px) } - to { opacity:1; transform:translateX(0) } +.badge-green { background: rgba(23,169,100,.18); color: #6ee7a8 } +.badge-red { background: rgba(219,54,148,.18); color: #ff9ec6 } +.badge-yellow { background: rgba(217,154,43,.18); color: #fcd9a0 } +.badge-blue { background: rgba(63,110,245,.18); color: #a3b8ff } +.btn { + padding: 6px 14px; border-radius: var(--radius-md); border: none; + font-size: 12px; cursor: pointer; font-weight: 500; + transition: all .12s var(--ease-out); } -.empty-state { text-align:center; padding:40px 20px; color:var(--text-muted) } -.empty-state p { font-size:14px; margin-bottom:8px } -.empty-state .icon { font-size:36px; margin-bottom:12px; opacity:.5 } -.chat-layout { display:flex; gap:16px; height:calc(100vh - 100px); min-height:60vh; overflow:hidden } -.chat-main { flex:2; min-width:0; min-height:0; display:flex; flex-direction:column } -.chat-main .card { flex:1; display:flex; flex-direction:column; margin-bottom:0; min-height:0 } -.chat-main .card h2 { flex-shrink:0 } -.chat-messages { flex:1; overflow-y:auto; padding:12px; border:1px solid var(--border-color); border-radius:8px; background:var(--chat-bg); margin-bottom:0; display:flex; flex-direction:column; gap:4px; min-height:0 } -.msg { display:flex; gap:8px; margin-bottom:2px; align-items:flex-start; max-width:85%; animation:msgIn .15s ease both } -.msg-user { flex-direction:row-reverse; align-self:flex-end } -.msg-assistant { align-self:flex-start } -.msg-system { align-self:center; max-width:90% } -@keyframes msgIn { - from { opacity:0; transform:translateY(6px) } - to { opacity:1; transform:translateY(0) } +.btn:active { transform: scale(.97) } +.btn-primary { background: var(--accent); color: #fff } +.btn-primary:hover { background: var(--sakura-500) } +.btn-danger { background: var(--error); color: #fff } +.btn-danger:hover { background: var(--sakura-700) } +.btn-sm { padding: 4px 10px; font-size: 11px } +.btn-ghost { background: transparent; border: 1px solid var(--btn-ghost-border); color: var(--text-secondary) } +.btn-ghost:hover { background: var(--btn-ghost-hover-bg); color: var(--text-primary) } +input, textarea, select { + background: var(--bg-input); border: 1px solid var(--border-color); + border-radius: var(--radius-sm); padding: 8px 12px; + color: var(--text-primary); font-size: 13px; width: 100%; + margin-bottom: 10px; outline: none; + transition: border .15s, background .2s, color .2s; } -.msg-avatar { width:28px; height:28px; border-radius:6px; display:flex; align-items:center; justify-content:center; font-size:12px; flex-shrink:0 } -.msg-avatar img { width:28px; height:28px; border-radius:50%; object-fit:cover; display:block } -.msg-avatar svg { display:block; width:16px; height:16px } -.msg-user .msg-avatar { background:var(--msg-user-bg); color:var(--msg-user-color) } -.msg-assistant .msg-avatar { background:transparent } -.msg-system .msg-avatar { background:var(--msg-system-bg); color:var(--msg-system-color) } -.msg-content { min-width:0 } -.msg-bubble { padding:8px 12px; border-radius:10px; font-size:13px; line-height:1.5; word-break:break-word; position:relative } -.msg-source { font-size:11px; opacity:.55; margin-bottom:4px; color:inherit } -.msg-user .msg-bubble { background:var(--msg-user-bg); color:var(--msg-user-color); border-bottom-right-radius:4px } -.msg-assistant .msg-bubble { background:var(--msg-assistant-bg); color:var(--msg-assistant-color); border-bottom-left-radius:4px } -.msg-system .msg-bubble { background:var(--msg-system-bg); color:var(--msg-system-color); text-align:center; font-size:12px } -.msg-bubble .text { white-space:pre-wrap } -.msg-bubble .text p { margin:4px 0 } -.msg-bubble .text h1,.msg-bubble .text h2,.msg-bubble .text h3 { font-size:1em; margin:8px 0 4px } -.msg-bubble .text ul,.msg-bubble .text ol { padding-left:20px; margin:4px 0 } -.msg-bubble .text pre { background:var(--pre-bg); border-radius:6px; padding:8px; margin:4px 0; overflow-x:auto; font-size:11px; max-height:200px } -.msg-bubble .text code { background:var(--pre-bg); padding:1px 4px; border-radius:3px; font-size:11px } -.msg-bubble .text pre code { background:none; padding:0 } -.msg-bubble .text blockquote { border-left:3px solid var(--border-color); padding-left:8px; margin:4px 0; opacity:0.8 } -.msg-bubble .text table { border-collapse:collapse; margin:4px 0; font-size:12px; width:100% } -.msg-bubble .text th,.msg-bubble .text td { border:1px solid var(--border-color); padding:3px 6px; text-align:left } -.msg-bubble .text img { max-width:100%; border-radius:6px } -.msg-bubble .reasoning { border-left:2px solid rgba(255,255,255,0.2); padding-left:8px; margin:6px 0; font-size:11px; opacity:0.7 } -.msg-bubble .reasoning-title { cursor:pointer; font-size:10px; font-weight:600; user-select:none; margin-bottom:2px; opacity:0.6 } -.msg-bubble .reasoning-body { line-height:1.4 } -.msg-bubble .tool-call { background:rgba(0,0,0,0.15); border-radius:6px; padding:6px 8px; margin:4px 0; font-size:11px; border-left:2px solid var(--accent) } -.msg-bubble .tool-call .tc-name { font-weight:600; color:var(--accent) } -.msg-bubble .tool-call .tc-args { font-family:monospace; font-size:10px; opacity:0.7; white-space:pre-wrap; word-break:break-all; margin-top:2px } -.msg-bubble .tool-call .tc-result { font-family:monospace; font-size:10px; opacity:0.6; white-space:pre-wrap; word-break:break-all; margin-top:2px; max-height:80px; overflow-y:auto } -.chat-input-row { display:flex; gap:8px; flex-shrink:0; padding-top:10px } -.chat-input-row input { flex:1; margin-bottom:0 } -.chat-input-row button { flex-shrink:0; margin-bottom:0 } -.chat-sidebar { flex:1; min-width:240px; max-width:340px; overflow-y:auto; display:flex; flex-direction:column; gap:12px } -.chat-sidebar .card { margin-bottom:0 } -#sm-container-chat { height:180px } -.loading { display:inline-block; width:16px; height:16px; border:2px solid var(--loading-border); border-radius:50%; border-top-color:var(--loading-top); animation:spin .6s linear infinite } -@keyframes spin { to { transform:rotate(360deg) } } -.monaco-like { font-family:monospace; font-size:12px; background:var(--bg-input); border:1px solid var(--border-color); border-radius:6px } -.kv-row { display:flex; padding:6px 0; border-bottom:1px solid var(--kv-border); font-size:13px } -.kv-row .key { color:var(--text-muted); width:180px; flex-shrink:0 } -.kv-row .val { color:var(--text-primary); word-break:break-all } -.tool-badge { display:inline-block; padding:1px 6px; border-radius:3px; font-size:10px; background:var(--accent-bg); color:var(--accent); margin:1px } -.check-pass { color:#86efac } -.check-fail { color:#fca5a5 } -.check-skip { color:var(--text-secondary) } -.memory-graph { width:100%; height:300px; background:var(--bg-input); border-radius:8px; border:1px solid var(--border-color); position:relative; overflow:hidden; display:flex; align-items:center; justify-content:center; color:var(--text-muted); font-size:13px } -.health-panel { display:grid; gap:8px } -.health-item { display:flex; align-items:center; gap:10px; padding:8px 12px; background:var(--bg-input); border-radius:6px; font-size:13px } -.health-item .check-name { flex:1 } -.health-item .check-status { font-size:11px; font-weight:500 } -.fade-in { animation:fadeIn .2s ease } -@keyframes fadeIn { from { opacity:0; transform:translateY(4px) } to { opacity:1; transform:translateY(0) } } -#starmap-container { width:100%; height:calc(100vh - 88px); position:relative; overflow:hidden; border-radius:10px; border:1px solid var(--border-color); background:var(--bg-input) } -#starmap-container canvas { display:block } -#starmap-stats { position:absolute; top:16px; left:16px; background:rgba(10,10,26,0.85); padding:12px 16px; border-radius:8px; border:1px solid rgba(100,100,255,0.3); font-size:13px; z-index:10; backdrop-filter:blur(10px); color:#ccc } -#starmap-stats h3 { margin-bottom:6px; color:#4488ff; font-size:14px } -#starmap-stats p { margin:2px 0; color:#888; font-size:12px } -#starmap-stats span { color:#fff; font-weight:700 } -#starmap-info { position:absolute; top:16px; right:16px; background:rgba(10,10,26,0.9); padding:12px 16px; border-radius:8px; border:1px solid rgba(100,100,255,0.3); font-size:13px; z-index:10; display:none; backdrop-filter:blur(10px); color:#ccc; max-width:260px } -#starmap-info h3 { color:#44ff88; margin-bottom:6px; font-size:14px } -#starmap-info p { margin:2px 0; color:#888; font-size:12px } -#starmap-info .label { color:#666 } -#starmap-loading { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); font-size:18px; color:#4488ff; z-index:20 } -.starmap-toggle { position:absolute; bottom:16px; left:50%; transform:translateX(-50%); display:flex; gap:8px; z-index:10 } -.starmap-toggle button { background:rgba(10,10,26,0.85); border:1px solid rgba(100,100,255,0.3); color:#aaa; padding:6px 14px; border-radius:6px; cursor:pointer; font-size:12px; font-family:inherit; backdrop-filter:blur(10px); transition:all .2s } -.starmap-toggle button:hover { background:rgba(68,136,255,0.2); color:#fff; border-color:rgba(68,136,255,0.6) } -.starmap-toggle button.on { background:rgba(68,136,255,0.3); color:#4488ff; border-color:#4488ff } -#sm-container-chat { height:260px; background:var(--bg-input); border-radius:6px; border:1px solid var(--border-color); overflow:hidden; position:relative } -#sm-container-chat canvas { display:block } +input:focus, textarea:focus, select:focus { border-color: var(--accent) } +textarea { resize: vertical; min-height: 80px; font-family: var(--font-mono); font-size: 12px } +label { display: block; font-size: 11px; color: var(--text-secondary); margin-bottom: 3px; font-weight: 500 } +pre { background: var(--pre-bg); border-radius: var(--radius-sm); padding: 12px; font-size: 12px; overflow-x: auto; color: var(--pre-color); font-family: var(--font-mono); max-height: 400px; overflow-y: auto } +code { font-family: var(--font-mono); font-size: 12px; color: var(--pre-color) } +.empty-state { text-align: center; padding: 40px 20px; color: var(--text-muted) } +.empty-state p { font-size: 14px; margin-bottom: 8px } -.toggle-row { margin-top:8px; display:flex; align-items:center; gap:12px } -.toggle-row .label-text { color:#8888aa; font-size:12px } -.toggle-switch { position:relative; width:36px; height:20px; cursor:pointer; flex-shrink:0 } -.toggle-track { position:absolute; inset:0; background:rgba(60,60,80,0.8); border-radius:10px; transition:all 0.3s; border:1px solid rgba(100,100,255,0.2) } -.toggle-track.on { background:rgba(68,136,255,0.5); border-color:#4488ff } -.toggle-knob { position:absolute; width:16px; height:16px; left:2px; top:2px; background:#6666aa; border-radius:50%; transition:all 0.3s } -.toggle-knob.on { left:18px; background:#4488ff } -.toggle-btn { display:flex; align-items:center; gap:4px; padding:2px 8px; border-radius:4px; border:1px solid rgba(100,100,255,0.15); background:transparent; color:#8888aa; font-size:12px; font-family:inherit; cursor:pointer; transition:all 0.2s } -.toggle-btn:hover { background:rgba(68,136,255,0.15); color:#fff } -.toggle-btn.on { background:rgba(68,136,255,0.3); color:#4488ff; border-color:#4488ff } -.label-text { color:#8888aa; font-size:12px } -.loading-spinner { width:32px; height:32px; border:3px solid rgba(68,136,255,0.15); border-top:3px solid #4488ff; border-radius:50%; animation:spin 0.8s linear infinite } -@keyframes spin { to { transform:rotate(360deg) } } +/* ===== Chat (对话主页) ===== */ +.chat-layout { display: flex; flex-direction: column; gap: 12px; height: 100%; min-height: 60vh } +.chat-tabs { display: flex; gap: 4px; flex-wrap: wrap; border-bottom: 1px solid var(--border-color); padding-bottom: 10px } +.chat-tabs span { padding: 6px 14px; font-size: 13px; cursor: pointer; color: var(--text-muted); border-radius: var(--radius-pill); border: 1px solid transparent; transition: all .15s } +.chat-tabs span:hover { color: var(--text-primary); background: var(--bg-hover) } +.chat-tabs span.active { color: var(--accent); background: var(--accent-bg); border-color: rgba(255,127,172,.35) } +.chat-panel { display: none; flex: 1; min-height: 0; flex-direction: column } +.chat-panel.active { display: flex } +.chat-main { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column } +.chat-main .card { flex: 1; display: flex; flex-direction: column; margin-bottom: 0; min-height: 0; background: transparent; border: none; box-shadow: none; backdrop-filter: none; padding: 0 } +.chat-main .card h2 { flex-shrink: 0 } +.chat-messages { flex: 1; overflow-y: auto; padding: 18px 18px 26px; margin-bottom: 0; display: flex; flex-direction: column; gap: 6px; min-height: 0 } +.msg { display: flex; gap: 8px; margin-bottom: 2px; align-items: flex-start; max-width: 100% } +.msg-user { flex-direction: row-reverse; align-self: flex-end } +.msg-assistant { align-self: flex-start } +.msg-system { align-self: center; max-width: 90% } +.msg-avatar { width: 28px; height: 28px; border-radius: 50%; overflow: hidden; display: flex; align-items: center; justify-content: center; font-size: 12px; flex-shrink: 0 } +.msg-avatar img { width: 100%; height: 100%; object-fit: cover; display: block } +.msg-assistant .msg-avatar img { transform: scale(1.5) } +.msg-avatar svg { display: block; width: 16px; height: 16px } +.msg-user .msg-avatar { background: var(--msg-user-bg); color: var(--msg-user-color) } +.msg-assistant .msg-avatar { background: transparent } +.msg-system .msg-avatar { background: var(--msg-system-bg); color: var(--msg-system-color) } +.msg-content { min-width: 0; flex: 1 } + .msg-content .msg-bubble + .msg-bubble { margin-top: 6px } + .msg-channel { align-self: flex-start } + .msg-channel .msg-avatar.chan-avatar { color: #ffffff; font-weight: 700; font-size: 13px; text-transform: uppercase } + .msg-channel .msg-chan-name { font-size: 10px; color: var(--text-muted); opacity: .8; margin-bottom: 2px; padding-left: 4px; letter-spacing: .5px } + .msg-channel .msg-bubble { background: var(--msg-bubble-bg); color: var(--msg-bubble-color); border-bottom-left-radius: 4px; border: 1px solid var(--msg-bubble-border); box-shadow: 0 2px 8px rgba(0,0,0,.28) } +.msg-bubble { padding: 9px 12px; border-radius: var(--radius-md); font-size: 15px; line-height: 1.62; word-break: break-word; position: relative } +.msg-source { font-size: 11px; opacity: .55; margin-bottom: 4px; color: inherit } +.msg-user .msg-bubble { background: var(--msg-bubble-bg); color: var(--msg-bubble-color); border-bottom-right-radius: 4px; border: 1px solid var(--msg-bubble-border) } +.msg-assistant .msg-bubble { background: var(--msg-bubble-bg); color: var(--msg-bubble-color); border-bottom-left-radius: 4px; border: 1px solid var(--msg-bubble-border); box-shadow: 0 2px 10px rgba(0,0,0,.3) } +.msg-system .msg-bubble { background: var(--msg-system-bg); color: var(--msg-system-color); text-align: center; font-size: 12px; border: 1px solid var(--msg-bubble-border) } +.msg-bubble .text { white-space: pre-wrap } +.msg-bubble .text p { margin: 4px 0 } +.msg-bubble .text h1, .msg-bubble .text h2, .msg-bubble .text h3 { font-size: 1em; margin: 8px 0 4px } +.msg-bubble .text ul, .msg-bubble .text ol { padding-left: 20px; margin: 4px 0 } +.msg-bubble .text pre { background: var(--pre-bg); color: var(--pre-color); border-radius: var(--radius-sm); padding: 8px; margin: 4px 0; overflow-x: auto; font-size: 12px; line-height: 1.5; border: 1px solid var(--border-color); max-height: 200px } +.msg-bubble .text code { background: var(--pre-bg); color: var(--pre-color); padding: 1px 4px; border-radius: 3px; font-size: 12px } +.msg-bubble .text pre code { background: none; padding: 0 } +.msg-bubble .text blockquote { border-left: 3px solid var(--border-color); padding-left: 8px; margin: 4px 0; opacity: .8 } +.msg-bubble .text table { border-collapse: collapse; margin: 4px 0; font-size: 12px; width: 100% } +.msg-bubble .text th, .msg-bubble .text td { border: 1px solid var(--border-color); padding: 3px 6px; text-align: left } +.msg-bubble .text img { max-width: 100%; border-radius: var(--radius-sm) } +.msg-bubble .reasoning { border-left: 2px solid #dddddd; padding-left: 8px; margin: 6px 0; font-size: 11px; opacity: .8 } +.msg-bubble .reasoning-title { cursor: pointer; font-size: 10px; font-weight: 600; user-select: none; margin-bottom: 2px; opacity: .6 } +.msg-bubble .reasoning-body { line-height: 1.4 } +.msg-bubble .tool-call { background: var(--bg-hover); color: var(--text-secondary); border-radius: var(--radius-sm); padding: 7px 10px; margin: 6px 0; font-size: 11px; border: 1px solid var(--kv-border); border-left: 3px solid var(--accent); cursor: pointer } +.msg-bubble .tool-call .tc-line { display: flex; align-items: center; gap: 6px; font-size: 11px; user-select: none } +.msg-bubble .tool-call .tc-ico { display: inline-flex; flex-shrink: 0 } +.msg-bubble .tool-call .tc-name { font-weight: 600; color: var(--text-primary) } +.msg-bubble .tool-call .tc-state { margin-left: 6px; font-size: 9.5px; font-weight: 500; padding: 1px 6px; border-radius: 999px; flex-shrink: 0; margin-left: auto } +.msg-bubble .tool-call .tc-run { background: rgba(63,110,245,.18); color: #a3b8ff } +.msg-bubble .tool-call .tc-done { background: rgba(23,169,100,.18); color: #6ee7a8 } +.msg-bubble .tool-call .tc-deny { background: rgba(219,54,148,.18); color: #ff9ec6 } +.msg-bubble .tool-call .tc-caret { font-size: 10px; color: var(--text-muted); transition: transform .15s var(--ease-out) } +.msg-bubble .tool-call.open .tc-caret { transform: rotate(180deg) } +.msg-bubble .tool-call .tc-detail { margin-top: 6px } +.msg-bubble .tool-call .tc-args { font-family: var(--font-mono); font-size: 11px; opacity: .85; white-space: pre-wrap; word-break: break-all; margin-top: 5px; background: var(--pre-bg); color: var(--pre-color); border-radius: 4px; padding: 5px 7px } +.msg-bubble .tool-call .tc-result { font-family: var(--font-mono); font-size: 11px; opacity: .72; white-space: pre-wrap; word-break: break-all; margin-top: 5px; background: var(--pre-bg); color: var(--pre-color); border-radius: 4px; padding: 5px 7px; max-height: 80px; overflow-y: auto } +[data-theme=light] .msg-bubble .tool-call .tc-run { color: #3f6ef5 } +[data-theme=light] .msg-bubble .tool-call .tc-done { color: #128a52 } +[data-theme=light] .msg-bubble .tool-call .tc-deny { color: #c2185b } +.msg-bubble .thinking-tools { display: flex; gap: 6px; align-items: center; flex-wrap: wrap } +.msg-bubble .thinking-tool { display: inline-flex; align-items: center; gap: 5px; font-size: 11px; color: var(--text-secondary); background: var(--pre-bg); border-radius: 999px; padding: 3px 10px } +.msg-bubble .thinking-tool.pill-in { animation: pillIn .25s var(--ease-out) both } +@keyframes pillIn { from { opacity: 0; transform: translateX(8px) } to { opacity: 1; transform: none } } +.msg-bubble .live-spinner { width: 15px; height: 15px; border-radius: 50%; border: 2px solid var(--msg-user-bg); border-top-color: var(--accent); animation: spin .7s linear infinite; flex-shrink: 0; display: inline-block; vertical-align: middle } +.msg-bubble .live-spinner + .thinking-tools, +.msg-bubble .live-spinner + .text { margin-left: 8px } +.msg-bubble.grow-in { animation: bubbleGrow .5s var(--ease-out) both } +.msg-bubble.grow-in .text { animation: textFadeIn .35s ease .125s both } +@keyframes bubbleGrow { from { max-height: 28px; opacity: .4 } to { max-height: 3000px; opacity: 1 } } +@keyframes textFadeIn { from { opacity: 0 } to { opacity: 1 } } +.tool-call.tool-drip-in { animation: toolDripIn .3s var(--ease-out) both } +@keyframes toolDripIn { from { opacity: 0; transform: translateY(-14px) scale(.98) } to { opacity: 1; transform: none } } +.chat-input-row { display: flex; gap: 8px; flex-shrink: 0; padding-top: 10px } +.chat-input-row input { flex: 1; margin-bottom: 0 } +.chat-input-row button { flex-shrink: 0; margin-bottom: 0 } +#sm-container-chat { height: 480px; background: var(--bg-input); border-radius: var(--radius-md); border: 1px solid var(--glass-border); overflow: hidden; position: relative } +#sm-container-chat canvas { display: block } +.loading { display: inline-block; width: 16px; height: 16px; border: 2px solid var(--loading-border); border-radius: 50%; border-top-color: var(--loading-top); animation: spin .6s linear infinite } +.loading-spinner { width: 32px; height: 32px; border: 3px solid rgba(255,127,172,.15); border-top: 3px solid var(--accent); border-radius: 50%; animation: spin .8s linear infinite } +@keyframes spin { to { transform: rotate(360deg) } } -.sidebar-subnav { display:flex; gap:0; border-bottom:1px solid var(--border-color); margin-bottom:10px } -.sidebar-subnav span { padding:6px 12px; font-size:12px; cursor:pointer; color:var(--text-muted); border-bottom:2px solid transparent; transition:all .15s } -.sidebar-subnav span:hover { color:var(--text-primary) } -.sidebar-subnav span.active { color:var(--accent); border-bottom-color:var(--accent) } -@media(max-width:900px) { - .chat-layout { flex-direction:column; height:auto } - .chat-sidebar { max-width:none } - .msg { max-width:95% } +/* ===== 未配置后端引导 ===== */ +.setup-card { + text-align: center; padding: 48px 32px; + display: flex; flex-direction: column; align-items: center; gap: 16px; } +.setup-card .setup-icon { width: 64px; height: 64px; opacity: .8 } +.setup-card h2 { font-size: 18px; margin-bottom: 0 } +.setup-card p { color: var(--text-muted); font-size: 13px; max-width: 420px; line-height: 1.6 } +.setup-card .btn { padding: 8px 20px; font-size: 13px } + +/* ===== Settings ===== */ +.settings-tabs { display: flex; gap: 4px; flex-wrap: wrap; border-bottom: 1px solid var(--border-color); padding-bottom: 10px; margin-bottom: 16px } +.settings-tabs span { padding: 6px 14px; font-size: 13px; cursor: pointer; color: var(--text-muted); border-radius: var(--radius-pill); border: 1px solid transparent; transition: all .15s } +.settings-tabs span:hover { color: var(--text-primary); background: var(--bg-hover) } +.settings-tabs span.active { color: var(--accent); background: var(--accent-bg); border-color: rgba(255,127,172,.35) } +.settings-content { min-width: 0 } +.settings-key { font-family: var(--font-mono); font-size: 11px; color: var(--text-muted); margin-bottom: 2px } +.kv-row { display: flex; padding: 6px 0; border-bottom: 1px solid var(--kv-border); font-size: 13px } +.kv-row .key { color: var(--text-muted); width: 180px; flex-shrink: 0 } +.kv-row .val { color: var(--text-primary); word-break: break-all } +.tool-badge { display: inline-block; padding: 1px 6px; border-radius: 3px; font-size: 10px; background: var(--accent-bg); color: var(--accent); margin: 1px } +.check-pass { color: #6ee7a8 } +.check-fail { color: #ff9ec6 } +.check-skip { color: var(--text-secondary) } +.health-item { display: flex; align-items: center; gap: 10px; padding: 8px 12px; background: var(--bg-input); border-radius: var(--radius-sm); font-size: 13px } +.health-item .check-name { flex: 1 } +.health-item .check-status { font-size: 11px; font-weight: 500 } + +/* ===== 连接管理 (设置页内嵌) ===== */ +.conn-item { display: flex; align-items: center; gap: 12px; padding: 12px 16px; border: 1px solid var(--glass-border); border-radius: var(--radius-md); margin-bottom: 8px; cursor: pointer; transition: all .12s } +.conn-item:hover { background: var(--bg-hover); border-color: var(--accent) } +.conn-item.active { border-color: var(--accent); background: var(--accent-bg) } +.conn-item .conn-info { flex: 1; min-width: 0 } +.conn-item .conn-name { font-size: 14px; font-weight: 600; color: var(--text-primary) } +.conn-item .conn-url { font-size: 11px; color: var(--text-muted); margin-top: 2px } +.conn-item .conn-actions { display: flex; gap: 4px; flex-shrink: 0 } +.conn-form h3 { font-size: 15px; margin-bottom: 12px } +.conn-form-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 12px } + +/* ===== Toast ===== */ +.toast { + position: fixed; bottom: 20px; right: 20px; + background: var(--toast-bg); color: var(--toast-color); + backdrop-filter: blur(10px); + padding: 10px 20px; border-radius: var(--radius-md); font-size: 13px; + display: none; z-index: 300; + border: 1px solid rgba(23,169,100,.3); + box-shadow: var(--shadow-md); + animation: toastIn .15s var(--ease-out); +} +.toast.error { background: var(--toast-error-bg); color: var(--toast-error-color); border-color: rgba(219,54,148,.3) } +@keyframes toastIn { from { opacity: 0; transform: translateX(30px) } to { opacity: 1; transform: translateX(0) } } + +/* ===== Starmap ===== */ +#starmap-stats, #starmap-info { + position: absolute; top: 16px; + background: rgba(10,10,26,.85); padding: 12px 16px; border-radius: var(--radius-md); + border: 1px solid rgba(100,100,255,.3); font-size: 13px; z-index: 10; + backdrop-filter: blur(10px); color: #ccc; +} +#starmap-stats { left: 16px } +#starmap-info { right: 16px; display: none; max-width: 260px } +#starmap-stats h3, #starmap-info h3 { margin-bottom: 6px; font-size: 14px } +#starmap-stats h3 { color: #4488ff } +#starmap-info h3 { color: #44ff88 } +#starmap-stats p, #starmap-info p { margin: 2px 0; color: #888; font-size: 12px } +#starmap-stats span { color: #fff; font-weight: 700 } +#starmap-info .label { color: #666 } +#starmap-loading { position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%); font-size: 18px; color: #4488ff; z-index: 20 } +.starmap-toggle { position: absolute; bottom: 16px; left: 50%; transform: translateX(-50%); display: flex; gap: 8px; z-index: 10 } +.starmap-toggle button { background: rgba(10,10,26,.85); border: 1px solid rgba(100,100,255,.3); color: #aaa; padding: 6px 14px; border-radius: var(--radius-sm); cursor: pointer; font-size: 12px; font-family: inherit; backdrop-filter: blur(10px); transition: all .2s } +.starmap-toggle button:hover { background: rgba(68,136,255,.2); color: #fff; border-color: rgba(68,136,255,.6) } +.starmap-toggle button.on { background: rgba(68,136,255,.3); color: #4488ff; border-color: #4488ff } +.fade-in { animation: fadeIn .2s var(--ease-out) } +@keyframes fadeIn { from { opacity: 0; transform: translateY(4px) } to { opacity: 1; transform: translateY(0) } } + @media(max-width:768px) { - nav { padding:0 6px; gap:2px; overflow-x:auto; scrollbar-width:none; -ms-overflow-style:none; flex-wrap:nowrap } - nav::-webkit-scrollbar { display:none } - nav h1 { display:none } - nav a { padding:10px 8px; font-size:12px; white-space:nowrap; flex-shrink:0 } - nav > div { flex-shrink:0 } - #conn-name-display { display:none } - .conn-indicator { padding:4px 6px } - .container { padding:12px } - .card { padding:12px } - .grid-2,.grid-3,.grid-4 { grid-template-columns:1fr } - .stat-value { font-size:20px } - .settings-layout { flex-direction:column } - .settings-sidebar { width:100%; max-height:200px; display:flex; flex-wrap:wrap; padding:4px; overflow-x:auto } - .settings-sidebar a { display:inline-block; padding:6px 12px; border-left:none; border-bottom:2px solid transparent; white-space:nowrap } - .settings-sidebar a.active { border-left:none; border-bottom-color:var(--accent) } - .kv-row { flex-direction:column; gap:2px } - .kv-row .key { width:auto } - #starmap-stats { top:8px; left:8px; padding:8px 10px; font-size:11px } - #starmap-info { top:8px; right:8px; padding:8px 10px; max-width:180px; font-size:11px } - #starmap-container { height:calc(100vh - 48px) } - .chat-messages { max-height:300px } - .msg-avatar { width:24px; height:24px; font-size:11px } - .health-item { flex-wrap:wrap; gap:4px } - .health-item .check-name { flex:auto; width:100% } + .rail { width: 48px } + .container { padding: 12px } + .card { padding: 12px } + .grid-2, .grid-3, .grid-4 { grid-template-columns: 1fr } + .stat-value { font-size: 20px } + .kv-row { flex-direction: column; gap: 2px } + .kv-row .key { width: auto } + .msg { max-width: 100% } + .term-screen { background: #0f1115; border: 1px solid #2a2e35; border-radius: 8px; margin: 6px 0 4px 0; overflow: hidden; font-size: 11px; line-height: 1.45 } + .term-screen .term-head { display: flex; align-items: center; gap: 6px; padding: 4px 8px; background: #1a1d23; color: #9aa0a8; font-size: 10px; border-bottom: 1px solid #2a2e35; font-family: var(--font-mono) } + .term-screen .term-head .term-dot { width: 8px; height: 8px; border-radius: 50%; background: #3fb950; flex-shrink: 0 } + .term-screen .term-head .term-dot.stopped { background: #d29922 } + .term-screen pre.term-buf { margin: 0; padding: 6px 8px; background: transparent; color: #d8dee9; font-family: var(--font-mono); font-size: 11px; white-space: pre-wrap; word-break: break-all; max-height: 260px; overflow-y: auto; scrollbar-width: thin } + .term-screen pre.term-buf::-webkit-scrollbar { width: 6px } + .term-screen pre.term-buf::-webkit-scrollbar-thumb { background: #33373d; border-radius: 3px } + .health-item { flex-wrap: wrap; gap: 4px } } -@media(max-width:480px) { - .chat-layout { gap:10px } - .chat-sidebar { min-width:0 } - .msg { max-width:98% } - .settings-sidebar a { padding:6px 10px; font-size:12px } -} - -/* Connection Manager */ -.overlay { position:fixed; inset:0; background:rgba(0,0,0,0.6); display:none; align-items:center; justify-content:center; z-index:1000 } -.overlay-content { background:var(--bg-card); border:1px solid var(--border-color); border-radius:12px; padding:28px; width:520px; max-height:80vh; overflow-y:auto; animation:overlayIn .15s ease both } -.overlay-content h2 { font-size:18px; margin-bottom:16px; color:var(--text-primary) } -@keyframes overlayIn { - from { opacity:0; transform:translateY(12px) } - to { opacity:1; transform:translateY(0) } -} -.conn-item { display:flex; align-items:center; gap:12px; padding:12px 16px; border:1px solid var(--border-color); border-radius:8px; margin-bottom:8px; cursor:pointer; transition:border-color .12s,background .12s } -.conn-item:hover { background:var(--bg-hover); border-color:var(--accent) } -.conn-item.active { border-color:var(--accent); background:var(--accent-bg) } -.conn-item .conn-info { flex:1; min-width:0 } -.conn-item .conn-name { font-size:14px; font-weight:600; color:var(--text-primary) } -.conn-item .conn-url { font-size:11px; color:var(--text-muted); margin-top:2px } -.conn-item .conn-actions { display:flex; gap:4px; flex-shrink:0 } -.conn-form h3 { font-size:15px; margin-bottom:12px } -.conn-form-actions { display:flex; gap:8px; justify-content:flex-end; margin-top:12px } -.conn-indicator { display:flex; align-items:center; gap:4px; cursor:pointer; padding:4px 10px; border-radius:6px; font-size:12px; color:var(--text-secondary); transition:all .15s; user-select:none } -.conn-indicator:hover { background:var(--bg-hover); color:var(--text-primary) } diff --git a/go.mod b/go.mod index d3c41ae..20ca860 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,9 @@ require github.com/yanyiwu/gojieba v1.4.7 require github.com/yalue/onnxruntime_go v1.13.0 -require gitcode.com/JianFeeeee/homeagent-sdk v0.8.0 +require ( + gitcode.com/JianFeeeee/homeagent-sdk v0.8.0 + golang.org/x/sys v0.8.0 +) replace gitcode.com/JianFeeeee/homeagent-sdk => ./third_party/homeagent-sdk diff --git a/go.sum b/go.sum index 81d18cf..311e121 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,3 @@ -github.com/mattn/go-sqlite3 v1.14.48 h1:7XHIgl0a8HwOaiK4E47ozLkST78rR9+OtNGx27D/TFs= -github.com/mattn/go-sqlite3 v1.14.48/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w= github.com/mattn/go-sqlite3 v1.14.49 h1:B8jBHC3xhxZgxztrgruTuLucebnULQnx4W7cF7SAE9w= github.com/mattn/go-sqlite3 v1.14.49/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w= github.com/yalue/onnxruntime_go v1.13.0 h1:5HDXHon3EukQMyYA7yPMed/raWaDE/gjwLOwnVoiwy8= @@ -8,6 +6,8 @@ github.com/yanyiwu/gojieba v1.4.7 h1:2YkXELcYLTE0SJetq6xv4MjpEikWga6VpFn4jIFFQ/k github.com/yanyiwu/gojieba v1.4.7/go.mod h1:JUq4DddFVGdHXJHxxepxRmhrKlDpaBxR8O28v6fKYLY= github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA= github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8= +golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/agent/core/agent.go b/internal/agent/core/agent.go index 1130eb1..ed1b3da 100644 --- a/internal/agent/core/agent.go +++ b/internal/agent/core/agent.go @@ -100,6 +100,9 @@ type Agent struct { // 当前轮次的非文本媒体数据(图片/音频),供 describe_image 等工具访问 pendingMedia map[string]interface{} + // 当前输入是否为工具提醒/中断(以 system 角色注入,避免被当成用户消息) + interruptInput bool + // 非文本输入处理配置 inputCfg types.InputProcessingConfig diff --git a/internal/agent/core/agent_helpers_test.go b/internal/agent/core/agent_helpers_test.go index a30ed48..6f77986 100644 --- a/internal/agent/core/agent_helpers_test.go +++ b/internal/agent/core/agent_helpers_test.go @@ -86,6 +86,83 @@ func TestDocToTriplesEmptyContent(t *testing.T) { } } +// Phase 2: 归档上下文文档不得产出模板垃圾(context_archived 来源/主题模板三元组) +func TestDocToTriplesArchivedContext(t *testing.T) { + doc := &document.Doc{ + Summary: "来自 2 个来源的 5 条对话 (qq, webui) 涉及: 天气, 测试", + Content: "[15:04] qq: 今天天气怎么样\n[15:05] agent: 今天天气很好", + Source: "context_archived", + Meta: map[string]string{"is_archived_context": "true"}, + } + triples := docToTriples(doc, nil) + + for _, tr := range triples { + if tr.Subject == "文档" && tr.Relation == "来源" && tr.Object == "context_archived" { + t.Errorf("archived context must not write 来源 triple: %+v", tr) + } + if tr.Subject == "文档" && tr.Relation == "主题" { + t.Errorf("archived context must not write 主题 template triple: %+v", tr) + } + } +} + +// Phase 2: 模板化摘要(summarizeEntries 生成)不得作为主题写入 +func TestDocToTriplesTemplateSummary(t *testing.T) { + doc := &document.Doc{ + Summary: "来自 3 个来源的 10 条对话 (a, b, c) 涉及: 关键词1, 关键词2, 关键词3", + Content: "[10:00] a: 你好", + Source: "manual", + } + triples := docToTriples(doc, nil) + + for _, tr := range triples { + if tr.Subject == "文档" && tr.Relation == "主题" { + t.Errorf("template summary must not be written as 主题 triple: %+v", tr) + } + } + // 但非归档来源仍保留 来源 三元组 + foundSource := false + for _, tr := range triples { + if tr.Subject == "文档" && tr.Relation == "来源" && tr.Object == "manual" { + foundSource = true + } + } + if !foundSource { + t.Errorf("non-archived source should still produce 来源 triple") + } +} + +// Phase 2: 过长摘要不得写入主题 +func TestDocToTriplesLongSummary(t *testing.T) { + long := "" + for i := 0; i < 100; i++ { + long += "很长的摘要内容片段重复拼接" + } + doc := &document.Doc{ + Summary: long, + Content: "[10:00] a: 你好", + Source: "test", + } + triples := docToTriples(doc, nil) + for _, tr := range triples { + if tr.Subject == "文档" && tr.Relation == "主题" { + t.Errorf("overlong summary must not be written as 主题 triple") + } + } +} + +func TestIsTemplateSummary(t *testing.T) { + if !isTemplateSummary("来自 2 个来源的 5 条对话 (qq, webui) 涉及: 天气") { + t.Errorf("template summary not recognized") + } + if isTemplateSummary("今天天气很好") { + t.Errorf("plain summary wrongly recognized as template") + } + if !isTemplateSummary("") { + t.Errorf("empty summary should be treated as template") + } +} + func TestTruncateStr(t *testing.T) { tests := []struct { input string diff --git a/internal/agent/core/distill.go b/internal/agent/core/distill.go index 55f6c8b..b7da8b9 100644 --- a/internal/agent/core/distill.go +++ b/internal/agent/core/distill.go @@ -4,6 +4,7 @@ import ( "fmt" "log" "runtime/debug" + "strings" "time" agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io" @@ -397,15 +398,19 @@ func docToTriples(doc *document.Doc, embedder nlp.Vectorizer) []memory.Triple { return nil } - // 文档元数据 - triples = append(triples, memory.Triple{ - Subject: "文档", - SubjectType: "Concept", - Relation: "主题", - Object: doc.Summary, - ObjectType: "Topic", - Confidence: 1.0, - }) + isArchivedContext := doc.Meta != nil && doc.Meta["is_archived_context"] == "true" + + // 文档元数据:仅当 summary 合理(非空、非模板化、长度适中)时才写「主题」 + if !isArchivedContext && doc.Summary != "" && len([]rune(doc.Summary)) < 80 && !isTemplateSummary(doc.Summary) { + triples = append(triples, memory.Triple{ + Subject: "文档", + SubjectType: "Concept", + Relation: "主题", + Object: doc.Summary, + ObjectType: "Topic", + Confidence: 1.0, + }) + } // NLP 通用提取 e := nlp.NewExtractor(nil) @@ -422,7 +427,8 @@ func docToTriples(doc *document.Doc, embedder nlp.Vectorizer) []memory.Triple { } } - if doc.Source != "" { + // 仅当来源非归档上下文且非空时写「来源」——归档文档写死模板三元组属于垃圾 + if doc.Source != "" && doc.Source != "context_archived" { triples = append(triples, memory.Triple{ Subject: "文档", SubjectType: "Concept", @@ -436,6 +442,16 @@ func docToTriples(doc *document.Doc, embedder nlp.Vectorizer) []memory.Triple { return triples } +// isTemplateSummary 识别 summarizeEntries 生成的模板化摘要 +// (形如「来自 N 个来源的 M 条对话 (src1, src2) 涉及: kw1, kw2」), +// 这类摘要无独立信息量,不应作为「主题」实体写入图库。 +func isTemplateSummary(s string) bool { + if s == "" { + return true + } + return strings.HasPrefix(s, "来自 ") && strings.Contains(s, "条对话") +} + func (a *Agent) emitMemoryCandidate(source, input, response string, toolResults []ToolResultItem, toolsUsed []string) { a.io.EmitOutput("memory", "memory_candidate", map[string]interface{}{ "source": source, diff --git a/internal/agent/core/eventloop.go b/internal/agent/core/eventloop.go index a8da4d5..ef20565 100644 --- a/internal/agent/core/eventloop.go +++ b/internal/agent/core/eventloop.go @@ -287,6 +287,16 @@ func (a *Agent) processTextInput(evt *agentIO.InputEvent, input string) { } } + // 工具提醒/中断(terminal_watch、timer 等)不是用户发言: + // 以 system 角色注入 LLM,且不写入用户对话履历。 + isInterrupt, _ := evt.Payload["interrupt"].(bool) + a.mu.Lock() + a.interruptInput = isInterrupt + a.mu.Unlock() + if isInterrupt { + noMemory = true + } + stageCtx := a.stageCtxFromInput(input, evt.Source, "") stageCtx.Extra["input_source"] = evt.Source stageCtx.Extra["output_channel"] = evt.OutputChannel @@ -320,11 +330,13 @@ func (a *Agent) processTextInput(evt *agentIO.InputEvent, input string) { log.Printf("[agent] pruned %d low-relevance events to document memory", archived) } - a.context.Append(ContextEvent{ - Timestamp: start, - Source: evt.Source, - Input: input, - }) + if !isInterrupt { + a.context.Append(ContextEvent{ + Timestamp: start, + Source: evt.Source, + Input: input, + }) + } response, toolsUsed, toolResults, err := a.process(input, stageCtx) if err != nil { @@ -380,7 +392,6 @@ func (a *Agent) emitResponse(evt *agentIO.InputEvent, response string) { if stageCtx.TokenUsage != nil { payload["usage"] = stageCtx.TokenUsage } - if evt.ResponseCh != nil { evt.ResponseCh <- &agentIO.OutputEvent{ RequestID: evt.RequestID, @@ -392,11 +403,15 @@ func (a *Agent) emitResponse(evt *agentIO.InputEvent, response string) { } } - a.publishEvent(events.EventAgentOutput, map[string]interface{}{ + out := map[string]interface{}{ "content": response, "channel": ch, "source": evt.Source, - }) + } + if stageCtx.ReasoningContent != "" { + out["reasoning_content"] = stageCtx.ReasoningContent + } + a.publishEvent(events.EventAgentOutput, out) stageCtx.Phase = sdk.StageAfterOutput a.runStage(sdk.StageAfterOutput, stageCtx) } diff --git a/internal/agent/core/process.go b/internal/agent/core/process.go index 559e379..e99a5aa 100644 --- a/internal/agent/core/process.go +++ b/internal/agent/core/process.go @@ -27,6 +27,14 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri tools := a.buildToolDefs() msgs := a.buildMessages(sysPrompt, input, budget.ContextTokens) + // 工具提醒(interrupt):以 system 角色注入,不让模型误认为用户发言 + if a.interruptInput { + last := msgs[len(msgs)-1] + last.Role = "system" + last.Content = "[中断消息] " + last.Content + msgs[len(msgs)-1] = last + a.interruptInput = false + } if blocks, ok := stageCtx.Extra["media_blocks"].([]agentAPI.ContentBlock); ok && len(blocks) > 0 { if len(msgs) > 0 { msgs[len(msgs)-1].Blocks = blocks @@ -56,7 +64,16 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri for _, interrupt := range a.drainInterrupts() { msgs = append(msgs, agentAPI.Message{ Role: "system", - Content: interrupt, + Content: "[中断消息] " + interrupt, + }) + } + + // zen 兼容网关要求请求的最后一条消息必须是 user(thinking 续写模式校验), + // 工具轮产出的 tool/assistant 消息作结尾会被 400 拒绝,故补一条 user 占位。 + if last := msgs[len(msgs)-1]; last.Role != "user" { + msgs = append(msgs, agentAPI.Message{ + Role: "user", + Content: "请根据以上工具结果继续。", }) } @@ -177,6 +194,13 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri } a.publishEvent(events.EventAgentLLMChain, chainPayload) + if resp.ReasoningContent != "" { + a.publishEvent(events.EventReasoning, map[string]interface{}{ + "content": resp.ReasoningContent, + "channel": a.currentOutputChannel, + }) + } + if len(resp.ToolCalls) == 0 { return resp.Content, toolsUsed, toolResults, nil } @@ -185,15 +209,16 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri for _, tc := range resp.ToolCalls { if len(a.interceptCh) > 0 { for _, interrupt := range a.drainInterrupts() { - msgs = append(msgs, agentAPI.Message{Role: "system", Content: interrupt}) + msgs = append(msgs, agentAPI.Message{Role: "system", Content: "[中断消息] " + interrupt}) } - a.publishEvent(events.EventToolCall, map[string]interface{}{ - "tool": tc.Name, - "plugin": a.resolveToolPlugin(tc.Name), - "args": tc.Arguments, - "status": "interrupted", - "reason": "user interrupt before execution", - }) + a.publishEvent(events.EventToolCall, map[string]interface{}{ + "tool": tc.Name, + "plugin": a.resolveToolPlugin(tc.Name), + "args": tc.Arguments, + "status": "interrupted", + "reason": "user interrupt before execution", + "channel": a.currentOutputChannel, + }) break } @@ -208,13 +233,14 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri result := fmt.Sprintf("工具 %s 已被插件拒绝", tc.Name) msgs = append(msgs, agentAPI.Message{Role: "assistant", ToolCalls: []agentAPI.ToolCall{tc}}) msgs = append(msgs, agentAPI.Message{Role: "tool", ToolCallID: tc.ID, Content: result}) - a.publishEvent(events.EventToolCall, map[string]interface{}{ - "tool": tc.Name, - "plugin": pluginName, - "args": tc.Arguments, - "result": result, - "status": "denied", - }) + a.publishEvent(events.EventToolCall, map[string]interface{}{ + "tool": tc.Name, + "plugin": pluginName, + "args": tc.Arguments, + "result": result, + "status": "denied", + "channel": a.currentOutputChannel, + }) continue } tc.Arguments = stageCtx.ToolCalls[0].Arguments @@ -248,16 +274,17 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri msgs = append(msgs, agentAPI.Message{Role: "tool", ToolCallID: tc.ID, Content: result}) a.publishEvent(events.EventToolCall, map[string]interface{}{ - "tool": tc.Name, - "plugin": pluginName, - "args": tc.Arguments, - "result": result, - "status": "ok", + "tool": tc.Name, + "plugin": pluginName, + "args": tc.Arguments, + "result": result, + "status": "ok", + "channel": a.currentOutputChannel, }) if len(a.interceptCh) > 0 { for _, interrupt := range a.drainInterrupts() { - msgs = append(msgs, agentAPI.Message{Role: "system", Content: interrupt}) + msgs = append(msgs, agentAPI.Message{Role: "system", Content: "[中断消息] " + interrupt}) } break } diff --git a/internal/agent/core/stage.go b/internal/agent/core/stage.go index a7394f0..005913f 100644 --- a/internal/agent/core/stage.go +++ b/internal/agent/core/stage.go @@ -12,6 +12,14 @@ import ( ) func (a *Agent) runStage(stage sdk.Stage, ctx *sdk.StageContext) bool { + payload := map[string]interface{}{ + "phase": string(stage), + "channel": a.currentOutputChannel, + } + if ctx != nil && len(ctx.ToolCalls) > 0 { + payload["tool"] = ctx.ToolCalls[0].Name + } + a.publishEvent(events.EventStage, payload) if a.stageHost == nil { return false } diff --git a/internal/agent/core/tooldefs.go b/internal/agent/core/tooldefs.go index c1af1f3..044052c 100644 --- a/internal/agent/core/tooldefs.go +++ b/internal/agent/core/tooldefs.go @@ -49,12 +49,13 @@ func (a *Agent) buildSystemPrompt(memContext string, userInput string) string { } } - prompt += "\n\n【输出规则】你有多组输出门工具(type=output),每个对应一个输出通道。回复用户时必须调用对应的 output_send__{通道名} 工具。\n" - prompt += "- payload 参数是消息载荷(文本直接填文字),type 指定载荷类型(text/voice/image/file),meta 是 JSON 发送元数据(群号/用户号等)。\n" + prompt += "\n\n【中断消息】长任务执行期间,工具/插件/定时器等会通过中断机制向你发送提醒(如 QQ 新消息、终端输出到达、定时器到点等)。中断消息以 system 角色注入,内容带 [中断消息] 前缀,**不是用户发言,但也必须认真处理**:优先停下当前长任务,针对中断内容作出响应或决定继续执行。不要忽略带 [中断消息] 前缀的 system 消息。" + + prompt += "\n\n【输出规则】回复会自动发送到用户的输入来源通道,直接返回纯文本即可送达,无需调用任何工具。\n" + prompt += "- 输出门工具 output_send__{通道名} 用于主动向指定通道推送消息(如群发、主动通知、向其他通道发言),不是回复的必要步骤。除非用户要求在别的通道发送,否则不要使用。\n" prompt += "- 用 output_send__{通道名}_help 查看该通道的 meta 格式和 type 枚举。\n" prompt += "- 同一轮对话中可多次调用输出门工具。长消息应当分多次发出,而不是一口气发完。\n" - prompt += "- 直接返回纯文本不会到达任何用户端。\n" - prompt += "- 需要多步执行的长任务:**必须先**用 output_send__ 发一条确认消息告诉用户已收到(如「好的我去看看~」),**然后再**执行具体排查工具。确认消息不代表任务完成,发出后仍需继续执行实际工具并最终汇报结果。" + prompt += "- 需要多步执行的长任务:**必须先**用输出门工具向当前输入通道发一条确认消息告诉用户已收到(如「好的我去看看~」,也可以直接返回文本),**然后再**执行具体排查工具。确认消息不代表任务完成,发出后仍需继续执行实际工具并最终汇报结果。" if a.indexer != nil { prompt += "\n\n" + a.indexer.BuildToolPrompt() diff --git a/internal/config/registry.go b/internal/config/registry.go index f874448..d743095 100644 --- a/internal/config/registry.go +++ b/internal/config/registry.go @@ -568,9 +568,9 @@ func (r *ConfigRegistry) seedDBValues(dataDir string) { WebUI 概览页展示你的立绘,可通过 /mascot.webp 直接访问。如输出通道支持图片引用,可借此发送自己的立绘。 -回复默认发送到用户的输入来源,无需额外工具。 -输出回复请使用 output_send__{通道名} 工具,content 为 JSON 字符串。用 output_list_channels 查看可用通道。 -使用 output_send__{通道名}_help 查看每个通道的 JSON 格式说明。 +回复会自动发送到用户的输入来源通道,直接返回纯文本即可送达,无需额外工具。 +输出门工具 output_send__{通道名} 仅用于主动向指定通道推送消息(群发、主动通知、向其他通道发言),不是回复的必要步骤。用 output_list_channels 查看可用通道。 +使用 output_send__{通道名}_help 查看每个通道的格式说明。 输出通道可多次调用,长消息应当分多次发出而不是一口气发完。 当用户上传图片或音频时,系统会自动附着媒体内容。如果模型不支持直接处理多媒体,请调用对应的媒体处理工具。`) @@ -646,7 +646,7 @@ func (r *ConfigRegistry) seedCoreDefs(dataDir string) { reg(ConfigDef{Key: "core.agent.review_interval", Default: "120m", Type: "duration", DisplayName: "关系复审间隔", Description: "三元组关系复审的执行间隔", Category: "agent"}) reg(ConfigDef{Key: "core.agent.merge_interval", Default: "120m", Type: "duration", DisplayName: "实体合并检测间隔", Description: "实体合并检测(LLM 裁决)的执行间隔", Category: "agent"}) reg(ConfigDef{Key: "core.agent.workdir", Default: "", Type: "string", DisplayName: "工作目录", Description: "Agent 命令执行的默认工作目录(如 cmd_run 工具的 fallback),留空使用内核所在目录", Category: "agent"}) - reg(ConfigDef{Key: "core.agent.embedding_model_path", Default: "", Type: "string", DisplayName: "预训练词嵌入模型路径", Description: "预训练词嵌入模型路径(word2vec 文本格式),支持逗号分隔多个模型。空则使用 TF-IDF 回退。修改后需重启生效。", Category: "agent"}) + reg(ConfigDef{Key: "core.agent.embedding_model_path", Default: "", Type: "string", DisplayName: "预训练词嵌入模型路径", Description: "预训练词嵌入模型路径(word2vec 文本格式),支持逗号分隔多个模型。路径后可加 #topN 规格只加载前 N 个词向量(如 /data/cc.zh.300.vec#top50000)以控制常驻内存,词频降序命中覆盖绝大部分文本。空则使用 TF-IDF 回退。修改后需重启生效。", Category: "agent"}) reg(ConfigDef{Key: "core.agent.onnx_model_path", Default: "", Type: "string", DisplayName: "ONNX 模型路径", Description: "依存句法分析 ONNX 模型文件路径。留空使用二进制内嵌模型/规则引擎。修改后需重启生效。", Category: "agent"}) reg(ConfigDef{Key: "core.agent.system_prompt", Default: "", Type: "text", DisplayName: "系统身份提示词", Description: "Agent 的系统提示词,定义身份和行为规则。留空则使用编译时内置默认值。修改后需重启生效。", Category: "agent"}) diff --git a/internal/events/bus.go b/internal/events/bus.go index 214b459..7e902fd 100644 --- a/internal/events/bus.go +++ b/internal/events/bus.go @@ -16,6 +16,7 @@ const ( EventReasoning EventType = "reasoning" EventStage EventType = "stage" EventSystem EventType = "system" + EventTerminalOutput EventType = "terminal_output" EventAll EventType = "*" ) diff --git a/internal/lua/adapters/server.lua b/internal/lua/adapters/server.lua new file mode 100644 index 0000000..aeba948 --- /dev/null +++ b/internal/lua/adapters/server.lua @@ -0,0 +1,103 @@ +local adapter = {} + +adapter.name = "server" +adapter.version = "1.0.0" +adapter.endpoint = "/chat/completions" +adapter.headers = {} + +-- 专用于 zen 兼容网关(thinking 模式要求回传 reasoning_content)。 +-- 关键:不删除 disable_thinking(homeagent 置 true 时网关关闭 thinking, +-- 从而不再强制要求 reasoning_content 回传);同时保留已有 reasoning_content 双保险。 +function adapter.transform_request(raw_body) + local ok, req = pcall(json.decode, raw_body) + if not ok then return raw_body end + req.extra_body = nil + return json.encode(req) +end + +function adapter.transform_response(raw_body) + local ok, resp = pcall(json.decode, raw_body) + if not ok or resp == nil then return raw_body end + + local unified = { + content = "", + finish_reason = "", + token_usage = { prompt = 0, completion = 0, total = 0 } + } + + if type(resp.usage) == "table" then + unified.token_usage.prompt = resp.usage.prompt_tokens or 0 + unified.token_usage.completion = resp.usage.completion_tokens or 0 + unified.token_usage.total = resp.usage.total_tokens or 0 + end + + if type(resp.choices) == "table" and #resp.choices > 0 then + local ch = resp.choices[1] + if type(ch.message) == "table" then + unified.content = ch.message.content or "" + if ch.message.reasoning_content then + unified.reasoning_content = ch.message.reasoning_content + end + if type(ch.message.tool_calls) == "table" then + local tcs = {} + for _, tc in ipairs(ch.message.tool_calls) do + local fn = tc["function"] + local name = tc.name + local raw_args = tc.arguments + if type(fn) == "table" then + name = fn.name or name + raw_args = fn.arguments or raw_args + end + local args = {} + if type(raw_args) == "table" then + args = raw_args + elseif type(raw_args) == "string" and raw_args ~= "" then + local args_ok, decoded = pcall(json.decode, raw_args) + if args_ok and type(decoded) == "table" then + args = decoded + elseif args_ok then + args = { value = decoded } + else + args = { raw = raw_args } + end + end + if name ~= nil and name ~= "" then + table.insert(tcs, { + id = tc.id, + type = tc.type or "function", + name = name, + arguments = args + }) + end + end + unified.tool_calls = tcs + end + end + unified.finish_reason = ch.finish_reason or "" + end + + return json.encode(unified) +end + +function adapter.transform_stream_chunk(raw_chunk) + local ok, chunk = pcall(json.decode, raw_chunk) + if not ok then return "" end + + if not chunk.choices or #chunk.choices == 0 then return "" end + local delta = chunk.choices[1].delta or {} + local fr = chunk.choices[1].finish_reason + + local unified = { + content = delta.content or "", + done = (fr ~= nil) + } + if delta.reasoning_content then + unified.reasoning_content = delta.reasoning_content + end + if delta.tool_calls then + unified.tool_calls = delta.tool_calls + end + return json.encode(unified) +end + +return adapter diff --git a/internal/lua/vm.go b/internal/lua/vm.go index 861c149..44ead29 100644 --- a/internal/lua/vm.go +++ b/internal/lua/vm.go @@ -558,6 +558,7 @@ func (v *VM) writeBundledAdapters() error { known := []string{ "openai", "anthropic", "deepseek", "gemini", "github", "groq", "mistral", "ollama", "kimicode", + "server", } for _, name := range known { srcPath := "adapters/" + name + ".lua" diff --git a/internal/memory/document/document.go b/internal/memory/document/document.go index f91d158..a53e91a 100644 --- a/internal/memory/document/document.go +++ b/internal/memory/document/document.go @@ -183,6 +183,10 @@ func (s *Store) ContextToDoc(source string, entries []ContextEntry, vec vector.V docVec = vec.Vectorize(summary + " " + content) } else { docVec = s.veczer.Vectorize(summary + " " + content) + } + meta := map[string]string{"content_hash": contentHash} + if source == "context_archived" { + meta["is_archived_context"] = "true" } doc := &Doc{ ID: id, @@ -195,7 +199,7 @@ func (s *Store) ContextToDoc(source string, entries []ContextEntry, vec vector.V LastAccess: time.Now(), AccessCount: 1, Source: source, - Meta: map[string]string{"content_hash": contentHash}, + Meta: meta, Vector: docVec, } s.docs[id] = doc diff --git a/internal/memory/pipeline/pipeline.go b/internal/memory/pipeline/pipeline.go index 711c533..ac6a8f6 100644 --- a/internal/memory/pipeline/pipeline.go +++ b/internal/memory/pipeline/pipeline.go @@ -193,11 +193,15 @@ func (d *Distiller) distillLoop() { func (d *Distiller) distillOnce() { d.mu.Lock() - cutoff := time.Now().AddDate(0, 0, -d.cfg.RetentionDays) + batchSize := d.cfg.BatchSize + if batchSize <= 0 { + batchSize = 50 + } + // 每 tick 取前 N 条未蒸馏记录(无 RetentionDays 门槛),蒸馏成功才标记/移除 var toDistill []RawRecord var remaining []RawRecord for _, r := range d.records { - if r.CreatedAt.Before(cutoff) && !r.Distilled { + if !r.Distilled && len(toDistill) < batchSize { toDistill = append(toDistill, r) } else { remaining = append(remaining, r) @@ -210,22 +214,29 @@ func (d *Distiller) distillOnce() { return } - batchSize := d.cfg.BatchSize - if batchSize <= 0 { - batchSize = 50 - } + distilled := 0 for i := 0; i < len(toDistill); i += batchSize { end := i + batchSize if end > len(toDistill) { end = len(toDistill) } - d.distillBatch(toDistill[i:end]) + if d.distillBatch(toDistill[i:end]) { + distilled += end - i + } else { + // 蒸馏失败:记录写回待处理队列,下次 tick 重试 + d.mu.Lock() + d.records = append(toDistill[i:end], d.records...) + d.mu.Unlock() + } } d.cleanupRawFiles() - log.Printf("[memory] distilled %d records", len(toDistill)) + if distilled > 0 { + log.Printf("[memory] distilled %d records", distilled) + } } -func (d *Distiller) distillBatch(batch []RawRecord) { +// distillBatch 蒸馏一批记录,全部成功返回 true,任一失败返回 false(调用方重试) +func (d *Distiller) distillBatch(batch []RawRecord) bool { var userContent, assistantContent string sessionIDs := make(map[string]bool) for _, r := range batch { @@ -245,8 +256,10 @@ func (d *Distiller) distillBatch(batch []RawRecord) { } if _, _, err := d.db.Commit(triples, sessionID, 0); err != nil { log.Printf("[memory] distill commit: %v", err) + return false } } + return true } func (d *Distiller) cleanupRawFiles() { diff --git a/internal/memory/pipeline/pipeline_test.go b/internal/memory/pipeline/pipeline_test.go index 8f1f0b8..62f68de 100644 --- a/internal/memory/pipeline/pipeline_test.go +++ b/internal/memory/pipeline/pipeline_test.go @@ -1,6 +1,7 @@ package pipeline import ( + "fmt" "os" "path/filepath" "testing" @@ -117,6 +118,71 @@ func TestDistillOnce(t *testing.T) { } } +// Phase 4: 新记录无需等待 RetentionDays,下一 tick 立即蒸馏(文档所述 10min 频率) +func TestDistillOnceFreshRecords(t *testing.T) { + db, err := memory.NewGraphDB(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatal(err) + } + defer db.Close() + + dir := t.TempDir() + d := NewDistiller(db, dir, DistillerConfig{ + Interval: 10 * time.Minute, + RetentionDays: 7, + BatchSize: 50, + }) + d.Append("sess1", "user", "我的名字是李四") + d.Append("sess1", "assistant", "你好李四!") + + if len(d.records) != 2 { + t.Fatalf("expected 2 fresh records, got %d", len(d.records)) + } + + d.distillOnce() + if len(d.records) != 0 { + t.Errorf("fresh records should be distilled on next tick (no retention gate), got %d remaining", len(d.records)) + } + + // 二次蒸馏不重复(已蒸馏记录已被移除) + d.distillOnce() + if len(d.records) != 0 { + t.Errorf("second distill should be no-op, got %d records", len(d.records)) + } +} + +// Phase 4: BatchSize 限制每 tick 处理前 N 条,未蒸馏记录留待下个 tick +func TestDistillOnceBatchLimit(t *testing.T) { + db, err := memory.NewGraphDB(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatal(err) + } + defer db.Close() + + dir := t.TempDir() + d := NewDistiller(db, dir, DistillerConfig{ + Interval: 10 * time.Minute, + RetentionDays: 7, + BatchSize: 3, + }) + for i := 0; i < 10; i++ { + d.Append("sess1", "user", fmt.Sprintf("第 %d 条消息内容", i)) + } + + d.distillOnce() + if len(d.records) != 7 { + t.Fatalf("expected 7 records remaining after batch 3, got %d", len(d.records)) + } + + // 后续 tick 继续消化,最终全部蒸馏 + for i := 0; i < 5 && len(d.records) > 0; i++ { + d.distillOnce() + } + if len(d.records) != 0 { + t.Errorf("all records should be distilled after several ticks, got %d remaining", len(d.records)) + } +} + func TestExtractKeyTriples(t *testing.T) { tests := []struct { user string diff --git a/internal/memory/static_embedder.go b/internal/memory/static_embedder.go index 631ef1c..f843e1b 100644 --- a/internal/memory/static_embedder.go +++ b/internal/memory/static_embedder.go @@ -129,12 +129,13 @@ func ensureModelFile(modelPath string) { if modelPath == "" { return } - if _, err := os.Stat(modelPath); err == nil { + path, _ := parseModelSpec(modelPath) + if _, err := os.Stat(path); err == nil { return } - url := modelDownloadURL(modelPath) - log.Printf("[static_embedder] model %s not found, downloading from fastText...", modelPath) - if dlErr := downloadFastTextModel(modelPath, url); dlErr != nil { + url := modelDownloadURL(path) + log.Printf("[static_embedder] model %s not found, downloading from fastText...", path) + if dlErr := downloadFastTextModel(path, url); dlErr != nil { log.Printf("[static_embedder] download failed: %v, will use TF-IDF fallback", dlErr) } else { log.Printf("[static_embedder] download ok") @@ -183,7 +184,24 @@ func (e *StaticEmbedder) loadAll(paths []string) error { return firstErr } -func (e *StaticEmbedder) load(path string, primary bool) error { +// parseModelSpec 解析模型路径规格:`path#top50000` 表示只加载前 50000 个词向量(按文件顺序,fastText +// 词频降序,前 N 词覆盖绝大多数文本命中),用于降低常驻内存;无规格返回原路径与 0(全量加载)。 +func parseModelSpec(p string) (path string, topN int) { + path = p + if i := strings.IndexByte(p, '#'); i >= 0 { + path = p[:i] + spec := p[i+1:] + if strings.HasPrefix(spec, "top") { + if n, err := strconv.Atoi(strings.TrimPrefix(spec, "top")); err == nil && n > 0 { + topN = n + } + } + } + return path, topN +} + +func (e *StaticEmbedder) load(spec string, primary bool) error { + path, topN := parseModelSpec(spec) f, err := os.Open(path) if err != nil { return fmt.Errorf("open: %w", err) @@ -217,7 +235,11 @@ func (e *StaticEmbedder) load(path string, primary bool) error { vecSum = make([]float64, dim) } + loaded := 0 for scanner.Scan() { + if topN > 0 && loaded >= topN { + break + } line := strings.TrimSpace(scanner.Text()) if line == "" { continue @@ -244,6 +266,7 @@ func (e *StaticEmbedder) load(path string, primary bool) error { } count++ } + loaded++ } if primary { @@ -263,7 +286,7 @@ func (e *StaticEmbedder) load(path string, primary bool) error { e.loaded = true } - log.Printf("[static_embedder] loaded %d words, dim=%d from %s", len(e.words), e.dim, path) + log.Printf("[static_embedder] loaded %d words, dim=%d from %s (topN=%d)", len(e.words), e.dim, path, topN) return nil } diff --git a/internal/memory/synth_model_test.go b/internal/memory/synth_model_test.go index aa4f543..3692eb7 100644 --- a/internal/memory/synth_model_test.go +++ b/internal/memory/synth_model_test.go @@ -86,3 +86,45 @@ func newSynthEmbedder(t testing.TB, dim int) *StaticEmbedder { } return e } + +// Phase 5: #topN 规格裁剪加载——只加载前 N 个词向量,控制常驻内存 +func TestStaticEmbedderTopNSpec(t *testing.T) { + path := writeSynthModel(t, 300) + + // 解析规格 + cleanPath, topN := parseModelSpec(path + "#top5") + if cleanPath != path || topN != 5 { + t.Fatalf("parseModelSpec(#top5) = (%q, %d), want (%q, 5)", cleanPath, topN, path) + } + cleanPath2, topN2 := parseModelSpec(path) + if cleanPath2 != path || topN2 != 0 { + t.Fatalf("parseModelSpec(plain) = (%q, %d), want (%q, 0)", cleanPath2, topN2, path) + } + cleanPath3, topN3 := parseModelSpec(path + "#abc") + if cleanPath3 != path || topN3 != 0 { + t.Fatalf("parseModelSpec(#abc) = (%q, %d), want (%q, 0)", cleanPath3, topN3, path) + } + + // 裁剪加载 + e := NewStaticEmbedder(path + "#top5") + if !e.Loaded() { + t.Fatal("topN embedder should be loaded") + } + if len(e.words) != 5 { + t.Errorf("expected 5 words loaded with #top5, got %d", len(e.words)) + } +} + +// Phase 5: 裁剪后向量化仍可用(未命中词走 unkVec 兜底) +func TestStaticEmbedderTopNVectorize(t *testing.T) { + path := writeSynthModel(t, 300) + e := NewStaticEmbedder(path + "#top1") + if !e.Loaded() { + t.Fatal("embedder should be loaded") + } + v := e.Vectorize("天气怎么样") + // 未命中词不应产生空向量(unkVec 兜底) + if len(v) == 0 { + t.Error("vectorize with topN=1 should still produce a vector (unkVec fallback)") + } +} diff --git a/internal/network/monitor.go b/internal/network/monitor.go index e0cb74a..63ba2e9 100644 --- a/internal/network/monitor.go +++ b/internal/network/monitor.go @@ -57,7 +57,11 @@ func (m *Monitor) Start(ctx context.Context, endpoints []string) { } m.mu.Unlock() - ticker := time.NewTicker(m.interval) + interval := m.interval + if interval <= 0 { + interval = 30 * time.Second + } + ticker := time.NewTicker(interval) defer ticker.Stop() m.checkAll(ctx) diff --git a/internal/plugins/agentcli/plugin.go b/internal/plugins/agentcli/plugin.go index fb181fa..e9e133e 100644 --- a/internal/plugins/agentcli/plugin.go +++ b/internal/plugins/agentcli/plugin.go @@ -46,8 +46,17 @@ func terminalRunning(t *TerminalSession) bool { return t.cmd != nil && (t.cmd.ProcessState == nil || !t.cmd.ProcessState.Exited()) } +// terminalWatch 终端提醒规则(由 terminal_watch 工具设置)。 +type terminalWatch struct { + interval time.Duration // 固定时间反馈间隔,0 禁用 + onExit bool // 命令执行结束提醒(默认 true) + bufferBytes int // 该终端专用缓冲阈值(字节),0 使用全局 notify_bytes + quiet bool // 静默模式:不随输出流通知,仅定时反馈/结束提醒/空闲汇总 +} + type TerminalSession struct { id string + command string cmd *exec.Cmd session ptyTerm mu sync.Mutex @@ -59,8 +68,15 @@ type TerminalSession struct { done chan struct{} // 通知节流字段 - unreadBytes int // 最近一次通知后积累的未读字节数 - lastNotify time.Time // 最近一次通知时间 + unreadBytes int // 最近一次通知后积累的未读字节数 + lastNotify time.Time // 最近一次通知时间 + lastData time.Time // 最近一次读到的数据时间(用于判定输出停止) + lastFeedback time.Time // 最近一次定时反馈时间 + backoff time.Duration // 输出风暴退避:持续高速输出时通知间隔翻倍 + watch terminalWatch // 该终端的提醒规则 + + // 实时画面推流(terminal_output 事件) + stream bytes.Buffer // 待推送的增量输出,由 readLoop 每 200ms flush 一次 } func (t *TerminalSession) Write(input string) (int, error) { @@ -85,12 +101,13 @@ func (t *TerminalSession) Close() { t.mu.Unlock() close(t.stopCh) + // 先终止进程(各平台实现:Linux 信号 / Windows TerminateProcess,幂等),再释放资源。 + // 不能依赖 cmd.Process.Kill():Windows 后端 cmd.Process 为占位(仅 Pid)。 + if t.session != nil { + _ = t.session.Kill() + } t.session.Close() <-t.done - - if t.cmd != nil && t.cmd.Process != nil { - t.cmd.Process.Kill() - } } func (t *TerminalSession) ReadOutput() string { @@ -119,6 +136,17 @@ func (t *TerminalSession) appendOutput(data []byte) { } } t.buf.Write(data) + // 同步追加到实时画面推流缓冲(最大 64KB,超出丢弃最旧部分) + const maxStream = 64 * 1024 + if t.stream.Len()+len(data) > maxStream { + excess := t.stream.Len() + len(data) - maxStream + if t.stream.Len() > excess { + t.stream.Next(excess) + } else { + t.stream.Reset() + } + } + t.stream.Write(data) } func (t *TerminalSession) IsExpired() bool { @@ -194,8 +222,10 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { } s.RegisterTool("terminal_create", sdk.ToolDef{ - Name: "terminal_create", - Description: "创建一个新的交互式终端会话。返回终端 ID,后续通过此 ID 进行读写操作。适用于运行交互式程序如 vim、ssh、top、nano 等。终端默认 5 分钟后自动关闭,可通过 timeout 参数调整。", + Name: "terminal_create", + Description: "创建一个新的交互式终端会话。返回终端 ID,后续通过此 ID 进行读写操作。适用于运行交互式程序如 vim、ssh、top、nano 等。" + + "通知模式通过 notify 参数选择(默认 exit):exit=仅命令执行结束后提醒一次;interval=定时反馈(如 interval=30s 每 30 秒反馈一次状态摘要);" + + "buffer=未读输出积累到指定字节数后提醒(如 buffer=8192);多个模式用逗号组合(如 interval=30s,buffer=8192)。终端默认 5 分钟后自动关闭,可通过 timeout 参数调整。", NoMemory: true, Parameters: map[string]interface{}{ "type": "object", @@ -204,6 +234,10 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { "type": "string", "description": "要执行的命令(默认 bash)。如需运行特定程序直接传入即可,例如:vim /tmp/test.txt", }, + "notify": map[string]interface{}{ + "type": "string", + "description": "通知模式(可选):exit(默认,命令结束后提醒);interval=时长(定时反馈,如 30s/1m);buffer=字节数(缓冲阈值提醒);可逗号组合", + }, "timeout": map[string]interface{}{ "type": "string", "description": "终端自动关闭时间,例如 5m, 10m, 30m, 1h(默认 5m)", @@ -249,8 +283,8 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { }) s.RegisterTool("terminal_read", sdk.ToolDef{ - Name: "terminal_read", - Description: "读取指定终端的当前屏幕内容。返回自上次读取以来的新输出。如需持续监控请多次调用。", + Name: "terminal_read", + Description: "读取指定终端的输出。mode=new(默认)返回自上次读取以来的新输出并清空缓冲;mode=now 返回终端当前显示的全部屏幕内容(不清空缓冲)。如需持续监控请多次调用。", NoMemory: true, Parameters: map[string]interface{}{ "type": "object", @@ -259,9 +293,13 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { "type": "string", "description": "终端 ID", }, + "mode": map[string]interface{}{ + "type": "string", + "description": "读取模式:new(默认,新输出并清空缓冲)或 now(当前屏幕全部内容,不清理)", + }, "clear": map[string]interface{}{ "type": "boolean", - "description": "读取后是否清除缓冲区(默认 true)", + "description": "读取后是否清除缓冲区(默认与 mode 一致:new 清除,now 不清除)", }, }, "required": []string{"id"}, @@ -326,6 +364,48 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { return p.handleList() }) + s.RegisterTool("terminal_watch", sdk.ToolDef{ + Name: "terminal_watch", + Description: "为指定终端设置提醒规则,避免长时间运行任务(编译/下载/构建等)的输出造成通知风暴。" + + "可选规则:interval=固定时间反馈(每隔该时长向 agent 反馈一次终端状态摘要);" + + "on_exit=命令执行结束提醒;buffer_bytes=未读输出积累到该字节数时提醒一次;" + + "quiet=静默模式(抑制随输出流的通知,仅保留定时反馈与结束提醒,推荐长任务使用)。" + + "未提供的字段保持原值,clear=true 清除全部规则。默认 on_exit=true。", + NoMemory: true, + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "id": map[string]interface{}{ + "type": "string", + "description": "终端 ID,来自 terminal_create 的返回值", + }, + "interval": map[string]interface{}{ + "type": "string", + "description": "固定时间反馈间隔,如 30s, 1m, 5m(可选,0 禁用)", + }, + "on_exit": map[string]interface{}{ + "type": "boolean", + "description": "命令执行结束时是否提醒(默认 true)", + }, + "buffer_bytes": map[string]interface{}{ + "type": "integer", + "description": "未读输出积累阈值(字节),达到后提醒一次(可选,默认全局 2048)", + }, + "quiet": map[string]interface{}{ + "type": "boolean", + "description": "静默模式:不随输出流通知,仅保留定时反馈与结束提醒(推荐编译/下载等长任务)", + }, + "clear": map[string]interface{}{ + "type": "boolean", + "description": "清除该终端全部提醒规则(恢复默认行为)", + }, + }, + "required": []string{"id"}, + }, + }, func(args map[string]interface{}) (interface{}, error) { + return p.handleWatch(args) + }) + p.wg.Add(1) go p.cleanupLoop(s) @@ -378,18 +458,28 @@ func (p *Plugin) handleCreate(s *sdk.PluginSDK, args map[string]interface{}) (in cols = uint16(c) } + // 通知模式:默认 exit(命令执行结束后提醒一次)。 + // 支持 interval=30s / buffer=8192 / quiet,可逗号组合。 + watch := terminalWatch{onExit: true, quiet: true} + if notifyStr, ok := args["notify"].(string); ok && notifyStr != "" { + watch = parseNotifyMode(notifyStr, watch) + } + term, cmd, err := newCommandPty(command, rows, cols) if err != nil { return map[string]interface{}{"error": fmt.Sprintf("创建终端失败: %v", err)}, nil } session := &TerminalSession{ + id: "", + command: command, cmd: cmd, session: term, createdAt: time.Now(), timeout: timeout, stopCh: make(chan struct{}), done: make(chan struct{}), + watch: watch, } p.mu.Lock() @@ -404,15 +494,34 @@ func (p *Plugin) handleCreate(s *sdk.PluginSDK, args map[string]interface{}) (in log.Printf("[agentcli] created terminal %s: command=%q timeout=%v rows=%d cols=%d", id, command, timeout, rows, cols) return map[string]interface{}{ - "id": id, - "status": "created", - "command": command, - "timeout": timeout.String(), - "rows": rows, - "cols": cols, + "id": id, + "status": "created", + "command": command, + "timeout": timeout.String(), + "rows": rows, + "cols": cols, + "notify_mode": notifyModeString(watch), }, nil } +// notifyModeString 输出可读的通知模式描述。 +func notifyModeString(w terminalWatch) string { + var parts []string + if w.onExit { + parts = append(parts, "exit") + } + if w.interval > 0 { + parts = append(parts, "interval="+w.interval.String()) + } + if w.bufferBytes > 0 { + parts = append(parts, fmt.Sprintf("buffer=%d", w.bufferBytes)) + } + if len(parts) == 0 { + return "quiet" + } + return strings.Join(parts, ",") +} + func (p *Plugin) handleWrite(s *sdk.PluginSDK, args map[string]interface{}) (interface{}, error) { id, _ := args["id"].(string) if id == "" { @@ -455,13 +564,49 @@ func (p *Plugin) handleWrite(s *sdk.PluginSDK, args map[string]interface{}) (int }, nil } +// parseNotifyMode 解析 notify 参数并合并进 watch。 +// 支持:exit / quiet / interval=时长 / buffer=字节数,逗号分隔组合。 +func parseNotifyMode(s string, base terminalWatch) terminalWatch { + w := base + for _, part := range strings.Split(s, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + kv := strings.SplitN(part, "=", 2) + key := strings.TrimSpace(kv[0]) + val := "" + if len(kv) == 2 { + val = strings.TrimSpace(kv[1]) + } + switch key { + case "exit": + w.onExit = true + w.quiet = false + case "quiet", "silent": + w.quiet = true + case "interval": + if d, err := time.ParseDuration(val); err == nil && d > 0 { + w.interval = d + } + case "buffer": + var n int + if _, err := fmt.Sscanf(val, "%d", &n); err == nil && n > 0 { + w.bufferBytes = n + } + } + } + return w +} + func (p *Plugin) handleRead(args map[string]interface{}) (interface{}, error) { id, _ := args["id"].(string) if id == "" { return map[string]interface{}{"error": "id is required"}, nil } - clear := true + mode, _ := args["mode"].(string) + clear := mode != "now" if v, ok := args["clear"].(bool); ok { clear = v } @@ -474,19 +619,29 @@ func (p *Plugin) handleRead(args map[string]interface{}) (interface{}, error) { } var output string + session.mu.Lock() if clear { - output = session.ReadAndClearOutput() + output = session.buf.String() + session.buf.Reset() + // 实时画面推流缓冲同步清空,避免 terminal_output 事件与读取结果重复 + session.stream.Reset() } else { - output = session.ReadOutput() + output = session.buf.String() } + session.mu.Unlock() if output == "" { - output = "[终端无新输出]" + if mode == "now" { + output = "[终端当前无屏幕内容]" + } else { + output = "[终端无新输出]" + } } return map[string]interface{}{ "status": "ok", "terminal": id, + "mode": mode, "output": output, "running": terminalRunning(session), "uptime": time.Since(session.createdAt).String(), @@ -550,6 +705,55 @@ func (p *Plugin) handleClose(args map[string]interface{}) (interface{}, error) { }, nil } +func (p *Plugin) handleWatch(args map[string]interface{}) (interface{}, error) { + id, _ := args["id"].(string) + if id == "" { + return map[string]interface{}{"error": "id is required"}, nil + } + + p.mu.Lock() + session, ok := p.sessions[id] + p.mu.Unlock() + if !ok { + return map[string]interface{}{"error": fmt.Sprintf("终端 %s 不存在或已关闭", id)}, nil + } + + session.mu.Lock() + if v, ok := args["clear"].(bool); ok && v { + session.watch = terminalWatch{onExit: true} + } else { + if v, ok := args["interval"].(string); ok && v != "" { + if d, err := time.ParseDuration(v); err == nil && d >= 0 { + session.watch.interval = d + } + } + if v, ok := args["on_exit"].(bool); ok { + session.watch.onExit = v + } + if v, ok := args["buffer_bytes"].(float64); ok && v >= 0 { + session.watch.bufferBytes = int(v) + } + if v, ok := args["quiet"].(bool); ok { + session.watch.quiet = v + } + if session.watch.interval == 0 && session.watch.bufferBytes == 0 && !session.watch.quiet { + session.watch.onExit = true + } + } + w := session.watch + session.mu.Unlock() + + log.Printf("[agentcli] watch updated for %s: %+v", id, w) + return map[string]interface{}{ + "status": "ok", + "terminal": id, + "interval": w.interval.String(), + "on_exit": w.onExit, + "buffer_bytes": w.bufferBytes, + "quiet": w.quiet, + }, nil +} + func (p *Plugin) handleList() (interface{}, error) { p.mu.Lock() defer p.mu.Unlock() @@ -571,6 +775,7 @@ func (p *Plugin) handleList() (interface{}, error) { } terms = append(terms, termInfo{ ID: t.id, + Command: t.command, Uptime: time.Since(t.createdAt).Round(time.Second).String(), ExpiresIn: remaining.Round(time.Second).String(), Running: running, @@ -598,9 +803,24 @@ func (p *Plugin) readLoop(t *TerminalSession, s *sdk.PluginSDK) { readCh := make(chan readResult, 4) go p.reader(t, buf, readCh) + // 实时画面推流 ticker:每 200ms 批量发布一次 terminal_output 事件 + flushTicker := time.NewTicker(200 * time.Millisecond) + defer flushTicker.Stop() + // 立即发送首次"终端已启动"通知,让 agent 感知存在 s.InjectText("agentcli", "agentcli", fmt.Sprintf("[终端 %s 已启动]", t.id)) - t.lastNotify = time.Now() + now := time.Now() + t.mu.Lock() + t.lastNotify = now + t.lastData = now + t.lastFeedback = now + t.mu.Unlock() + +// 硬上限:未读输出积累达到该值也通知一次(防大输出静默丢失),频率极低 + hardNotifyBytes := 64 * 1024 + hardNotifyInterval := 10 * time.Second + // 输出停止判定:超过该时长无新数据则视为输出停止 + quietLatency := 2 * time.Second for { if t.IsExpired() { @@ -613,16 +833,52 @@ func (p *Plugin) readLoop(t *TerminalSession, s *sdk.PluginSDK) { } if !terminalRunning(t) { - s.InjectText("agentcli", "agentcli", fmt.Sprintf("[终端 %s 中的进程已退出]", t.id)) + if t.watch.onExit { + s.InjectText("agentcli", "agentcli", fmt.Sprintf("[终端 %s 中的命令已执行结束]", t.id)) + } else { + s.InjectText("agentcli", "agentcli", fmt.Sprintf("[终端 %s 中的进程已退出]", t.id)) + } p.mu.Lock() delete(p.sessions, t.id) p.mu.Unlock() return } + // 固定时间反馈:watch.interval > 0 时每隔该时长主动反馈一次状态摘要 + t.mu.Lock() + if t.watch.interval > 0 && time.Since(t.lastFeedback) >= t.watch.interval { + t.lastFeedback = time.Now() + t.lastNotify = t.lastFeedback + unread := t.unreadBytes + t.unreadBytes = 0 + preview := previewTail(t.buf.String(), 120) + t.mu.Unlock() + s.InjectText("agentcli", "agentcli", + fmt.Sprintf("[终端 %s 定时反馈: 运行中, 期间新输出约 %d 字节]\n%s", t.id, unread, preview)) + continue + } + t.mu.Unlock() + select { case <-t.stopCh: return + case <-flushTicker.C: + // 批量推送终端实时画面增量(独立 ticker,避免被高密度数据饿死) + var streamData string + t.mu.Lock() + if t.stream.Len() > 0 { + streamData = t.stream.String() + t.stream.Reset() + } + t.mu.Unlock() + if streamData != "" { + s.Publish(&sdk.Event{ + Type: sdk.EventTerminalOutput, + Source: "agentcli", + Payload: map[string]interface{}{"terminal_id": t.id, "output": streamData, "running": terminalRunning(t)}, + Timestamp: time.Now().UnixMilli(), + }) + } case r := <-readCh: if r.err != nil { // 读取错误/EOF → 立即通知(进程可能已结束) @@ -634,32 +890,66 @@ func (p *Plugin) readLoop(t *TerminalSession, s *sdk.PluginSDK) { copy(data, buf[:r.n]) t.appendOutput(data) - // 语义通知:累积未读字节数 + // 缓冲阈值通知(仅当 agent 显式选择 buffer 模式,或未读积累达到硬上限)。 + // 默认模式(仅 exit 提醒)下不随输出流通知,杜绝通知风暴。 t.mu.Lock() + t.lastData = time.Now() t.unreadBytes += r.n - needNotify := t.unreadBytes >= p.notifyBytes || - time.Since(t.lastNotify) >= p.notifyInterval - t.mu.Unlock() - - if needNotify { - t.mu.Lock() - preview := t.buf.String() - if len(preview) > 200 { - preview = preview[len(preview)-200:] // 取最新 200 字符 + bufThr := t.watch.bufferBytes + if bufThr <= 0 { + bufThr = p.notifyBytes + } + minInterval := p.notifyInterval + if t.watch.interval > 0 { + minInterval = t.watch.interval + } + // 风暴退避:距上次通知不足 1s 说明输出极速,通知间隔翻倍(上限 30s) + if time.Since(t.lastNotify) < time.Second && t.unreadBytes >= bufThr { + if t.backoff == 0 { + t.backoff = minInterval + } else if t.backoff < 30*time.Second { + t.backoff *= 2 + if t.backoff > 30*time.Second { + t.backoff = 30 * time.Second + } } - preview = sanitizePreview(preview) - t.unreadBytes = 0 + } + interval := t.backoff + minInterval + isHard := t.watch.bufferBytes <= 0 && t.unreadBytes >= hardNotifyBytes + if isHard && hardNotifyInterval > interval { + interval = hardNotifyInterval + } + need := t.unreadBytes >= bufThr && time.Since(t.lastNotify) >= interval + if need { t.lastNotify = time.Now() + t.unreadBytes = 0 + preview := previewTail(t.buf.String(), 200) + t.mu.Unlock() + s.InjectText("agentcli", "agentcli", + fmt.Sprintf("[终端 %s 有新输出]\n%s", t.id, preview)) + } else { t.mu.Unlock() - - s.InjectText("agentcli", "agentcli", fmt.Sprintf("[终端 %s 有新输出]\n%s", t.id, preview)) } } case <-time.After(pollInterval): + // 空闲轮询:输出已停止时复位退避 + t.mu.Lock() + if t.backoff > 0 && time.Since(t.lastData) >= quietLatency { + t.backoff = 0 + } + t.mu.Unlock() } } } +// previewTail 返回 s 末尾最多 n 字符,并转义控制字符保证可读。 +func previewTail(s string, n int) string { + if len(s) > n { + s = s[len(s)-n:] + } + return sanitizePreview(s) +} + type readResult struct { n int err error diff --git a/internal/plugins/agentcli/plugin_test.go b/internal/plugins/agentcli/plugin_test.go index fb6afae..66b1fd9 100644 --- a/internal/plugins/agentcli/plugin_test.go +++ b/internal/plugins/agentcli/plugin_test.go @@ -4,6 +4,9 @@ package agentcli import ( "encoding/json" + "fmt" + "strings" + "sync" "testing" "time" @@ -389,3 +392,253 @@ func TestToolsRegistered(t *testing.T) { } } } + +// ——— Phase 6: 通知节流测试(mock 终端 + 捕获注入) ——— + +type injectCapture struct { + mu sync.Mutex + texts []string +} + +func (c *injectCapture) InjectInterruptText(source, channel, text string) { + c.mu.Lock() + c.texts = append(c.texts, text) + c.mu.Unlock() +} +func (c *injectCapture) InjectText(source, channel, text string) { + c.mu.Lock() + c.texts = append(c.texts, text) + c.mu.Unlock() +} +func (c *injectCapture) InjectTextNoMemory(source, channel, text string) { + c.mu.Lock() + c.texts = append(c.texts, text) + c.mu.Unlock() +} +func (c *injectCapture) InjectInputSync(source, channel, text string) string { return "" } + +func (c *injectCapture) snapshot() []string { + c.mu.Lock() + defer c.mu.Unlock() + out := make([]string, len(c.texts)) + copy(out, c.texts) + return out +} + +// mockTerm 可控输出流的假终端:Read 从 data chan 取数据,可模拟进程退出/读取错误 +type mockTerm struct { + mu sync.Mutex + data chan []byte + running bool + err error +} + +func newMockTerm() *mockTerm { + return &mockTerm{data: make(chan []byte, 16), running: true} +} + +func (m *mockTerm) Read(buf []byte) (int, error) { + for { + m.mu.Lock() + err := m.err + running := m.running + m.mu.Unlock() + if err != nil { + return 0, err + } + if !running { + return 0, fmt.Errorf("process exited") + } + select { + case data, ok := <-m.data: + if !ok { + return 0, fmt.Errorf("closed") + } + n := copy(buf, data) + return n, nil + case <-time.After(20 * time.Millisecond): + } + } +} + +func (m *mockTerm) WriteString(s string) (int, error) { return len(s), nil } +func (m *mockTerm) Resize(rows, cols uint16) error { return nil } +func (m *mockTerm) Running() bool { + m.mu.Lock() + defer m.mu.Unlock() + return m.running +} +func (m *mockTerm) Kill() error { return nil } +func (m *mockTerm) Close() error { return nil } + +func (m *mockTerm) push(data []byte) { + m.data <- data +} + +func (m *mockTerm) setRunning(v bool) { + m.mu.Lock() + m.running = v + m.mu.Unlock() +} + +func (m *mockTerm) setErr(err error) { + m.mu.Lock() + m.err = err + m.mu.Unlock() +} + +func newTestSession(term ptyTerm) *TerminalSession { + return &TerminalSession{ + id: "t1", + session: term, + createdAt: time.Now(), + timeout: 10 * time.Minute, + stopCh: make(chan struct{}), + done: make(chan struct{}), + } +} + +func startReadLoop(p *Plugin, s *sdk.PluginSDK, t *TerminalSession) { + p.wg.Add(1) + go p.readLoop(t, s) +} + +func waitInjected(c *injectCapture, substr string, timeout time.Duration) bool { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + for _, text := range c.snapshot() { + if strings.Contains(text, substr) { + return true + } + } + time.Sleep(20 * time.Millisecond) + } + return false +} + +// Phase 6: 持续吐进度时,通知频率显著低于 500ms/条(节流生效) +func TestReadLoopNotifyThrottle(t *testing.T) { + p := New("agentcli") + p.notifyBytes = 2048 + p.notifyInterval = 2 * time.Second + + capture := &injectCapture{} + sdkInst := sdk.New("agentcli", sdk.SDKConfig{ + RegTool: newToolCapture().RegisterTool, + RegStage: func(sdk.Stage, sdk.StageHandler) {}, + RegAPI: func(string) error { return nil }, + Settings: sdk.NewSettings("agentcli", nil), + }) + sdkInst.SetIOInjector(capture) + + term := newMockTerm() + ts := newTestSession(term) + startReadLoop(p, sdkInst, ts) + + if !waitInjected(capture, "已启动", 2*time.Second) { + t.Fatal("expected startup notification") + } + + // 持续以 100B/50ms(=2KB/s) 吐进度 3 秒 + stop := make(chan struct{}) + go func() { + ticker := time.NewTicker(50 * time.Millisecond) + defer ticker.Stop() + chunk := make([]byte, 100) + for i := range chunk { + chunk[i] = 'x' + } + for { + select { + case <-stop: + return + case <-ticker.C: + term.push(chunk) + } + } + }() + + time.Sleep(3 * time.Second) + close(stop) + + notifies := 0 + for _, text := range capture.snapshot() { + if strings.Contains(text, "有新输出") { + notifies++ + } + } + // 3 秒持续输出,500ms/条 的旧行为应有 6 条;节流后 ≤3 条 + if notifies > 3 { + t.Errorf("notify throttle ineffective: %d notifies in 3s (expected <=3)", notifies) + } + if notifies == 0 { + t.Error("expected at least one output notification") + } + + close(ts.stopCh) + <-ts.done +} + +// Phase 6: 进程退出 → 立即通知(两条路径:PTY Read 返回 EOF 走"读取结束", +// 或 reader 阻塞时顶部 terminalRunning 检测走"进程已退出") +func TestReadLoopNotifyOnExit(t *testing.T) { + p := New("agentcli") + p.notifyBytes = 2048 + p.notifyInterval = 2 * time.Second + + capture := &injectCapture{} + sdkInst := sdk.New("agentcli", sdk.SDKConfig{ + RegTool: newToolCapture().RegisterTool, + RegStage: func(sdk.Stage, sdk.StageHandler) {}, + RegAPI: func(string) error { return nil }, + Settings: sdk.NewSettings("agentcli", nil), + }) + sdkInst.SetIOInjector(capture) + + term := newMockTerm() + ts := newTestSession(term) + startReadLoop(p, sdkInst, ts) + + if !waitInjected(capture, "已启动", 2*time.Second) { + t.Fatal("expected startup notification") + } + + term.setRunning(false) + gotExit := waitInjected(capture, "进程已退出", 2*time.Second) + gotReadEnd := waitInjected(capture, "读取结束", time.Second) + if !gotExit && !gotReadEnd { + t.Error("expected immediate notification on process exit (either 进程已退出 or 读取结束)") + } + close(ts.stopCh) +} + +// Phase 6: 读取错误/EOF → 立即通知 +func TestReadLoopNotifyOnReadError(t *testing.T) { + p := New("agentcli") + p.notifyBytes = 2048 + p.notifyInterval = 2 * time.Second + + capture := &injectCapture{} + sdkInst := sdk.New("agentcli", sdk.SDKConfig{ + RegTool: newToolCapture().RegisterTool, + RegStage: func(sdk.Stage, sdk.StageHandler) {}, + RegAPI: func(string) error { return nil }, + Settings: sdk.NewSettings("agentcli", nil), + }) + sdkInst.SetIOInjector(capture) + + term := newMockTerm() + ts := newTestSession(term) + startReadLoop(p, sdkInst, ts) + + if !waitInjected(capture, "已启动", 2*time.Second) { + t.Fatal("expected startup notification") + } + + term.setErr(fmt.Errorf("read timeout")) + if !waitInjected(capture, "读取结束", 3*time.Second) { + t.Error("expected immediate notification on read error") + } + close(ts.stopCh) + <-ts.done +} diff --git a/internal/plugins/agentcli/pty_windows.go b/internal/plugins/agentcli/pty_windows.go index b342c8e..56588d2 100644 --- a/internal/plugins/agentcli/pty_windows.go +++ b/internal/plugins/agentcli/pty_windows.go @@ -8,208 +8,31 @@ import ( "os/exec" "strings" "sync" - "syscall" - "unsafe" + + "gitcode.com/JianFeeeee/HomeAgent/internal/ptywin" ) -var ( - kernel32 = syscall.NewLazyDLL("kernel32.dll") - - procCreatePseudoConsole = kernel32.NewProc("CreatePseudoConsole") - procResizePseudoConsole = kernel32.NewProc("ResizePseudoConsole") - procClosePseudoConsole = kernel32.NewProc("ClosePseudoConsole") - procInitializeProcThreadAttributeList = kernel32.NewProc("InitializeProcThreadAttributeList") - procUpdateProcThreadAttribute = kernel32.NewProc("UpdateProcThreadAttribute") - procDeleteProcThreadAttributeList = kernel32.NewProc("DeleteProcThreadAttributeList") - procCreateProcessW = kernel32.NewProc("CreateProcessW") - procGetExitCodeProcess = kernel32.NewProc("GetExitCodeProcess") - procTerminateProcess = kernel32.NewProc("TerminateProcess") - procCloseHandle = kernel32.NewProc("CloseHandle") -) - -const ( - procThreadAttributePseudoConsole = 0x16 // PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE (22) - extendedStartupinfoPresent = 0x00080000 - createUnicodeEnvironment = 0x00000400 - stillActive = 259 // STILL_ACTIVE -) - -type coord struct { - x int16 - y int16 -} - -type processInformation struct { - process syscall.Handle - thread syscall.Handle - pid uint32 - tid uint32 -} - -// startupInfoEx 对应 STARTUPINFOEXW:STARTUPINFOW 之后追加 attribute list 指针。 -type startupInfoEx struct { - cb uint32 - lpReserved *uint16 - lpDesktop *uint16 - lpTitle *uint16 - dwX uint32 - dwY uint32 - dwXSize uint32 - dwYSize uint32 - dwXCountChars uint32 - dwYCountChars uint32 - dwFillAttribute uint32 - dwFlags uint32 - wShowWindow uint16 - cbReserved2 uint16 - lpReserved2 *byte - hStdInput syscall.Handle - hStdOutput syscall.Handle - hStdErr syscall.Handle - lpAttributeList uintptr -} - func defaultShell() string { return "cmd.exe" } -// windowsPty 基于 Windows ConPTY(Pseudo Console)的终端后端。 -// -// ConPTY 通过 CreatePseudoConsole 创建伪控制台,子进程以 -// PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE 挂到伪控制台。宿主侧使用两根 -// 管道与伪控制台通信:我们写 inW(输入)、读 outR(输出)。 +// windowsPty 基于 internal/ptywin(ConPTY)的终端后端。 type windowsPty struct { - hpc syscall.Handle // 伪控制台句柄 - inW *os.File // 我们向伪控制台写输入 - outR *os.File // 我们读伪控制台输出 - proc syscall.Handle // 子进程句柄 - procID int + c *ptywin.ConPty cmd *exec.Cmd - attrList []byte closeOnce sync.Once } // newCommandPty 创建 ConPTY 并在其上运行命令(cmd.exe /c )。 func newCommandPty(command string, rows, cols uint16) (ptyTerm, *exec.Cmd, error) { - inR, inW, err := os.Pipe() - if err != nil { - return nil, nil, fmt.Errorf("create input pipe: %w", err) - } - outR, outW, err := os.Pipe() - if err != nil { - inR.Close() - inW.Close() - return nil, nil, fmt.Errorf("create output pipe: %w", err) - } - - sz := coord{x: int16(cols), y: int16(rows)} - var hpc syscall.Handle - r, _, e := procCreatePseudoConsole.Call( - uintptr(unsafe.Pointer(&sz)), - inW.Fd(), - outR.Fd(), - 0, - uintptr(unsafe.Pointer(&hpc)), - ) - if r == 0 { - inR.Close() - inW.Close() - outR.Close() - outW.Close() - return nil, nil, fmt.Errorf("CreatePseudoConsole: %v", e) - } - - // 初始化 process thread attribute list 并注入伪控制台句柄 - attrList, err := buildAttrList(hpc) - if err != nil { - procClosePseudoConsole.Call(uintptr(hpc)) - inR.Close() - inW.Close() - outR.Close() - outW.Close() - return nil, nil, err - } - cmdLine := windowsCommandLine(command) - cli, err := syscall.UTF16PtrFromString(cmdLine) + c, err := ptywin.Start(cmdLine, ptywin.ConPtyDimensions(int(cols), int(rows))) if err != nil { - return nil, nil, err + return nil, nil, fmt.Errorf("conpty start: %v", err) } - var si startupInfoEx - si.cb = uint32(unsafe.Sizeof(si)) - si.lpAttributeList = uintptr(unsafe.Pointer(&attrList[0])) - - var pi processInformation - flags := uint32(extendedStartupinfoPresent | createUnicodeEnvironment) - r, _, e = procCreateProcessW.Call( - 0, // 应用名 - uintptr(unsafe.Pointer(cli)), // 命令行(CreateProcessW 会就地改写,可写 buffer) - 0, 0, // 无安全属性 - 0, // bInheritHandles FALSE - uintptr(flags), // 创建标志 - 0, // 环境(继承) - 0, // 工作目录 - uintptr(unsafe.Pointer(&si)), - uintptr(unsafe.Pointer(&pi)), - ) - if r == 0 { - procDeleteProcThreadAttributeList.Call(uintptr(unsafe.Pointer(&attrList[0]))) - procClosePseudoConsole.Call(uintptr(hpc)) - inR.Close() - inW.Close() - outR.Close() - outW.Close() - return nil, nil, fmt.Errorf("CreateProcessW: %v", e) - } - - // 子进程无需 pipe 的父侧副本;我们只保留 inW/outR - inR.Close() - outW.Close() - cmdObj := exec.Command("cmd.exe") - cmdObj.Process = &os.Process{Pid: int(pi.pid)} + cmdObj.Process = &os.Process{Pid: c.Pid()} - pt := &windowsPty{ - hpc: hpc, - inW: inW, - outR: outR, - proc: pi.process, - procID: int(pi.pid), - cmd: cmdObj, - attrList: attrList, - } - return pt, cmdObj, nil -} - -func buildAttrList(hpc syscall.Handle) ([]byte, error) { - var size uintptr - r, _, e := procInitializeProcThreadAttributeList.Call(0, 1, 0, uintptr(unsafe.Pointer(&size))) - if r == 0 || size == 0 { - return nil, fmt.Errorf("InitializeProcThreadAttributeList(size): %v", e) - } - buf := make([]byte, size) - r, _, e = procInitializeProcThreadAttributeList.Call( - uintptr(unsafe.Pointer(&buf[0])), - 1, - 0, - uintptr(unsafe.Pointer(&size)), - ) - if r == 0 { - return nil, fmt.Errorf("InitializeProcThreadAttributeList: %v", e) - } - r, _, e = procUpdateProcThreadAttribute.Call( - uintptr(unsafe.Pointer(&buf[0])), - 0, - procThreadAttributePseudoConsole, - uintptr(hpc), - unsafe.Sizeof(hpc), - 0, - 0, - ) - if r == 0 { - procDeleteProcThreadAttributeList.Call(uintptr(unsafe.Pointer(&buf[0]))) - return nil, fmt.Errorf("UpdateProcThreadAttribute: %v", e) - } - return buf, nil + return &windowsPty{c: c, cmd: cmdObj}, cmdObj, nil } func windowsCommandLine(command string) string { @@ -217,43 +40,24 @@ func windowsCommandLine(command string) string { } func (p *windowsPty) Read(buf []byte) (int, error) { - return p.outR.Read(buf) + return p.c.Read(buf) } func (p *windowsPty) WriteString(s string) (int, error) { - return p.inW.WriteString(s) + return p.c.Write([]byte(s)) } func (p *windowsPty) Resize(rows, cols uint16) error { - if p.hpc == 0 { - return fmt.Errorf("pseudo console closed") - } - sz := coord{x: int16(cols), y: int16(rows)} - r, _, e := procResizePseudoConsole.Call(uintptr(p.hpc), uintptr(unsafe.Pointer(&sz))) - if r == 0 { - return fmt.Errorf("ResizePseudoConsole: %v", e) - } - return nil + return p.c.Resize(int(cols), int(rows)) } func (p *windowsPty) Running() bool { - if p.proc == 0 { - return false - } - var code uint32 - r, _, _ := procGetExitCodeProcess.Call(uintptr(p.proc), uintptr(unsafe.Pointer(&code))) - if r == 0 { - // 句柄失效(进程已退出并释放句柄)视为停止 - return false - } - return code == stillActive + return p.c != nil && p.c.Running() } func (p *windowsPty) Kill() error { - if p.proc != 0 { - procTerminateProcess.Call(uintptr(p.proc), 1) - procCloseHandle.Call(uintptr(p.proc)) - p.proc = 0 + if p.c != nil { + return p.c.Kill() } return nil } @@ -261,28 +65,15 @@ func (p *windowsPty) Kill() error { func (p *windowsPty) Close() error { var errs []string p.closeOnce.Do(func() { - if p.inW != nil { - if err := p.inW.Close(); err != nil { + if p.c != nil { + if err := p.c.Close(); err != nil { errs = append(errs, err.Error()) } + p.c = nil } - if p.outR != nil { - if err := p.outR.Close(); err != nil { - errs = append(errs, err.Error()) - } - } - if p.hpc != 0 { - procClosePseudoConsole.Call(uintptr(p.hpc)) - p.hpc = 0 - } - if len(p.attrList) > 0 { - procDeleteProcThreadAttributeList.Call(uintptr(unsafe.Pointer(&p.attrList[0]))) - p.attrList = nil - } - _ = p.Kill() }) if len(errs) > 0 { return fmt.Errorf("close: %s", strings.Join(errs, "; ")) } return nil -} \ No newline at end of file +} diff --git a/internal/plugins/agentcli/pty_windows_test.go b/internal/plugins/agentcli/pty_windows_test.go new file mode 100644 index 0000000..4266266 --- /dev/null +++ b/internal/plugins/agentcli/pty_windows_test.go @@ -0,0 +1,65 @@ +//go:build windows + +package agentcli + +import ( + "strings" + "testing" + "time" +) + +// TestNewCommandPtyConPTY 验证 Windows ConPTY 后端:一次性命令输出可读, +// 交互式会话可写读往返。 +func TestNewCommandPtyConPTY(t *testing.T) { + ta, _, err := newCommandPty("cmd.exe /c echo conpty-ok", 24, 80) + if err != nil { + t.Fatalf("once: %v", err) + } + outA := drainFor(ta, 3*time.Second) + if !strings.Contains(string(outA), "conpty-ok") { + t.Fatalf("once output missing echo: %q", string(outA)) + } + ta.Close() + + tb, _, err := newCommandPty("cmd.exe", 24, 80) + if err != nil { + t.Fatalf("interactive: %v", err) + } + defer tb.Close() + time.Sleep(300 * time.Millisecond) + if _, err := tb.WriteString("echo hi-123\r\n"); err != nil { + t.Fatalf("write: %v", err) + } + outB := drainFor(tb, 3*time.Second) + if !strings.Contains(string(outB), "hi-123") { + t.Fatalf("interactive output missing echo: %q", string(outB)) + } + if !tb.Running() { + t.Fatalf("interactive shell should still be running") + } +} + +func drainFor(term ptyTerm, dur time.Duration) []byte { + deadline := time.Now().Add(dur) + buf := make([]byte, 4096) + var out []byte + for time.Now().Before(deadline) { + ch := make(chan struct{ N int; E error }, 1) + go func() { + n, e := term.Read(buf) + ch <- struct{ N int; E error }{n, e} + }() + select { + case r := <-ch: + if r.N > 0 { + out = append(out, buf[:r.N]...) + } + if r.E != nil { + return out + } + case <-time.After(500 * time.Millisecond): + return out + } + } + return out +} diff --git a/internal/plugins/cli/plugin.go b/internal/plugins/cli/plugin.go index 420aeae..f305e25 100644 --- a/internal/plugins/cli/plugin.go +++ b/internal/plugins/cli/plugin.go @@ -58,6 +58,14 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { payload, _ := args["payload"].(string) if payload != "" { fmt.Println(payload) + s.Publish(&sdk.Event{ + Type: sdk.EventAgentOutput, + Payload: map[string]interface{}{ + "content": payload, + "channel": "cli", + "kind": "channel_output", + }, + }) } return map[string]interface{}{"status": "ok"}, nil }) diff --git a/internal/plugins/cmd/plugin.go b/internal/plugins/cmd/plugin.go index 17dd874..0be0445 100644 --- a/internal/plugins/cmd/plugin.go +++ b/internal/plugins/cmd/plugin.go @@ -159,16 +159,19 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() - // Windows 上预置 chcp 65001 确保控制台输出为 UTF-8,避免 GBK 乱码 - execCmd := command + // Windows 上必须经 cmd.exe /c 执行(chcp 65001 预置为 UTF-8 输出), + // 直接 exec 会把整条命令当成一个程序路径导致所有命令失败。 + var cmd *exec.Cmd if isWindows { - execCmd = "chcp 65001>nul & " + command + execCmd := "chcp 65001>nul & " + command + cmd = exec.CommandContext(ctx, "cmd.exe", "/d", "/c", execCmd) + } else { + parts := shellUnquote(command) + if len(parts) == 0 { + return map[string]interface{}{"error": "command is required"}, nil + } + cmd = exec.CommandContext(ctx, parts[0], parts[1:]...) } - parts := shellUnquote(execCmd) - if len(parts) == 0 { - return map[string]interface{}{"error": "command is required"}, nil - } - cmd := exec.CommandContext(ctx, parts[0], parts[1:]...) if workdir != "" { cmd.Dir = workdir } diff --git a/internal/plugins/files/plugin.go b/internal/plugins/files/plugin.go index 81692b6..abed5f6 100644 --- a/internal/plugins/files/plugin.go +++ b/internal/plugins/files/plugin.go @@ -6,6 +6,7 @@ import ( "log" "os" "path/filepath" + "runtime" "sort" "strings" "sync" @@ -30,6 +31,8 @@ type Plugin struct { baseDir string // L0 写前留档根目录(/file_baseline 的父目录),空则禁用 } +var isWindowsBuild = runtime.GOOS == "windows" + func New(name string) *Plugin { return &Plugin{name: name} } @@ -178,12 +181,41 @@ func (p *Plugin) resolvePath(userPath string) (string, error) { return "", fmt.Errorf("resolve path: %w", err) } base := filepath.Clean(p.filesDir) - if base != "/" && !strings.HasPrefix(abs, base+string(filepath.Separator)) && abs != base { + if !pathWithinSandbox(abs, base) { return "", fmt.Errorf("path outside sandbox: %s", userPath) } return abs, nil } +// pathWithinSandbox 判断 abs 是否位于沙箱 base 之内。 +// Windows 文件系统大小写不敏感,且卷根目录(如 C:\)应放行全盘路径。 +func pathWithinSandbox(abs, base string) bool { + lower := func(s string) string { + if isWindowsBuild { + return strings.ToLower(s) + } + return s + } + abs = filepath.Clean(abs) + base = filepath.Clean(base) + if equalFoldPath(abs, base) { + return true + } + // 卷根沙箱(C:\、D:\ 等)表示整机可访问 + if isWindowsBuild && len(base) == 3 && base[1] == ':' && base[2] == '\\' { + return true + } + prefix := lower(base) + string(filepath.Separator) + return strings.HasPrefix(lower(abs), prefix) +} + +func equalFoldPath(a, b string) bool { + if isWindowsBuild { + return strings.EqualFold(a, b) + } + return a == b +} + func (p *Plugin) handleRead(args map[string]interface{}) (interface{}, error) { path, _ := args["path"].(string) if path == "" { diff --git a/internal/plugins/webui/dashboard.html b/internal/plugins/webui/dashboard.html index 5e5d06e..7e4a7d2 100644 --- a/internal/plugins/webui/dashboard.html +++ b/internal/plugins/webui/dashboard.html @@ -23,98 +23,192 @@ }, 8000); -