fix: LLM 工具循环 400、中断消息注入、ConPTY 终端支持

- agent: 工具轮请求尾部补 user 占位(zen 网关强制),tool 消息正确配对
- agent: 工具提醒/中断以 system 角色注入并带 [中断消息] 前缀,不进用户履历;系统提示词说明中断消息格式
- agentcli: 基于 ConPTY 的交互式终端(ptywin fork),terminal_create/read/write/resize/close/watch
- webui: server 输出通道适配器(保留 reasoning_content/disable_thinking)
- GUI: 沉浸式标题栏、icon 圆角重制、mascot 等打磨
This commit is contained in:
JianFeeeee
2026-08-14 00:48:40 +08:00
parent 816597caac
commit 147d0baaf9
43 changed files with 4670 additions and 1478 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 KiB

After

Width:  |  Height:  |  Size: 86 KiB

View File

@ -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 默认为 webuiHTTP
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 <apiKey>(若配置了密钥)。返回 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) {

View File

@ -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 }),
},
});

View File

@ -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 =
'<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2m0 16v2M4.9 4.9l1.4 1.4m11.4 11.4 1.4 1.4M2 12h2m16 0h2M4.9 19.1l1.4-1.4m11.4-11.4 1.4-1.4"/></svg>';
var ICON_MOON_GUI =
'<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>';
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 || '-', '')
+ '</div></div>';
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 = '<div class="chat-layout"><div class="chat-main">';
html += '<div class="card"><h2>' + __('对话','Chat') + ' <span id="chat-stage" class="badge" style="font-size:10px;font-weight:400;display:' + (state.chatLoading ? 'inline' : 'none') + '">' + escHtml(state.chatStage || '') + '</span></h2><div class="chat-messages" id="chat-msgs">';
var html = '<div class="chat-layout">';
html += '<div class="chat-tabs">'
+ '<span class="active" onclick="switchChatPanel(\'chat\',this)">' + __('对话','Chat') + '</span>'
+ '<span onclick="switchChatPanel(\'starmap\',this)">' + __('星图','Star Map') + '</span>'
+ '<span onclick="switchChatPanel(\'terminal\',this)">' + __('终端','Terminal') + '</span>'
+ '<span onclick="switchChatPanel(\'cmd\',this)">' + __('运行中命令','Running Commands') + '</span>'
+ '<span onclick="switchChatPanel(\'memory\',this)">' + __('记忆','Memory') + '</span>'
+ '<span onclick="switchChatPanel(\'context\',this)">' + __('上下文','Context') + '</span>'
+ '<span onclick="switchChatPanel(\'knowledge\',this)">' + __('知识','Knowledge') + '</span>'
+ '</div>';
if (!state.currentConn) {
html += '<div class="chat-panel active" id="chat-panel-chat"><div class="card setup-card">'
+ '<svg class="setup-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg>'
+ '<h2>' + __('未配置后端','No backend configured') + '</h2>'
+ '<p>' + __('连接 HomeAgent 服务端后即可开始对话。请在设置中添加后端连接。','Connect to a HomeAgent server to start chatting. Add a backend connection in Settings.') + '</p>'
+ '<button class="btn btn-primary" onclick="goSettingsConn()">' + __('前往设置添加后端','Go to Settings to add backend') + '</button>'
+ '</div></div>';
for (var p2 = 0; p2 < 6; p2++) {
html += '<div class="chat-panel" id="chat-panel-' + ['starmap','terminal','cmd','memory','context','knowledge'][p2] + '"><div class="card"><p style="color:var(--text-muted)">' + __('请先在设置中添加后端连接','Add a backend connection in Settings first') + '</p></div></div>';
}
cont.innerHTML = html;
_chatLayoutBuilt = true;
return;
}
html += '<div class="chat-panel active" id="chat-panel-chat"><div class="chat-main">';
html += '<div class="card"><h2>' + __('对话','Chat') + ' <span id="chat-stage" class="badge" style="font-size:10px;font-weight:400;display:none">' + escHtml(state.chatStage || '') + '</span></h2><div class="chat-messages" id="chat-msgs">';
if (state.messages.length === 0) {
html += '<div class="empty-state" style="flex:1;display:flex;align-items:center;justify-content:center"><p>' + __('开始对话以测试 Agent 回复','Start a conversation to test Agent replies') + '</p></div>';
}
@ -288,35 +356,28 @@ function buildChatLayout() {
+ '<div class="chat-input-row">'
+ '<input id="chat-input" placeholder="' + __('输入消息...','Type a message...') + '" onkeydown="if(event.key==\'Enter\')sendChat()">'
+ '<button class="btn btn-primary" onclick="sendChat()" id="chat-send-btn">' + __('发送','Send') + '</button>'
+ '</div></div>';
html += '</div><div class="chat-sidebar">'
+ '<div class="card" style="padding:12px"><h2 style="font-size:13px;margin-bottom:8px">' + __('星图','Star Map') + '</h2>'
+ '<div id="sm-container-chat" style="height:160px;display:flex;align-items:center;justify-content:center"><div class="loading-spinner"></div></div></div>'
+ '<div class="card" style="padding:12px"><h2 style="font-size:13px;margin-bottom:8px">' + __('终端','Terminal') + ' <span id="term-count-badge" class="badge badge-blue">0</span></h2>'
+ '<div id="term-list" style="max-height:160px;overflow-y:auto;font-size:11px"></div></div>'
+ '<div class="card" style="padding:12px"><h2 style="font-size:13px;margin-bottom:8px">' + __('命令历史','Command History') + ' <span id="cmd-count-badge" class="badge badge-blue">0</span></h2>'
+ '<div id="cmd-list" style="max-height:120px;overflow-y:auto;font-size:11px"></div></div>'
+ '<div class="card" style="padding:12px">'
+ '<div class="sidebar-subnav">'
+ '<span class="active" onclick="switchChatSub(\'memory\',this)">' + __('记忆','Memory') + '</span>'
+ '<span onclick="switchChatSub(\'context\',this)">' + __('上下文','Context') + '</span>'
+ '<span onclick="switchChatSub(\'knowledge\',this)">' + __('知识','Knowledge') + '</span>'
+ '</div>'
+ '<div id="chat-sub-memory">'
+ '</div></div></div></div>';
html += '<div class="chat-panel" id="chat-panel-starmap"><div class="card"><h2>' + __('星图','Star Map') + '</h2>'
+ '<div id="sm-container-chat" style="display:flex;align-items:center;justify-content:center;min-height:480px"><div class="loading-spinner"></div></div></div></div>';
html += '<div class="chat-panel" id="chat-panel-terminal"><div class="card"><h2>' + __('终端','Terminal') + ' <span id="term-count-badge" class="badge badge-blue">0</span></h2>'
+ '<div id="term-list" style="max-height:60vh;overflow-y:auto;font-size:12px"></div></div></div>';
html += '<div class="chat-panel" id="chat-panel-cmd"><div class="card"><h2>' + __('运行中命令','Running Commands') + ' <span id="cmd-count-badge" class="badge badge-blue">0</span></h2>'
+ '<div id="cmd-list" style="max-height:60vh;overflow-y:auto;font-size:12px"></div></div></div>';
html += '<div class="chat-panel" id="chat-panel-memory"><div class="card"><h2>' + __('记忆','Memory') + '</h2>'
+ '<div class="kv-row"><span class="key">' + __('实体','Entities') + '</span><span class="val">' + (k?.memory?.entity_count || '-') + '</span></div>'
+ '<div class="kv-row"><span class="key">' + __('关系','Relations') + '</span><span class="val">' + (k?.memory?.relation_count || '-') + '</span></div>'
+ '<div style="margin-top:8px">'
+ '<input id="mem-query" placeholder="' + __('关键词查询','Keyword query') + '">'
+ '<button class="btn btn-primary btn-sm" onclick="queryMemoryChat()">' + __('查询','Query') + '</button>'
+ '</div><div id="mem-result-chat" style="margin-top:8px;max-height:180px;overflow:auto"></div>'
+ '</div>'
+ '<div id="chat-sub-context" style="display:none">'
+ '</div></div>';
html += '<div class="chat-panel" id="chat-panel-context"><div class="card"><h2>' + __('上下文','Context') + '</h2>'
+ '<div style="margin-top:8px">'
+ '<input id="ctx-query" placeholder="' + __('输入当前话题','Enter current topic') + '">'
+ '<button class="btn btn-primary btn-sm" onclick="queryMemoryContext()">' + __('获取上下文','Get Context') + '</button>'
+ '</div><div id="ctx-result" style="margin-top:8px;max-height:200px;overflow:auto"></div>'
+ '</div>'
+ '<div id="chat-sub-knowledge" style="display:none">'
+ '</div></div>';
html += '<div class="chat-panel" id="chat-panel-knowledge"><div class="card"><h2>' + __('知识','Knowledge') + '</h2>'
+ '<div class="kv-row"><span class="key">' + __('项目','Items') + '</span><span class="val">' + (k?.knowledge?.item_count || '-') + '</span></div>'
+ '<div style="margin-top:8px">'
+ '<input id="know-query" placeholder="' + __('搜索知识','Search knowledge') + '">'
@ -326,16 +387,67 @@ function buildChatLayout() {
+ '<input id="know-name" placeholder="' + __('知识名称','Knowledge name') + '" style="margin-bottom:4px">'
+ '<textarea id="know-content" placeholder="' + __('内容','Content') + '" style="min-height:50px;margin-bottom:4px"></textarea>'
+ '<button class="btn btn-primary btn-sm" onclick="createKnowledgeChat()">' + __('创建','Create') + '</button>'
+ '</div></div></div></div></div>';
+ '</div></div></div>';
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 += '<span class="thinking-tool' + anim + '" data-tool="' + escHtml(nm) + '">'
+ '<svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M14.7 6.3a4 4 0 0 0-5.4 5.4L3 18l3 3 6.3-6.3a4 4 0 0 0 5.4-5.4l-2.9 2.9-2.5-.6-.6-2.5z"/></svg>'
+ escHtml(nm) + '</span>';
});
return s;
}
var html = '';
if (msgs.length === 0) {
html = '<div class="empty-state" style="flex:1;display:flex;align-items:center;justify-content:center"><p>' + __('开始对话以测试 Agent 回复','Start a conversation to test Agent replies') + '</p></div>';
@ -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 = '<div class="reasoning">'
+ '<div class="reasoning-title" onclick="var n=this.nextElementSibling;n.style.display=n.style.display===\'none\'?\'block\':\'none\';this.textContent=this.textContent===\'' + __('收起思考','Collapse') + '\'?\'' + __('展开思考','Expand') + '\':\'' + __('收起思考','Collapse') + '\'">' + __('收起思考','Collapse') + '</div>'
+ '<div class="reasoning-body" style="display:none">' + rcBody + '</div></div>';
rc = '<div class="msg-bubble"><div class="reasoning">'
+ '<div class="reasoning-title" onclick="var n=this.nextElementSibling;n.style.display=n.style.display===\'none\'?\'block\':\'none\';this.textContent=this.textContent===\'' + __('展开思考','Expand') + '\'?\'' + __('收起思考','Collapse') + '\':\'' + __('展开思考','Expand') + '\'">' + __('展开思考','Expand') + '</div>'
+ '<div class="reasoning-body" style="display:none">' + rcBody + '</div></div></div>';
}
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 += '<div class="tool-call">'
+ '<div><span class="tc-name">' + statusIcon + ' ' + escHtml(tc.tool || tc.name || '') + '</span></div>'
var resultStr = tc.result ? (typeof tc.result === 'object' ? JSON.stringify(tc.result, null, 1) : String(tc.result)) : '';
var statusIcon = tc.status === 'denied'
? '<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" style="color:var(--error);vertical-align:-1px"><circle cx="12" cy="12" r="9"/><path d="M5.6 5.6l12.8 12.8"/></svg>'
: '<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" style="color:var(--accent);vertical-align:-1px"><path d="M14.7 6.3a4 4 0 0 0-5.4 5.4L3 18l3 3 6.3-6.3a4 4 0 0 0 5.4-5.4l-2.9 2.9-2.5-.6-.6-2.5z"/></svg>';
tcs += '<div class="msg-bubble"><div class="tool-call' + (newlyDone.indexOf(tc.tool || tc.name || '') !== -1 ? ' tool-drip-in' : '') + '" onclick="toggleToolCall(this)">'
+ '<div class="tc-line"><span class="tc-ico">' + statusIcon + '</span><span class="tc-name">' + escHtml(tc.tool || tc.name || '') + '</span>'
+ (tc.status === 'denied'
? '<span class="tc-state tc-deny">' + __('已拒绝','Denied') + '</span>'
: (resultStr
? '<span class="tc-state tc-done">' + __('完成','Done') + '</span>'
: '<span class="tc-state tc-run">' + __('调用中','Running') + '</span>'))
+ '<span class="tc-caret">▾</span></div>'
+ '<div class="tc-detail" style="display:none">'
+ (argsStr && argsStr !== '{}' ? '<div class="tc-args">' + escHtml(argsStr) + '</div>' : '')
+ (resultStr ? '<div class="tc-result">' + escHtml(resultStr) + '</div>' : '')
+ '</div>';
+ (resultStr ? '<div class="tc-result">' + escHtml(resultStr) + '</div>' : '')
+ '</div></div></div>';
});
}
var body = rc + tcs + '<div class="text">' + c + '</div>';
if (m.source && m.source !== 'webui') {
body = '<div class="msg-source">' + escHtml(__('通道','Channel')) + ': ' + escHtml(m.source) + '</div>' + 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 = '<span class="live-spinner"></span>' + (newPending.length ? '<span class="thinking-tools">' + pillHtml() + '</span>' : '');
if (c) {
body += '<div class="msg-bubble' + growCls + '">' + liveRow + '<div class="text">' + c + '</div></div>';
c = '';
} else {
body += '<div class="msg-bubble">' + liveRow + '</div>';
}
} else if (c) {
body += '<div class="msg-bubble' + growCls + '"><div class="text">' + c + '</div></div>';
}
if (role === 'system') {
html += '<div class="msg msg-system"><div class="msg-bubble">' + body + '</div></div>';
html += '<div class="msg msg-system"><div class="msg-bubble">' + (c || '') + '</div></div>';
} else if (isChan) {
html += '<div class="msg msg-channel">'
+ '<div class="msg-avatar chan-avatar" style="background:' + chanColor(m.source) + '">' + chanLetter(m.source) + '</div>'
+ '<div class="msg-content"><div class="msg-chan-name">' + escHtml(m.source) + '</div>' + body + '</div>'
+ '</div>';
} else {
var userAvatar = '<svg viewBox="0 0 24 24" style="width:16px;height:16px" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 4-6 8-6s8 2 8 6"/></svg>';
var aiAvatar = '<img src="mascot.svg" style="width:28px;height:28px;border-radius:50%;object-fit:cover" alt="' + __('小宅','Agent') + '">';
var aiAvatar = '<img src="mascot.webp" style="width:28px;height:28px;border-radius:50%;object-fit:cover" alt="' + __('小宅','Agent') + '">';
html += '<div class="msg msg-' + role + '">'
+ '<div class="msg-avatar">' + (role === 'user' ? userAvatar : aiAvatar) + '</div>'
+ '<div class="msg-content"><div class="msg-bubble">' + body + '</div></div>'
+ '<div class="msg-content">' + body + '</div>'
+ '</div>';
}
});
}
if (state.chatLoading && !streamingLast) {
var aiAvatar2 = '<img src="mascot.webp" style="width:28px;height:28px;border-radius:50%;object-fit:cover" alt="' + __('小宅','Agent') + '">';
html += '<div class="msg msg-assistant"><div class="msg-avatar">' + aiAvatar2 + '</div><div class="msg-content"><div class="msg-bubble">'
+ '<span class="live-spinner"></span>'
+ (newPending.length ? '<span class="thinking-tools">' + pillHtml() + '</span>' : '')
+ '</div></div></div>';
}
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 = '<span style="color:#5c6672">' + __('[终端暂无输出]','[No terminal output]') + '</span>';
} else {
fullOut = escHtml(fullOut);
}
html += '<div style="border:1px solid var(--border-color);border-radius:6px;margin-bottom:4px;font-size:11px">';
html += '<div style="display:flex;align-items:center;gap:6px;padding:6px 8px;cursor:pointer;background:var(--bg-hover)" onclick="var d=document.getElementById(\'' + detailId + '\');d.style.display=d.style.display===\'none\'?\'block\':\'none\'">';
html += '<span style="font-family:monospace;font-size:10px;flex:1">' + escHtml(t.id || '-') + '</span>';
html += '<span style="flex:1;color:var(--text-muted)">' + escHtml(t.command || '') + '</span>';
html += '<span class="badge ' + (t.running ? 'badge-green' : 'badge-red') + '">' + (t.running ? __('运行中','Running') : __('已关闭','Closed')) + '</span>';
html += '<span style="flex:1;color:var(--text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + escHtml(t.command || '') + '</span>';
html += '<span class="badge ' + (running ? 'badge-green' : 'badge-red') + '">' + (running ? __('运行中','Running') : __('已关闭','Closed')) + '</span>';
html += '<span style="color:var(--text-muted);font-size:10px">' + escHtml(t.created_at || '') + '</span>';
html += '</div>';
html += '<div id="' + detailId + '" style="display:none;padding:8px;border-top:1px solid var(--border-color);background:var(--bg-input)">';
html += '<div class="kv-row"><span class="key">ID</span><span class="val" style="font-family:monospace">' + escHtml(t.id || '-') + '</span></div>';
html += '<div class="kv-row"><span class="key">' + __('命令','Command') + '</span><span class="val">' + escHtml(t.command || '-') + '</span></div>';
html += '<div class="kv-row"><span class="key">' + __('状态','Status') + '</span><span class="val">' + (t.running ? __('运行中','Running') : __('已关闭','Closed')) + '</span></div>';
html += '<div class="kv-row"><span class="key">' + __('创建时间','Created') + '</span><span class="val">' + escHtml(t.created_at || '-') + '</span></div>';
if (t.uptime) html += '<div class="kv-row"><span class="key">' + __('运行时长','Uptime') + '</span><span class="val">' + escHtml(t.uptime) + '</span></div>';
if (t.output) {
html += '<div class="kv-row"><span class="key">' + __('输出预览','Output') + '</span><span class="val"><pre style="font-size:10px;margin:0;max-height:100px;overflow:auto">' + escHtml((t.output || '').substring(0, 500)) + '</pre></span></div>';
}
html += '</div></div>';
html += '<div class="term-screen">';
html += '<div class="term-head"><span class="term-dot' + (running ? '' : ' stopped') + '" id="term-dot-' + escHtml(t.id) + '"></span><span style="font-weight:600">' + escHtml(t.id) + '</span><span>' + escHtml(t.command || '') + '</span><span style="flex:1"></span><span>' + escHtml(t.uptime || '') + '</span></div>';
html += '<pre class="term-buf" id="term-buf-' + escHtml(t.id) + '">' + fullOut + '</pre>';
html += '</div></div></div>';
});
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 = '<p style="color:var(--text-muted);padding:8px;text-align:center;font-size:11px">' + __('暂无命令记录','No command history') + '</p>';
var running = (state.terminals || []).filter(function(t) { return t.running; });
if (cnt) cnt.textContent = running.length;
if (running.length === 0) {
r.innerHTML = '<p style="color:var(--text-muted);padding:8px;text-align:center;font-size:11px">' + __('暂无运行中的命令','No running commands') + '</p>';
return;
}
var html = '<table style="font-size:10px"><tr><th>' + __('命令','Command') + '</th><th>' + __('状态','Status') + '</th><th>' + __('时间','Time') + '</th></tr>';
list.slice().reverse().slice(0, 50).forEach(function(c) {
var html = '<table style="font-size:10px"><tr><th>' + __('命令','Command') + '</th><th>' + __('状态','Status') + '</th><th>' + __('运行时长','Uptime') + '</th></tr>';
running.forEach(function(t) {
var scr = (state.termScreens && state.termScreens[t.id]) || null;
var out = scr ? scr.output : t.output || '';
html += '<tr>'
+ '<td style="font-family:monospace;max-width:180px;overflow:hidden;text-overflow:ellipsis">' + escHtml(c.command || '') + '</td>'
+ '<td><span class="badge ' + (c.status === 'ok' ? 'badge-green' : 'badge-red') + '">' + escHtml(c.status || '') + '</span></td>'
+ '<td style="color:var(--text-muted);white-space:nowrap">' + escHtml((c.time || '').substring(0, 19)) + '</td>'
+ '<td style="font-family:monospace;max-width:200px;overflow:hidden;text-overflow:ellipsis">' + escHtml(t.command || t.id || '') + '</td>'
+ '<td><span class="badge badge-green">' + __('运行中','Running') + '</span></td>'
+ '<td style="color:var(--text-muted);white-space:nowrap">' + escHtml(t.uptime || '-') + '</td>'
+ '</tr>';
if (out) {
html += '<tr><td colspan="3" style="padding:0"><pre style="margin:0;padding:4px 8px;max-height:120px;overflow:auto;background:var(--bg-input);border-radius:4px;font-size:10px;color:var(--text-secondary)">' + escHtml(out.substring(0, 2000)) + '</pre></td></tr>';
}
});
html += '</table>';
r.innerHTML = html;
@ -936,7 +1118,7 @@ function renderPlugins() {
html += '<p style="color:var(--text-muted);font-size:13px">' + __('点击上方按钮运行','Click the button above to run') + '</p>';
}
html += '</div></div>';
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 = '<div class="card"><p style="color:var(--text-muted)">' + __('内核未响应','Kernel not responding') + '</p></div>'; return }
if (!k) { document.getElementById('view-kernel').innerHTML = '<div class="card"><p style="color:var(--text-muted)">' + __('内核未响应','Kernel not responding') + '</p></div>'; return }
var html = '<div class="card"><h2>' + __('运行时','Runtime') + '</h2><div class="grid-3">'
+ statCard('Goroutines', k?.runtime?.goroutines || '-', '')
+ statCard(__('内存','Memory'), k?.runtime?.memory_mb ? k.runtime.memory_mb + ' MB' : '-', '')
@ -1053,7 +1235,7 @@ function renderKernel() {
html += '<p style="color:var(--text-muted)">' + __('无','None') + '</p>';
}
html += '</div>';
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 = '<div class="settings-layout"><div class="settings-sidebar"></div><div class="settings-content">';
var html = '<div class="card"><h2>' + __('后端连接','Backend Connections') + '</h2>'
+ '<div id="conn-manager"></div></div>'
+ '<div class="settings-tabs" id="settings-tabs"></div>'
+ '<div class="settings-content">';
if (regularKeys.length === 0 && Object.keys(sourceMap).length === 0 && Object.keys(mcpServerMap).length === 0 && state.selectedSection !== 'plugin.mcp') {
html += '<div class="card"><h2>' + escHtml(state.selectedSection) + '</h2><p style="color:var(--text-muted)">' + __('暂无设置项','No settings') + '</p></div>';
} else {
@ -1371,8 +1556,9 @@ function renderOneSettings() {
}
}
html += '</div></div>';
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 = '<div class="card"><h2>' + __('设置','Settings') + '</h2><p style="color:var(--text-muted)">' + __('设置面板已加载','Settings panel loaded') + '</p></div>';
document.getElementById('view-settings').innerHTML = '<div class="card"><h2>' + __('设置','Settings') + '</h2><p style="color:var(--text-muted)">' + __('设置面板已加载','Settings panel loaded') + '</p></div>';
renderOneSettings();
}
@ -1458,8 +1644,7 @@ async function deleteMCPServer(name) {
// ===== Adapters =====
async function renderAdapters() {
var html = '<div class="card"><h2>' + __('Lua 适配器管理','Lua Adapter Management') + '</h2>'
+ '<p style="color:var(--text-muted);font-size:12px;margin-bottom:12px">' + __('上传自定义 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.') + '</p></div>';
var html = '';
try {
var r = await api('/adapters');
var adapters = r.adapters || [];
@ -1485,7 +1670,7 @@ async function renderAdapters() {
} catch(e) {
html += '<div class="card"><p style="color:var(--text-muted)">' + __('加载适配器失败: ','Failed to load adapters: ') + escHtml(e.message) + '</p></div>';
}
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 += '<div class="card"><p style="color:var(--text-muted)">' + __('暂无后端连接,添加一个以开始使用','No backend connections yet. Add one to get started.') + '</p></div>';
} 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 = '<span class="status-dot ' + (state.currentConn && state.currentConn.id === c.id ? 'dot-green' : 'dot-gray') + '"></span>'
+ '<div class="conn-info"><div class="conn-name">' + escHtml(c.name) + '</div><div class="conn-url">' + escHtml(c.url) + '</div></div>'
+ '<div class="conn-actions">'
+ '<button class="btn btn-ghost btn-sm" onclick="selectConnection(\'' + c.id + '\')">' + __('连接','Connect') + '</button> '
+ '<button class="btn btn-ghost btn-sm" onclick="editConnection(\'' + c.id + '\', event)">' + __('编辑','Edit') + '</button> '
+ '<button class="btn btn-danger btn-sm" onclick="deleteConnection(\'' + c.id + '\', event)">' + __('删除','Delete') + '</button></div>';
cont.appendChild(div);
});
}
var form = document.createElement('div');
form.className = 'conn-form';
form.id = 'conn-form';
form.style.display = 'none';
form.innerHTML = '<h3 id="conn-form-title">' + __('添加连接','Add Connection') + '</h3>'
+ '<label>' + __('名称','Name') + '</label><input id="conn-name" placeholder="My HomeAgent">'
+ '<label>' + __('连接类型','Type') + '</label><select id="conn-type" onchange="toggleConnType()">'
+ '<option value="webui">WebUI (HTTP)</option>'
+ '<option value="cli">CLI (unix socket)</option></select>'
+ '<div id="conn-addr-webui"><label>' + __('地址','URL') + '</label><input id="conn-url" placeholder="http://localhost:18080"></div>'
+ '<div id="conn-addr-cli" style="display:none"><label>' + __('Socket 路径','Socket Path') + '</label><input id="conn-sock" placeholder="C:\\path\\to\\cli.sock"></div>'
+ '<label>' + __('API 密钥','API Key') + ' <span style="color:var(--text-muted);font-weight:400">(' + __('可选','optional') + ')</span></label>'
+ '<input id="conn-key" type="password" placeholder="sk-...">'
+ '<div class="conn-form-actions">'
+ '<button class="btn btn-ghost" onclick="cancelConnForm()">' + __('取消','Cancel') + '</button>'
+ '<button class="btn btn-primary" onclick="saveConnForm()" id="conn-save-btn">' + __('保存','Save') + '</button></div>';
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 '<div class="conn-item ' + (state.currentConn && state.currentConn.id === c.id ? 'active' : '') + '" onclick="selectConnection(\'' + c.id + '\')">'
+ '<span class="status-dot ' + (state.currentConn && state.currentConn.id === c.id ? 'dot-green' : 'dot-gray') + '"></span>'
+ '<div class="conn-info"><div class="conn-name">' + escHtml(c.name) + '</div><div class="conn-url">' + escHtml(c.url) + '</div></div>'
+ '<div class="conn-actions">'
+ '<button class="btn btn-ghost btn-sm" onclick="editConnection(\'' + c.id + '\', event)">' + __('编辑','Edit') + '</button>'
+ '<button class="btn btn-danger btn-sm" onclick="deleteConnection(\'' + c.id + '\', event)">' + __('删除','Delete') + '</button></div></div>';
}).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 (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; }
} catch(e) {
toast(__('无法连接到 ','Cannot connect to ') + url + ': ' + e.message, 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;
}
testBtn.textContent = __('保存 / Save','Save'); testBtn.disabled = false;
} 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 ') + (ctype === 'cli' ? sock : url) + ': ' + e.message, true);
testBtn.textContent = __('保存','Save'); testBtn.disabled = false; return;
}
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') {
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 (p.channel !== '_consolidation_') {
if (phase === 'pre_action') state.chatStage = __('AI 思考中...','AI thinking...');
else if (phase === 'before_toolcall') state.chatStage = __('工具调用: ','Tool: ') + (tool || '');
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(); }
}

28
cmd/gui/renderer/icon.svg Normal file
View File

@ -0,0 +1,28 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" width="400" height="400">
<rect width="400" height="400" rx="60" ry="60" fill="#F8FAFC"/>
<g transform="translate(200,200)">
<circle cx="0" cy="0" r="105" fill="none" stroke="#E2E8F0" stroke-width="2.5" stroke-dasharray="8,6"/>
<rect x="-130" y="-170" width="36" height="340" rx="8" fill="#3B82F6"/>
<rect x="94" y="-170" width="36" height="140" rx="8" fill="#CBD5E1"/>
<rect x="94" y="70" width="36" height="100" rx="8" fill="#CBD5E1"/>
<rect x="-94" y="-18" width="188" height="36" rx="8" fill="#38BDF8"/>
<polygon points="0,-28 24.2,14 -24.2,14" fill="#F59E0B" stroke="#F59E0B" stroke-width="8" stroke-linejoin="round"/>
<circle cx="74" cy="-74" r="14" fill="#DBEAFE" stroke="#3B82F6" stroke-width="2.5"/>
<line x1="28" y1="-28" x2="63" y2="-63" stroke="#3B82F6" stroke-width="3" stroke-dasharray="6,4" opacity="0.6"/>
<circle cx="-74" cy="-74" r="14" fill="#EDE9FE" stroke="#8B5CF6" stroke-width="2.5"/>
<line x1="-28" y1="-28" x2="-63" y2="-63" stroke="#8B5CF6" stroke-width="3" stroke-dasharray="6,4" opacity="0.6"/>
<circle cx="74" cy="74" r="14" fill="#CFFAFE" stroke="#06B6D4" stroke-width="2.5"/>
<line x1="28" y1="28" x2="63" y2="63" stroke="#06B6D4" stroke-width="3" stroke-dasharray="6,4" opacity="0.6"/>
<circle cx="-74" cy="74" r="14" fill="#FEF3C7" stroke="#F59E0B" stroke-width="2.5"/>
<line x1="-28" y1="28" x2="-63" y2="63" stroke="#F59E0B" stroke-width="3" stroke-dasharray="6,4" opacity="0.6"/>
<circle cx="0" cy="0" r="42" fill="none" stroke="#F59E0B" stroke-width="4"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -4,6 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HomeAgent</title>
<link rel="icon" type="image/svg+xml" href="icon.svg">
<link rel="stylesheet" href="style.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js" onerror="window._THREE_FAILED=true"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js" onerror="window._THREE_FAILED=true"></script>
@ -12,56 +13,67 @@
</head>
<body>
<!-- Connection Manager Overlay -->
<div id="conn-overlay" class="overlay">
<div class="overlay-content conn-manager">
<h2>连接管理 / Connections</h2>
<div class="conn-list" id="conn-list"></div>
<div class="conn-form" id="conn-form" style="display:none">
<h3 id="conn-form-title">添加连接 / Add Connection</h3>
<label>名称 / Name</label>
<input id="conn-name" placeholder="My HomeAgent">
<label>地址 / URL</label>
<input id="conn-url" placeholder="http://localhost:8080">
<label>API 密钥 / API Key <span style="color:var(--text-muted);font-weight:400">(可选)</span></label>
<input id="conn-key" type="password" placeholder="sk-...">
<div class="conn-form-actions">
<button class="btn btn-ghost" onclick="cancelConnForm()">取消 / Cancel</button>
<button class="btn btn-primary" onclick="saveConnForm()">保存 / Save</button>
</div>
</div>
<button class="btn btn-primary" onclick="showConnForm()" id="conn-add-btn" style="margin-top:12px">+ 添加连接 / Add Connection</button>
<div class="titlebar" id="titlebar">
<div class="titlebar-controls">
<button class="tb-btn" id="tb-min" title="最小化" onclick="window.homeagent&&homeagent.win.minimize()">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><line x1="4" y1="12" x2="20" y2="12"/></svg>
</button>
<button class="tb-btn" id="tb-max" title="最大化" onclick="window.homeagent&&homeagent.win.toggleMaximize()">
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><rect x="5" y="5" width="14" height="14" rx="1.5"/></svg>
</button>
<button class="tb-btn tb-close" id="tb-close" title="关闭" onclick="window.homeagent&&homeagent.win.close()">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg>
</button>
</div>
</div>
<!-- Main App -->
<div id="app" style="display:none">
<nav>
<div id="app">
<!-- 左侧图标栏:主页=对话,其余为二级页面 -->
<aside class="rail" id="rail">
<div class="rail-logo"><img src="icon.svg" alt="HomeAgent"></div>
<button class="rail-btn active" id="rail-chat" onclick="switchView('chat')" title="对话">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg>
</button>
<button class="rail-btn" id="rail-overview" onclick="switchView('overview')" title="概览">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
</button>
<button class="rail-btn" id="rail-plugins" onclick="switchView('plugins')" title="插件">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
</button>
<button class="rail-btn" id="rail-adapters" onclick="switchView('adapters')" title="适配器">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg>
</button>
<button class="rail-btn" id="rail-kernel" onclick="switchView('kernel')" title="内核">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><path d="M9 1v4M15 1v4M9 19v4M15 19v4M1 9h4M1 15h4M19 9h4M19 15h4"/></svg>
</button>
<div class="rail-spacer"></div>
<button class="rail-btn" id="rail-settings" onclick="switchView('settings')" title="设置">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
</button>
<button class="rail-btn" onclick="toggleTheme()" title="切换亮色/暗色模式" id="theme-btn"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg></button>
<span class="conn-dot dot-gray" id="rail-conn-dot" title="连接状态"></span>
</aside>
<div class="main">
<div class="topbar">
<h1>HomeAgent</h1>
<a class="active" onclick="switchTab('overview')" data-i18n="navOverview">概览</a>
<a onclick="switchTab('chat')" data-i18n="navChat">对话</a>
<a onclick="switchTab('plugins')" data-i18n="navPlugins">插件</a>
<a onclick="switchTab('settings')" data-i18n="navSettings">设置</a>
<a onclick="switchTab('adapters')" data-i18n="navAdapters">适配器</a>
<a onclick="switchTab('kernel')" data-i18n="navKernel">内核</a>
<div style="margin-left:auto;display:flex;align-items:center;gap:8px">
<span id="conn-status" class="conn-indicator" onclick="openConnManager()" title="点击管理连接 / Click to manage connections">
<span class="conn-indicator" id="conn-indicator" onclick="goSettingsConn()" title="点击管理后端连接">
<span class="status-dot dot-gray" id="conn-dot"></span>
<span id="conn-name-display">未连接</span>
<span style="font-size:10px;margin-left:4px;opacity:0.6"></span>
<span style="font-size:10px;margin-left:4px;opacity:.6"></span>
</span>
<span id="lang-btn" class="theme-btn" onclick="toggleLang()" style="font-size:13px;min-width:28px;cursor:pointer;padding:4px 8px;border:1px solid var(--border-color);border-radius:6px;text-align:center">EN</span>
<button class="theme-btn" onclick="toggleTheme()" id="theme-btn" title="切换亮色/暗色模式">🌙</button>
<div class="spacer"></div>
<button class="lang-btn" onclick="toggleLang()" id="lang-btn">EN</button>
</div>
</nav>
<div class="container" id="app-content">
<div id="tab-overview" class="tab-content active"></div>
<div id="tab-chat" class="tab-content"></div>
<div id="tab-plugins" class="tab-content"></div>
<div id="tab-settings" class="tab-content"></div>
<div id="tab-adapters" class="tab-content"></div>
<div id="tab-kernel" class="tab-content"></div>
<div class="container">
<div id="view-chat" class="view active"></div>
<div id="view-overview" class="view"></div>
<div id="view-plugins" class="view"></div>
<div id="view-adapters" class="view"></div>
<div id="view-kernel" class="view"></div>
<div id="view-settings" class="view"></div>
</div>
</div>
</div>

View File

@ -1,9 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
<circle cx="50" cy="50" r="48" fill="#3B82F6"/>
<circle cx="35" cy="40" r="6" fill="white"/>
<circle cx="65" cy="40" r="6" fill="white"/>
<circle cx="35" cy="40" r="3" fill="#1E3A5F"/>
<circle cx="65" cy="40" r="3" fill="#1E3A5F"/>
<path d="M35 65 Q50 80 65 65" stroke="white" stroke-width="3" fill="none" stroke-linecap="round"/>
<ellipse cx="50" cy="58" rx="8" ry="4" fill="#F59E0B"/>
</svg>

Before

Width:  |  Height:  |  Size: 496 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

View File

@ -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) }

5
go.mod
View File

@ -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

4
go.sum
View File

@ -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=

View File

@ -100,6 +100,9 @@ type Agent struct {
// 当前轮次的非文本媒体数据(图片/音频),供 describe_image 等工具访问
pendingMedia map[string]interface{}
// 当前输入是否为工具提醒/中断(以 system 角色注入,避免被当成用户消息)
interruptInput bool
// 非文本输入处理配置
inputCfg types.InputProcessingConfig

View File

@ -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

View File

@ -4,6 +4,7 @@ import (
"fmt"
"log"
"runtime/debug"
"strings"
"time"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
@ -397,7 +398,10 @@ func docToTriples(doc *document.Doc, embedder nlp.Vectorizer) []memory.Triple {
return nil
}
// 文档元数据
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",
@ -406,6 +410,7 @@ func docToTriples(doc *document.Doc, embedder nlp.Vectorizer) []memory.Triple {
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,

View File

@ -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)
}
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)
}

View File

@ -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,7 +209,7 @@ 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,
@ -193,6 +217,7 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
"args": tc.Arguments,
"status": "interrupted",
"reason": "user interrupt before execution",
"channel": a.currentOutputChannel,
})
break
}
@ -214,6 +239,7 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
"args": tc.Arguments,
"result": result,
"status": "denied",
"channel": a.currentOutputChannel,
})
continue
}
@ -253,11 +279,12 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
"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
}

View File

@ -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
}

View File

@ -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/filemeta 是 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()

View File

@ -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"})

View File

@ -16,6 +16,7 @@ const (
EventReasoning EventType = "reasoning"
EventStage EventType = "stage"
EventSystem EventType = "system"
EventTerminalOutput EventType = "terminal_output"
EventAll EventType = "*"
)

View File

@ -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_thinkinghomeagent 置 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

View File

@ -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"

View File

@ -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

View File

@ -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() {

View File

@ -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

View File

@ -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
}

View File

@ -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)")
}
}

View File

@ -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)

View File

@ -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
@ -61,6 +70,13 @@ type TerminalSession struct {
// 通知节流字段
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 {
@ -195,7 +223,9 @@ 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 参数调整。",
Description: "创建一个新的交互式终端会话。返回终端 ID后续通过此 ID 进行读写操作。适用于运行交互式程序如 vim、ssh、top、nano 等。" +
"通知模式通过 notify 参数选择(默认 exitexit=仅命令执行结束后提醒一次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/1mbuffer=字节数(缓冲阈值提醒);可逗号组合",
},
"timeout": map[string]interface{}{
"type": "string",
"description": "终端自动关闭时间,例如 5m, 10m, 30m, 1h默认 5m",
@ -250,7 +284,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.RegisterTool("terminal_read", sdk.ToolDef{
Name: "terminal_read",
Description: "读取指定终端的当前屏幕内容。返回自上次读取以来的新输出。如需持续监控请多次调用。",
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()
@ -410,9 +500,28 @@ func (p *Plugin) handleCreate(s *sdk.PluginSDK, args map[string]interface{}) (in
"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 == "" {
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) {
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
}
preview = sanitizePreview(preview)
t.unreadBytes = 0
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
}
}
}
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

View File

@ -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
}

View File

@ -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 对应 STARTUPINFOEXWSTARTUPINFOW 之后追加 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 ConPTYPseudo Console)的终端后端。
//
// ConPTY 通过 CreatePseudoConsole 创建伪控制台,子进程以
// PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE 挂到伪控制台。宿主侧使用两根
// 管道与伪控制台通信:我们写 inW输入、读 outR输出
// windowsPty 基于 internal/ptywinConPTY)的终端后端。
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 <command>)。
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,25 +65,12 @@ 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, "; "))

View File

@ -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
}

View File

@ -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
})

View File

@ -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
}
parts := shellUnquote(execCmd)
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:]...)
cmd = exec.CommandContext(ctx, parts[0], parts[1:]...)
}
if workdir != "" {
cmd.Dir = workdir
}

View File

@ -6,6 +6,7 @@ import (
"log"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
"sync"
@ -30,6 +31,8 @@ type Plugin struct {
baseDir string // L0 写前留档根目录(<data>/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 == "" {

File diff suppressed because it is too large Load Diff

View File

@ -26,7 +26,35 @@ var dashboardFS embed.FS
var dashboardHTML string
const loginHTML = `<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>HomeAgent Login</title><style>body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#0f172a;color:#e2e8f0;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}.card{background:#1e293b;border:1px solid #334155;border-radius:12px;padding:28px;width:360px}h1{margin:0 0 16px;font-size:20px;color:#38bdf8}label{display:block;font-size:12px;color:#94a3b8;margin:10px 0 4px}input{width:100%;padding:10px 12px;border-radius:8px;border:1px solid #334155;background:#0f172a;color:#e2e8f0}button{width:100%;margin-top:16px;padding:10px 12px;border:none;border-radius:8px;background:#2563eb;color:#fff;font-weight:600;cursor:pointer}.err{margin-top:12px;color:#fca5a5;font-size:13px}</style></head><body><div class="card"><h1>HomeAgent</h1><form id="login-form"><label>用户名</label><input id="username" autocomplete="username"><label>密码</label><input id="password" type="password" autocomplete="current-password"><button type="submit">登录</button><div id="err" class="err"></div></form></div><script>document.getElementById('login-form').addEventListener('submit',async(e)=>{e.preventDefault();const username=document.getElementById('username').value;const password=document.getElementById('password').value;const err=document.getElementById('err');err.textContent='';const r=await fetch('/api/v1/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username,password})});if(r.ok){location.href='/';return}let data={};try{data=await r.json()}catch(_){}err.textContent=data.error||'登录失败'})</script></body></html>`
const loginHTML = `<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>HomeAgent 登录</title><style>
:root{--sakura-300:#ffb3c8;--sakura-400:#ff7fac;--sakura-500:#f33b7c;--frost-300:#88c0d0;--text-primary:#e8e6ee;--text-secondary:#a0a3b5;--text-muted:#6e7284;--bg-primary:#0d0d16;--bg-card:rgba(24,24,38,0.72);--bg-input:rgba(13,13,22,0.6);--border-color:rgba(255,255,255,0.09);--glass-border:rgba(255,255,255,0.12);--glass-blur:20px;--radius-lg:18px;--radius-md:12px;--radius-pill:999px;--shadow-glow:0 0 18px rgba(243,59,124,0.35);--ease-out:cubic-bezier(.22,.61,.36,1)}
[data-theme="light"]{--text-primary:#23252e;--text-secondary:#5b5f73;--text-muted:#9aa0b5;--bg-primary:#f6f3f8;--bg-card:rgba(255,255,255,0.72);--bg-input:rgba(255,255,255,0.8);--border-color:rgba(35,37,46,0.1);--glass-border:rgba(255,255,255,0.75);--shadow-glow:0 0 18px rgba(243,59,124,0.28)}
*{box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Microsoft YaHei',sans-serif;background:radial-gradient(1200px 800px at 15% 0%,rgba(243,59,124,.22),transparent 55%),radial-gradient(1000px 700px at 90% 10%,rgba(136,192,208,.18),transparent 55%),radial-gradient(900px 600px at 50% 110%,rgba(163,184,255,.14),transparent 60%),var(--bg-primary);background-attachment:fixed;color:var(--text-primary);display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;padding:20px;transition:background .3s,color .2s;overflow:hidden}
body::before{content:'';position:fixed;inset:0;pointer-events:none;background-image:radial-gradient(rgba(255,255,255,.05) 1px,transparent 1px);background-size:28px 28px}
.login-wrap{width:100%;max-width:400px;position:relative;z-index:1}
.login-card{background:var(--bg-card);backdrop-filter:blur(var(--glass-blur)) saturate(1.4);-webkit-backdrop-filter:blur(var(--glass-blur)) saturate(1.4);border:1px solid var(--glass-border);border-radius:var(--radius-lg);padding:36px 32px 28px;box-shadow:0 20px 60px rgba(0,0,0,.45)}
.logo{width:84px;height:84px;margin:0 auto 16px;border-radius:50%;overflow:hidden;border:2px solid rgba(255,255,255,.25);box-shadow:0 8px 24px rgba(243,59,124,.35);background:#F8FAFC;display:flex;align-items:center;justify-content:center}
.logo img{width:100%;height:100%;object-fit:cover;display:block}
h1{margin:0 0 6px;font-size:22px;font-weight:700;text-align:center;letter-spacing:-.01em;background:linear-gradient(120deg,var(--sakura-400),var(--frost-300));-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent}
.sub{margin:0 0 24px;font-size:12px;color:var(--text-muted);text-align:center}
label{display:block;font-size:11px;color:var(--text-secondary);margin:14px 0 6px;font-weight:500;letter-spacing:.03em}
input{width:100%;padding:11px 14px;border-radius:var(--radius-md);border:1px solid var(--border-color);background:var(--bg-input);color:var(--text-primary);font-size:14px;outline:none;transition:border .15s,box-shadow .15s}
input:focus{border-color:var(--sakura-400);box-shadow:var(--shadow-glow)}
input::placeholder{color:var(--text-muted)}
button{width:100%;margin-top:22px;padding:12px 14px;border:none;border-radius:var(--radius-md);background:linear-gradient(120deg,var(--sakura-500),var(--sakura-400));color:#fff;font-size:14px;font-weight:600;cursor:pointer;letter-spacing:.08em;transition:transform .15s var(--ease-out),box-shadow .2s,filter .2s}
button:hover{transform:translateY(-1px);filter:brightness(1.08);box-shadow:0 8px 24px rgba(243,59,124,.4)}
button:active{transform:translateY(0) scale(.98)}
button:disabled{opacity:.6;cursor:not-allowed;transform:none}
.err{margin-top:14px;color:#ff6b6b;font-size:13px;text-align:center;min-height:18px;transition:opacity .2s}
.foot{margin-top:20px;font-size:11px;color:var(--text-muted);text-align:center}
.foot svg{width:12px;height:12px;vertical-align:-2px}
@media(max-width:480px){.login-card{padding:28px 22px 22px}}
</style></head><body><div class="login-wrap"><div class="login-card"><div class="logo"><img src="/logo.svg" alt="HomeAgent"></div><h1>HomeAgent</h1><p class="sub">智能家居助手控制台</p><form id="login-form"><label>用户名</label><input id="username" autocomplete="username" placeholder="请输入用户名" required><label>密码</label><input id="password" type="password" autocomplete="current-password" placeholder="请输入密码" required><button type="submit" id="submit-btn">登 录</button><div id="err" class="err"></div></form></div><div class="foot">HomeAgent &middot; NapCat Theme</div></div><script>
(function(){var m=window.matchMedia('(prefers-color-scheme: light)');function apply(){document.documentElement.setAttribute('data-theme',m.matches?'light':'dark')}apply();m.addEventListener('change',apply)})();
document.getElementById('login-form').addEventListener('submit',async(e)=>{e.preventDefault();const username=document.getElementById('username').value.trim();const password=document.getElementById('password').value;const err=document.getElementById('err');const btn=document.getElementById('submit-btn');err.textContent='';if(!username||!password){err.textContent='请输入用户名和密码';return}btn.disabled=true;btn.textContent='登录中...';try{const r=await fetch('/api/v1/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username,password})});if(r.ok){location.href='/';return}let data={};try{data=await r.json()}catch(_){}err.textContent=data.error||'登录失败'}catch(_){err.textContent='网络错误,请重试'}finally{btn.disabled=false;btn.textContent='登 录'}});
document.getElementById('password').addEventListener('keydown',function(e){if(e.key==='Enter')document.getElementById('login-form').dispatchEvent(new Event('submit'))});
</script></body></html>`
func init() {
data, err := dashboardFS.ReadFile("dashboard.html")
@ -56,6 +84,7 @@ type Handler struct {
chatMu sync.Mutex
chatHistory []ChatMsg
pendingIdx int // chatHistory 中正在进行的 assistant 消息索引,-1 表示无
cmdMu sync.Mutex
cmdHistory []CmdExec
termMu sync.Mutex
@ -66,10 +95,20 @@ type ChatMsg struct {
Role string `json:"role"`
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content,omitempty"`
ToolCalls []ChatToolCall `json:"tool_calls,omitempty"`
Source string `json:"source,omitempty"`
Time string `json:"time"`
}
type ChatToolCall struct {
Tool string `json:"tool"`
Name string `json:"name,omitempty"`
Args interface{} `json:"args,omitempty"`
Result interface{} `json:"result,omitempty"`
Status string `json:"status,omitempty"`
Plugin string `json:"plugin,omitempty"`
}
type CmdExec struct {
Command string `json:"command"`
Stdout string `json:"stdout"`
@ -132,15 +171,53 @@ func NewHandler(s *sdk.PluginSDK) *Handler {
llm: llm,
sessions: make(map[string]time.Time),
termStates: make(map[string]*termState),
pendingIdx: -1,
}
h.loadChatHistory()
if s != nil {
go h.trackToolEvents()
h.subscribeChatEvents()
h.subscribeTerminalStream()
}
return h
}
// subscribeTerminalStream 常驻订阅终端实时画面推流terminal_output 事件),
// 维护 termStates 的 Running 状态与全量输出缓冲,供 /api/v1/terminals 与前端轮询使用。
func (h *Handler) subscribeTerminalStream() {
if h.sdk == nil {
return
}
h.sdk.Subscribe(sdk.EventTerminalOutput, func(ev *sdk.Event) {
id, _ := ev.Payload["terminal_id"].(string)
if id == "" {
return
}
output, _ := ev.Payload["output"].(string)
running, _ := ev.Payload["running"].(bool)
h.termMu.Lock()
ts, ok := h.termStates[id]
if !ok {
ts = &termState{ID: id, created: time.Now()}
h.termStates[id] = ts
}
ts.Running = running
if output != "" {
const maxTermOutput = 64 * 1024
if len(ts.Output)+len(output) > maxTermOutput {
excess := len(ts.Output) + len(output) - maxTermOutput
if len(ts.Output) > excess {
ts.Output = ts.Output[excess:]
} else {
ts.Output = ""
}
}
ts.Output += output
}
h.termMu.Unlock()
})
}
func (h *Handler) loadChatHistory() {
if h.settings == nil {
return
@ -183,6 +260,9 @@ func (h *Handler) subscribeChatEvents() {
if content == "" {
return
}
h.chatMu.Lock()
h.pendingIdx = -1
h.chatMu.Unlock()
h.addChatMsg(ChatMsg{
Role: "user",
Content: content,
@ -190,9 +270,91 @@ func (h *Handler) subscribeChatEvents() {
Time: time.Unix(ev.Timestamp, 0).Format(time.RFC3339),
})
})
h.sdk.Subscribe(sdk.EventToolCall, func(ev *sdk.Event) {
tool, _ := ev.Payload["tool"].(string)
if tool == "" {
return
}
channel, _ := ev.Payload["channel"].(string)
if channel == "_consolidation_" {
return
}
plugin, _ := ev.Payload["plugin"].(string)
status, _ := ev.Payload["status"].(string)
if status == "" {
status = "ok"
}
tc := ChatToolCall{
Tool: tool,
Name: tool,
Args: ev.Payload["args"],
Result: ev.Payload["result"],
Status: status,
Plugin: plugin,
}
h.chatMu.Lock()
msg := h.pendingAssistantLocked()
if msg == nil {
h.chatHistory = append(h.chatHistory, ChatMsg{Role: "assistant", Time: time.Now().Format(time.RFC3339)})
h.pendingIdx = len(h.chatHistory) - 1
msg = &h.chatHistory[h.pendingIdx]
}
msg.ToolCalls = append(msg.ToolCalls, tc)
h.persistChatLocked()
h.chatMu.Unlock()
})
h.sdk.Subscribe(sdk.EventReasoning, func(ev *sdk.Event) {
content, _ := ev.Payload["content"].(string)
if content == "" {
return
}
channel, _ := ev.Payload["channel"].(string)
if channel == "_consolidation_" {
return
}
h.chatMu.Lock()
msg := h.pendingAssistantLocked()
if msg == nil {
h.chatHistory = append(h.chatHistory, ChatMsg{Role: "assistant", Time: time.Now().Format(time.RFC3339)})
h.pendingIdx = len(h.chatHistory) - 1
msg = &h.chatHistory[h.pendingIdx]
}
msg.ReasoningContent += content
h.persistChatLocked()
h.chatMu.Unlock()
})
h.sdk.Subscribe(sdk.EventAgentOutput, func(ev *sdk.Event) {
content, _ := ev.Payload["content"].(string)
channel, _ := ev.Payload["channel"].(string)
kind, _ := ev.Payload["kind"].(string)
h.chatMu.Lock()
// 输出通道主动输出(output_send__{通道})作为独立气泡,不并入最终回复
if kind == "channel_output" {
h.pendingIdx = -1
h.chatMu.Unlock()
if content == "" {
return
}
h.addChatMsg(ChatMsg{
Role: "assistant",
Content: content,
Source: channel,
Time: time.Unix(ev.Timestamp, 0).Format(time.RFC3339),
})
return
}
if msg := h.pendingAssistantLocked(); msg != nil && content != "" {
msg.Content = content
if channel != "" {
msg.Source = channel
}
h.pendingIdx = -1
h.persistChatLocked()
h.chatMu.Unlock()
return
}
h.pendingIdx = -1
h.chatMu.Unlock()
if content == "" {
return
}
@ -205,6 +367,27 @@ func (h *Handler) subscribeChatEvents() {
})
}
// pendingAssistantLocked 返回 chatHistory 中当前进行中的 assistant 消息(已持有 chatMu
// 仅当最后一条是 assistant 且尚未产出最终内容时视为进行中,避免跨轮次误合并。
func (h *Handler) pendingAssistantLocked() *ChatMsg {
if h.pendingIdx < 0 || h.pendingIdx >= len(h.chatHistory) {
return nil
}
msg := &h.chatHistory[h.pendingIdx]
if msg.Role != "assistant" || msg.Content != "" {
return nil
}
return msg
}
func (h *Handler) persistChatLocked() {
if h.settings == nil {
return
}
b, _ := json.Marshal(h.chatHistory)
_ = h.settings.Set("chathistory", string(b))
}
func (h *Handler) handleToolEvent(ev *sdk.Event) {
payload := ev.Payload
tool, _ := payload["tool"].(string)
@ -228,7 +411,21 @@ func (h *Handler) handleToolEvent(ev *sdk.Event) {
case "terminal_create":
id := getStr(args, "id")
if id == "" {
// agent 调用时不知道生成的 id从工具结果中回填
if res, ok := payload["result"].(map[string]interface{}); ok {
id = getStr(res, "id")
}
}
if id == "" {
break
}
cmd := getStr(args, "command")
if cmd == "" {
if res, ok := payload["result"].(map[string]interface{}); ok {
cmd = getStr(res, "command")
}
}
now := time.Now()
term := &termState{
ID: id,
@ -238,7 +435,13 @@ func (h *Handler) handleToolEvent(ev *sdk.Event) {
created: now,
}
h.termMu.Lock()
if old, ok := h.termStates[id]; ok {
old.Command = cmd
old.Running = true
old.created = now
} else {
h.termStates[id] = term
}
if len(h.termStates) > maxTerminals {
for k := range h.termStates {
delete(h.termStates, k)
@ -608,7 +811,7 @@ func (h *Handler) handleAgentAction(w http.ResponseWriter, r *http.Request, agen
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": fmt.Sprintf("%s_requested", action), "agent": string(agentID)})
writeJSON(w, http.StatusNotImplemented, map[string]string{"error": fmt.Sprintf("agent %s action not implemented by supervisor", action)})
}
func (h *Handler) handleMemory(w http.ResponseWriter, r *http.Request) {
@ -905,18 +1108,16 @@ func (h *Handler) addChatMsg(msg ChatMsg) {
h.chatMu.Lock()
h.chatHistory = append(h.chatHistory, msg)
if len(h.chatHistory) > maxChatHistory {
h.chatHistory = h.chatHistory[len(h.chatHistory)-maxChatHistory:]
drop := len(h.chatHistory) - maxChatHistory
h.chatHistory = h.chatHistory[drop:]
if h.pendingIdx >= 0 {
h.pendingIdx -= drop
if h.pendingIdx < 0 {
h.pendingIdx = -1
}
// 只持 latest 50 条做持久化(写放大防护),全量仍保留在内存
if h.settings != nil {
persistLen := len(h.chatHistory)
if persistLen > 50 {
persistLen = 50
}
toPersist := h.chatHistory[len(h.chatHistory)-persistLen:]
b, _ := json.Marshal(toPersist)
_ = h.settings.Set("chathistory", string(b))
}
h.persistChatLocked()
h.chatMu.Unlock()
}
@ -968,7 +1169,6 @@ func (h *Handler) handleChat(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "agent unavailable"})
return
}
// 带超时的上下文,防止 InjectTextSync 长时间阻塞 HTTP 请求
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
defer cancel()
@ -1052,8 +1252,9 @@ func (h *Handler) handleChatEvents(w http.ResponseWriter, r *http.Request) {
log.Printf("[SSE] client reported Last-Event-ID: %s", lastEventID)
}
subTypes := []string{"agent_output", "reasoning", "agent_error", "tool_call", "stage", "agent_llm_chain"}
subTypes := []string{"agent_output", "reasoning", "agent_error", "tool_call", "stage", "agent_llm_chain", "terminal_output"}
var unsubs []func()
var seq int64
for _, t := range subTypes {
t2 := t
unsub := h.sdk.Subscribe(sdk.EventType(t2), func(evt *sdk.Event) {
@ -1062,8 +1263,9 @@ func (h *Handler) handleChatEvents(w http.ResponseWriter, r *http.Request) {
log.Printf("[SSE] received tool_call event: tool=%s", toolName)
}
data, _ := json.Marshal(evt)
seq++
select {
case writeCh <- fmt.Sprintf("event: %s\ndata: %s\n", evt.Type, string(data)):
case writeCh <- fmt.Sprintf("id: %d-%d\nevent: %s\ndata: %s\n", evt.Timestamp, seq, evt.Type, string(data)):
if evt.Type == sdk.EventToolCall {
toolName, _ := evt.Payload["tool"].(string)
log.Printf("[SSE] wrote tool_call to writeCh: tool=%s", toolName)

View File

@ -632,13 +632,13 @@ func TestSettingsAPIFlow(t *testing.T) {
if !strings.Contains(html, "settings-layout") {
t.Fatal("HTML should contain settings-layout class")
}
if !strings.Contains(html, "settings-sidebar") {
t.Fatal("HTML should contain settings-sidebar class")
if !strings.Contains(html, "settings-tabs") {
t.Fatal("HTML should contain settings-tabs class")
}
if !strings.Contains(html, "saveSetting") {
t.Fatal("HTML should contain saveSetting JS function")
}
if !strings.Contains(html, "api('/settings'") {
if !strings.Contains(html, `api("/settings"`) {
t.Fatal("HTML should call api('/settings')")
}
})

View File

@ -83,6 +83,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
Payload: map[string]interface{}{
"content": payload,
"channel": "webui",
"kind": "channel_output",
},
})
}

389
internal/ptywin/conpty.go Normal file
View File

@ -0,0 +1,389 @@
//go:build windows
// +build windows
package ptywin
import (
"context"
"errors"
"fmt"
"unicode/utf16"
"unsafe"
"golang.org/x/sys/windows"
)
var (
modKernel32 = windows.NewLazySystemDLL("kernel32.dll")
fCreatePseudoConsole = modKernel32.NewProc("CreatePseudoConsole")
fResizePseudoConsole = modKernel32.NewProc("ResizePseudoConsole")
fClosePseudoConsole = modKernel32.NewProc("ClosePseudoConsole")
fInitializeProcThreadAttributeList = modKernel32.NewProc("InitializeProcThreadAttributeList")
fUpdateProcThreadAttribute = modKernel32.NewProc("UpdateProcThreadAttribute")
ErrConPtyUnsupported = errors.New("ConPty is not available on this version of Windows")
)
func IsConPtyAvailable() bool {
return fCreatePseudoConsole.Find() == nil &&
fResizePseudoConsole.Find() == nil &&
fClosePseudoConsole.Find() == nil &&
fInitializeProcThreadAttributeList.Find() == nil &&
fUpdateProcThreadAttribute.Find() == nil
}
const (
_STILL_ACTIVE uint32 = 259
_S_OK uintptr = 0
_PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE uintptr = 0x20016
defaultConsoleWidth = 80 // in characters
defaultConsoleHeight = 40 // in characters
)
type _COORD struct {
X, Y int16
}
func (c *_COORD) Pack() uintptr {
return uintptr((int32(c.Y) << 16) | int32(c.X))
}
type _HPCON windows.Handle
type handleIO struct {
handle windows.Handle
}
func (h *handleIO) Read(p []byte) (int, error) {
var numRead uint32 = 0
err := windows.ReadFile(h.handle, p, &numRead, nil)
return int(numRead), err
}
func (h *handleIO) Write(p []byte) (int, error) {
var numWritten uint32 = 0
err := windows.WriteFile(h.handle, p, &numWritten, nil)
return int(numWritten), err
}
func (h *handleIO) Close() error {
return windows.CloseHandle(h.handle)
}
type ConPty struct {
hpc _HPCON
pi *windows.ProcessInformation
ptyIn, ptyOut, cmdIn, cmdOut *handleIO
}
func win32ClosePseudoConsole(hPc _HPCON) {
if fClosePseudoConsole.Find() != nil {
return
}
// this kills the attached process. there is no return value.
fClosePseudoConsole.Call(uintptr(hPc))
}
func win32ResizePseudoConsole(hPc _HPCON, coord *_COORD) error {
if fResizePseudoConsole.Find() != nil {
return fmt.Errorf("ResizePseudoConsole not found")
}
ret, _, _ := fResizePseudoConsole.Call(uintptr(hPc), coord.Pack())
if ret != _S_OK {
return fmt.Errorf("ResizePseudoConsole failed with status 0x%x", ret)
}
return nil
}
func win32CreatePseudoConsole(c *_COORD, hIn, hOut windows.Handle) (_HPCON, error) {
if fCreatePseudoConsole.Find() != nil {
return 0, fmt.Errorf("CreatePseudoConsole not found")
}
var hPc _HPCON
ret, _, _ := fCreatePseudoConsole.Call(
c.Pack(),
uintptr(hIn),
uintptr(hOut),
0,
uintptr(unsafe.Pointer(&hPc)))
if ret != _S_OK {
return 0, fmt.Errorf("CreatePseudoConsole() failed with status 0x%x", ret)
}
return hPc, nil
}
type _StartupInfoEx struct {
startupInfo windows.StartupInfo
attributeList []byte
}
func getStartupInfoExForPTY(hpc _HPCON) (*_StartupInfoEx, error) {
if fInitializeProcThreadAttributeList.Find() != nil {
return nil, fmt.Errorf("InitializeProcThreadAttributeList not found")
}
if fUpdateProcThreadAttribute.Find() != nil {
return nil, fmt.Errorf("UpdateProcThreadAttribute not found")
}
var siEx _StartupInfoEx
siEx.startupInfo.Cb = uint32(unsafe.Sizeof(windows.StartupInfo{}) + unsafe.Sizeof(&siEx.attributeList[0]))
siEx.startupInfo.Flags |= windows.STARTF_USESTDHANDLES
var size uintptr
// first call is to get required size. this should return false.
ret, _, _ := fInitializeProcThreadAttributeList.Call(0, 1, 0, uintptr(unsafe.Pointer(&size)))
siEx.attributeList = make([]byte, size, size)
ret, _, err := fInitializeProcThreadAttributeList.Call(
uintptr(unsafe.Pointer(&siEx.attributeList[0])),
1,
0,
uintptr(unsafe.Pointer(&size)))
if ret != 1 {
return nil, fmt.Errorf("InitializeProcThreadAttributeList: %v", err)
}
ret, _, err = fUpdateProcThreadAttribute.Call(
uintptr(unsafe.Pointer(&siEx.attributeList[0])),
0,
_PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE,
uintptr(hpc),
unsafe.Sizeof(hpc),
0,
0)
if ret != 1 {
return nil, fmt.Errorf("InitializeProcThreadAttributeList: %v", err)
}
return &siEx, nil
}
func createConsoleProcessAttachedToPTY(hpc _HPCON, commandLine, workDir string, env []string) (*windows.ProcessInformation, error) {
cmdLine, err := windows.UTF16PtrFromString(commandLine)
if err != nil {
return nil, err
}
var currentDirectory *uint16
if workDir != "" {
currentDirectory, err = windows.UTF16PtrFromString(workDir)
if err != nil {
return nil, err
}
}
var envBlock *uint16
flags := uint32(windows.EXTENDED_STARTUPINFO_PRESENT)
if env != nil {
flags |= uint32(windows.CREATE_UNICODE_ENVIRONMENT)
envBlock = createEnvBlock(env)
}
siEx, err := getStartupInfoExForPTY(hpc)
if err != nil {
return nil, err
}
var pi windows.ProcessInformation
err = windows.CreateProcess(
nil, // use this if no args
cmdLine,
nil,
nil,
false, // inheritHandle
flags,
envBlock,
currentDirectory,
&siEx.startupInfo,
&pi)
if err != nil {
return nil, err
}
return &pi, nil
}
// createEnvBlock refers to syscall.createEnvBlock in go/src/syscall/exec_windows.go
// Sourced From: https://github.com/creack/pty/pull/155
func createEnvBlock(envv []string) *uint16 {
if len(envv) == 0 {
return &utf16.Encode([]rune("\x00\x00"))[0]
}
length := 0
for _, s := range envv {
length += len(s) + 1
}
length += 1
b := make([]byte, length)
i := 0
for _, s := range envv {
l := len(s)
copy(b[i:i+l], []byte(s))
copy(b[i+l:i+l+1], []byte{0})
i = i + l + 1
}
copy(b[i:i+1], []byte{0})
return &utf16.Encode([]rune(string(b)))[0]
}
// This will only return the first error.
func closeHandles(handles ...windows.Handle) error {
var err error
for _, h := range handles {
if h != windows.InvalidHandle {
if err == nil {
err = windows.CloseHandle(h)
} else {
windows.CloseHandle(h)
}
}
}
return err
}
// Close all open handles and terminate the process.
func (cpty *ConPty) Close() error {
// there is no return code
win32ClosePseudoConsole(cpty.hpc)
return closeHandles(
cpty.pi.Process,
cpty.pi.Thread,
cpty.ptyIn.handle,
cpty.ptyOut.handle,
cpty.cmdIn.handle,
cpty.cmdOut.handle)
}
// Wait for the process to exit and return the exit code. If context is canceled,
// Wait() will return STILL_ACTIVE and an error indicating the context was canceled.
func (cpty *ConPty) Wait(ctx context.Context) (uint32, error) {
var exitCode uint32 = _STILL_ACTIVE
for {
if err := ctx.Err(); err != nil {
return _STILL_ACTIVE, fmt.Errorf("wait canceled: %v", err)
}
ret, _ := windows.WaitForSingleObject(cpty.pi.Process, 1000)
if ret != uint32(windows.WAIT_TIMEOUT) {
err := windows.GetExitCodeProcess(cpty.pi.Process, &exitCode)
return exitCode, err
}
}
}
func (cpty *ConPty) Resize(width, height int) error {
coords := _COORD{
int16(width),
int16(height),
}
return win32ResizePseudoConsole(cpty.hpc, &coords)
}
func (cpty *ConPty) Read(p []byte) (int, error) {
return cpty.cmdOut.Read(p)
}
func (cpty *ConPty) Write(p []byte) (int, error) {
return cpty.cmdIn.Write(p)
}
func (cpty *ConPty) Pid() int {
return int(cpty.pi.ProcessId)
}
// Running 报告子进程是否仍在运行GetExitCodeProcess == STILL_ACTIVE
func (cpty *ConPty) Running() bool {
if cpty.pi == nil || cpty.pi.Process == 0 {
return false
}
var code uint32
err := windows.GetExitCodeProcess(cpty.pi.Process, &code)
if err != nil {
return false
}
return code == _STILL_ACTIVE
}
// Kill 强制终止子进程并关闭进程/线程句柄。
func (cpty *ConPty) Kill() error {
if cpty.pi == nil || cpty.pi.Process == 0 {
return nil
}
if err := windows.TerminateProcess(cpty.pi.Process, 1); err != nil {
return err
}
windows.CloseHandle(cpty.pi.Process)
windows.CloseHandle(cpty.pi.Thread)
cpty.pi.Process = 0
cpty.pi.Thread = 0
return nil
}
type conPtyArgs struct {
coords _COORD
workDir string
env []string
}
type ConPtyOption func(args *conPtyArgs)
func ConPtyDimensions(width, height int) ConPtyOption {
return func(args *conPtyArgs) {
args.coords.X = int16(width)
args.coords.Y = int16(height)
}
}
func ConPtyWorkDir(workDir string) ConPtyOption {
return func(args *conPtyArgs) {
args.workDir = workDir
}
}
func ConPtyEnv(env []string) ConPtyOption {
return func(args *conPtyArgs) {
args.env = env
}
}
// Start a new process specified in `commandLine` and attach a pseudo console using the Windows
// ConPty API. If ConPty is not available, ErrConPtyUnsupported will be returned.
//
// On successful return, an instance of ConPty is returned. You must call Close() on this to release
// any resources associated with the process. To get the exit code of the process, you can call Wait().
func Start(commandLine string, options ...ConPtyOption) (*ConPty, error) {
if !IsConPtyAvailable() {
return nil, ErrConPtyUnsupported
}
args := &conPtyArgs{
coords: _COORD{defaultConsoleWidth, defaultConsoleHeight},
}
for _, opt := range options {
opt(args)
}
var cmdIn, cmdOut, ptyIn, ptyOut windows.Handle
if err := windows.CreatePipe(&ptyIn, &cmdIn, nil, 0); err != nil {
return nil, fmt.Errorf("CreatePipe: %v", err)
}
if err := windows.CreatePipe(&cmdOut, &ptyOut, nil, 0); err != nil {
closeHandles(ptyIn, cmdIn)
return nil, fmt.Errorf("CreatePipe: %v", err)
}
hPc, err := win32CreatePseudoConsole(&args.coords, ptyIn, ptyOut)
if err != nil {
closeHandles(ptyIn, ptyOut, cmdIn, cmdOut)
return nil, err
}
pi, err := createConsoleProcessAttachedToPTY(hPc, commandLine, args.workDir, args.env)
if err != nil {
closeHandles(ptyIn, ptyOut, cmdIn, cmdOut)
win32ClosePseudoConsole(hPc)
return nil, fmt.Errorf("Failed to create console process: %v", err)
}
cpty := &ConPty{
hpc: hPc,
pi: pi,
ptyIn: &handleIO{ptyIn},
ptyOut: &handleIO{ptyOut},
cmdIn: &handleIO{cmdIn},
cmdOut: &handleIO{cmdOut},
}
return cpty, nil
}

View File

@ -16,5 +16,6 @@ const (
EventReasoning = events.EventReasoning
EventStage = events.EventStage
EventSystem = events.EventSystem
EventTerminalOutput = events.EventTerminalOutput
EventAll = events.EventAll
)

View File

@ -117,7 +117,11 @@ func (d *Daemon) RegisterAgent(id types.AgentID) {
}
func (d *Daemon) healthLoop() {
ticker := time.NewTicker(d.cfg.Daemon.HeartbeatInterval)
interval := d.cfg.Daemon.HeartbeatInterval
if interval <= 0 {
interval = 30 * time.Second
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {

50
plan.md
View File

@ -35,7 +35,8 @@
- [x] **LLM 驱动自检防护**`collectToolDefsForLLM` 改为只收集**只读白名单**工具(`isSafeReadonlyTool`),写/删/改生产数据及外部副作用工具memory_commit/doc_commit/knowledge_create/cmd_run/files_write/terminal_*/output_send/spawn_child 等)一律不交给 LLM 自检,防止 LLM 乱调污染生产。
- [x] 单测healthcheck 自检后注入的"生产"实例内容不变(快照对比 + `_hc_` 无残留);读/写工具白名单过滤测试通过。
- [x] 存量清理:删除生产残留的 `gotest/``luatest/``_hc_knowledge_test_*/` 目录(保留真实知识库);备份留于 `/tmp/opencode/knowledge_garbage_backup_20260812_122322`
- [ ] 部署验证:编译新 `homed` 部署后,`knowledge/``memory/graph.db``memory/documents/` 不再出现 `_hc_*` 残留
- [x] 代码复核healthcheck 三自检memory/knowledge/doc全部经 `s.Selftest("hc")` 隔离虚拟实例写→查→删生产实例零接触plugin.go:339/466-473/476-549
- [ ] 部署验证:编译新 `homed` 部署后,`knowledge/``memory/graph.db``memory/documents/` 不再出现 `_hc_*` 残留。—— **验证脚本已就绪:`scripts/verify_deploy.sh [data_dir]`,部署后一键检查 0.1 残留 / 1 去重与 UNIQUE 迁移 / 2 archived 残留 / 5 嵌入规格**
---
@ -92,19 +93,19 @@
- [x] **Schema 迁移**`initSchema` 新增 `migrateRelationUnique`——检测旧 relations 表无复合唯一约束(旧 DD表自动重建为带 `UNIQUE(source_id, target_id, relation_type, session_id)` 的新表并 `INSERT OR IGNORE` 去重(官方 12 步迁移),无需人工干预。
- [x] **Commit 逻辑**:改为"查存在 → 不存在才 INSERT 并计数;已存在则仅刷新 confidence/updated_at",重复提交不新增、不重计。
- [x] **验证**`TestCommitDedupSameSession`(同会话重复 commit 不增行)、`TestCommitDedupDifferentSession`(跨会话允许重复)、`TestMigrateRelationUniqueDedupsOldTable`(旧表重建去重)全绿;`go build ./...` 通过。
- [ ] 生产部署后确认graph.db 36→ 去重2 组 `like/plugin` 重复消失),跑 1 周不再新增重复。
- [ ] 生产部署后确认graph.db 36→ 去重2 组 `like/plugin` 重复消失),跑 1 周不再新增重复。—— **`scripts/verify_deploy.sh` 已含 relations 重复率 + UNIQUE 索引检查**
> entities 已有 `UNIQUE(name)` 保护,仅 relations 缺失。
---
### Phase 2归档三元组模板清理治本✅ **计划中**
### Phase 2归档三元组模板清理治本✅ **已完成**
**目标**`docToTriples` 不再把 `context_archived`/`Topic` 摘要当成实体写入图库。
- [ ] 重构 `docToTriples`:仅当 `doc.Source``context_archived` 且非空时写 `文档→来源``文档→主题` 仅当 summary 长度合理(<80 且非模板化时写否则跳过
- [ ] 引入 `doc.Meta["is_archived_context"]` 标记上下文归档文档 `docToTriples` 识别并跳过
- [ ] 单测验证构造冷文档 `archiveColdDocs` 无模板垃圾产出
- [x] 重构 `docToTriples`:仅当 `doc.Source``context_archived` 且非空时写 `文档→来源``文档→主题` 仅当 summary 长度合理(<80 且非模板化时写否则跳过
- [x] 引入 `doc.Meta["is_archived_context"]` 标记上下文归档文档 `docToTriples` 识别并跳过`ContextToDoc` source=`context_archived` 时自动打标)
- [x] 单测验证构造冷文档 `archiveColdDocs` 无模板垃圾产出`TestDocToTriplesArchivedContext`/`TestDocToTriplesTemplateSummary`/`TestDocToTriplesLongSummary`/`TestIsTemplateSummary` 全绿既有 4 docToTriples 用例回归通过
---
@ -119,19 +120,20 @@
---
### Phase 4Pipeline Distiller 行为对齐文档(可选,低优)✅ **计划中**
### Phase 4Pipeline Distiller 行为对齐文档(可选,低优)✅ **已完成**
- [ ] 改为真正的增量蒸馏 tick 最近 `RetentionDays` 未蒸馏记录 `extractKeyTriples` `Commit`标记 `Distilled=true`
- [ ] 移除 `CreatedAt.Before(cutoff)` 7 天门槛改为" tick 处理前 N "保持文档所述 10min 频率
- [ ] 单测验证启动即蒸馏 + 不重复蒸馏
- [x] 改为真正的增量蒸馏 tick N `BatchSize`默认 50未蒸馏记录 `extractKeyTriples` `Commit`**移除 RetentionDays 时间门槛**——新记录下个 tick 即蒸馏文档所述 10min 频率不再等 7
- [x] 蒸馏失败重试`distillBatch` 返回成功标志Commit 失败时记录写回队头下个 tick 重试原实现无论成败都移除会丢数据
- [x] 单测验证启动即蒸馏 + 不重复蒸馏 + batch 分批消化`TestDistillOnceFreshRecords`/`TestDistillOnceBatchLimit` 新增既有用例回归通过
---
### Phase 5嵌入模型内存优化运维侧✅ **计划中**
### Phase 5嵌入模型内存优化运维侧✅ **已完成(代码层)**
- [ ] 提供 **量化/裁剪** 选项`embedding_model_path` 支持 `top50k` 规格或运行时 `mmap` 只加载词表需求词
- [ ] 文档补充内存预算说明双模 300 1.5G RAM/模型
- [ ] 生产可选降级仅保留中文模型主语言)。
- [x] 提供 **量化/裁剪** 选项`embedding_model_path` 支持 `#topN` 规格 `/data/cc.zh.300.vec#top50000`只加载前 N 个词向量fastText 词频降序 N 词覆盖绝大多数文本命中`parseModelSpec` 解析规格`ensureModelFile`/`load` 均按裁剪路径处理未命中词走 `unkVec` 兜底无规格行为不变
- [x] 文档补充配置项 Description 已写明 `#topN` 用法与内存预算建议双模 300 全量 1.5G RAM/模型`top50000` 级裁剪可显著降低
- [ ] 生产可选降级仅保留中文模型主语言——部署时在 `embedding_model_path` 只填中文模型或加 `#topN` 即可无需改代码
- [x] 单测`TestStaticEmbedderTopNSpec`规格解析 + topN 裁剪加载词数+ `TestStaticEmbedderTopNVectorize`裁剪后 unkVec 兜底不空向量全绿
---
@ -150,8 +152,8 @@
- **首次创建 立即通知"**已启动**"**确保 agent 感知终端存在
- [x] 通知频率可配置per-plugin settings`notify_bytes``notify_interval`把控制权交还 agent不写死
- [x] 纯进度输出仍吸入 `t.buf`agent 需要时用现有 `terminal_read` 主动拉全量保持 agent 可感知存在可自主决策取量)。
- [ ] 单测终端持续吐进度时消息注入频率显著低于 500ms/进程结束/出错/提示符时立即通知
- [ ] 运维止血杀掉残留 `term_3` bashPID 3716282验证 QQ 消息恢复响应
- [x] 单测mock ptyTerm + capture IOInjector 三用例——`TestReadLoopNotifyThrottle`3s 持续 2KB/s 吐进度仅 3 条通知远低于 500ms/条的 6 )、`TestReadLoopNotifyOnExit`进程退出立即通知)、`TestReadLoopNotifyOnReadError`读取错误立即通知全绿
- [ ] 运维止血杀掉残留 `term_3` bashPID 3716282验证 QQ 消息恢复响应生产侧代码已就绪
---
@ -247,14 +249,16 @@ internal/memory/static_embedder.go # 量化/裁剪入口(可选)
### 实施路线非阻塞Phase 8+
```
Phase 8.1: CSS 变量系统 + Glassmorphism 基础样式(浅/深色)
Phase 8.2: 布局重构 — 侧边栏 + 面包屑 + 卡片网格响应式
Phase 8.3: 核心页面卡片化 — 概览/记忆/插件/工具/配置/日志
Phase 8.4: 交互微动效 — 3D 倾斜卡片、弹簧按钮、Toast、Loading
Phase 8.5: 数据可视化 — Canvas 记忆趋势/资源环图/工具热力图
Phase 8.6: 空状态/错误/确认弹窗统一组件库
Phase 8.7: 无障碍/键盘导航/移动端适配
Phase 8.1: CSS 变量系统 + Glassmorphism 基础样式(浅/深色)— dashboard.html :root 重写 NapCat DNA tokenssakura/frost 色板、玻璃变量、阴影、圆角、字体、动效)
Phase 8.2: 布局重构 — 左侧 16rem 可折叠侧边栏 + 顶部面包屑 topbar + 卡片网格响应式toggleSidebar/switchTab 联动)
Phase 8.3: 核心页面卡片化 — 全部 .card 玻璃态 + hover 抬升 + 语义色 badge/dot/按钮;星图/终端/设置面板统一换肤
Phase 8.4: 交互微动效 — 3D 透视倾斜 + 光标光斑(事件委托 .card.tilt、弹簧按钮 scale、Toast 滑入动画、Loading、tab 切换 fade-up
Phase 8.5: 数据可视化 — Canvas 记忆分布环图drawDonut 扫掠动画)+ 运行时资源条形图(延迟生长动画)
Phase 8.6: 空状态/错误/确认弹窗统一组件库 — showToast(type,msg) + confirmDialog(action,onConfirm)Esc/Enter/遮罩关闭)
Phase 8.7: 无障碍/键盘导航/移动端适配 — :focus-visible ring、prefers-reduced-motion 全停动效、主题滚动条、移动端自动折叠侧边栏
Phase 8.8: ✅ 用户反馈迭代8.x 收尾)— ①健康检查 UI 去冗余(系统操作卡仅保留重载插件,健康检查卡自带右上角「运行」按钮+空态文案);②主题跟随系统(无手动偏好时用 prefers-color-scheme并监听系统实时切换③设置页内容列 max-width 860px 居中④emoji 清理(☰/☀️/🌙/⛔/🔧/🧠 → 内联 SVG/纯文本,聊天工具调用状态用语义色图标);⑤总览页重构为插件页式全宽单列卡片(移除看板娘大照片卡、移除不准确的记忆分布环图+运行时资源条形图runtime/memory 改 kv-row 精确数字展示kernel 页同化
```
> 实现均在 `internal/plugins/webui/dashboard.html`(纯 CSS + Vanilla JS无构建链`handler_test.go:641` 修复上游遗留断言失配(`api('/settings'` → `api("/settings"`)。
### 技术约束
- **保持 Go `html/template` + 内嵌静态资源** —— 不引入 Node/构建链