mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-20 17:08:09 +00:00
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:
BIN
cmd/gui/icon.ico
BIN
cmd/gui/icon.ico
Binary file not shown.
|
Before Width: | Height: | Size: 295 KiB After Width: | Height: | Size: 86 KiB |
@ -12,16 +12,28 @@ let mainWindow;
|
||||
function loadConnections() {
|
||||
try {
|
||||
if (fs.existsSync(CONNECTIONS_FILE)) {
|
||||
return JSON.parse(fs.readFileSync(CONNECTIONS_FILE, 'utf-8'));
|
||||
const data = JSON.parse(fs.readFileSync(CONNECTIONS_FILE, 'utf-8'));
|
||||
normalizeConnections(data);
|
||||
return data;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load connections:', e);
|
||||
// 配置损坏:备份后重建,避免应用一直处于"无连接"状态
|
||||
try {
|
||||
const backup = CONNECTIONS_FILE + '.bak';
|
||||
fs.copyFileSync(CONNECTIONS_FILE, backup);
|
||||
fs.writeFileSync(CONNECTIONS_FILE, '{"connections":[],"currentId":null}', 'utf-8');
|
||||
console.error('Backed up corrupt connections to', backup);
|
||||
} catch (e2) {
|
||||
console.error('Failed to recover connections file:', e2);
|
||||
}
|
||||
}
|
||||
// Fallback: check app resource dir (installer writes fallback copy there)
|
||||
try {
|
||||
const fallback = path.join(__dirname, 'connections.json');
|
||||
if (fs.existsSync(fallback)) {
|
||||
const data = JSON.parse(fs.readFileSync(fallback, 'utf-8'));
|
||||
normalizeConnections(data);
|
||||
saveConnections(data);
|
||||
console.log('Imported connections from app resource dir');
|
||||
return data;
|
||||
@ -32,6 +44,15 @@ function loadConnections() {
|
||||
return { connections: [], currentId: null };
|
||||
}
|
||||
|
||||
// 兼容旧数据:缺失的 type 默认为 webui(HTTP)
|
||||
function normalizeConnections(data) {
|
||||
if (!data || !Array.isArray(data.connections)) return;
|
||||
data.connections.forEach((c) => {
|
||||
if (!c.type) c.type = 'webui';
|
||||
if (c.type !== 'cli' && c.type !== 'webui') c.type = 'webui';
|
||||
});
|
||||
}
|
||||
|
||||
function saveConnections(data) {
|
||||
try {
|
||||
fs.writeFileSync(CONNECTIONS_FILE, JSON.stringify(data, null, 2), 'utf-8');
|
||||
@ -109,7 +130,8 @@ function createWindow() {
|
||||
minWidth: 900,
|
||||
minHeight: 600,
|
||||
title: 'HomeAgent',
|
||||
icon: path.join(__dirname, 'icon.svg'),
|
||||
frame: false,
|
||||
icon: path.join(__dirname, 'icon.ico'),
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
contextIsolation: true,
|
||||
@ -128,6 +150,18 @@ function createWindow() {
|
||||
});
|
||||
}
|
||||
|
||||
ipcMain.handle('window:minimize', (e) => {
|
||||
BrowserWindow.fromWebContents(e.sender)?.minimize();
|
||||
});
|
||||
ipcMain.handle('window:toggleMaximize', (e) => {
|
||||
const win = BrowserWindow.fromWebContents(e.sender);
|
||||
if (!win) return;
|
||||
if (win.isMaximized()) win.unmaximize(); else win.maximize();
|
||||
});
|
||||
ipcMain.handle('window:close', (e) => {
|
||||
BrowserWindow.fromWebContents(e.sender)?.close();
|
||||
});
|
||||
|
||||
ipcMain.handle('connections:list', () => {
|
||||
return loadConnections();
|
||||
});
|
||||
@ -135,7 +169,14 @@ ipcMain.handle('connections:list', () => {
|
||||
ipcMain.handle('connections:add', (_, conn) => {
|
||||
const data = loadConnections();
|
||||
const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
||||
data.connections.push({ id, name: conn.name, url: conn.url, apiKey: conn.apiKey });
|
||||
data.connections.push({
|
||||
id,
|
||||
name: conn.name,
|
||||
url: conn.url || '',
|
||||
apiKey: conn.apiKey || '',
|
||||
type: conn.type === 'cli' ? 'cli' : 'webui',
|
||||
socketPath: conn.socketPath || '',
|
||||
});
|
||||
if (!data.currentId) data.currentId = id;
|
||||
saveConnections(data);
|
||||
return data;
|
||||
@ -170,6 +211,52 @@ ipcMain.handle('connections:setCurrent', (_, id) => {
|
||||
return data;
|
||||
});
|
||||
|
||||
// CLI 传输:通过 homed 的 unix socket(逐行 JSON 协议)发起请求。
|
||||
// 认证行:/auth <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) {
|
||||
|
||||
@ -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 }),
|
||||
},
|
||||
});
|
||||
|
||||
@ -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 (!name || !url) { toast(__('名称和地址不能为空','Name and URL required'), true); return; }
|
||||
if (ctype === 'cli') {
|
||||
if (!name || !sock) { toast(__('名称和 Socket 路径不能为空','Name and Socket Path required'), true); return; }
|
||||
} else {
|
||||
if (!name || !url) { toast(__('名称和地址不能为空','Name and URL required'), true); return; }
|
||||
}
|
||||
var testBtn = document.querySelector('#conn-form .btn-primary');
|
||||
testBtn.textContent = __('测试中...','Testing...'); testBtn.disabled = true;
|
||||
try {
|
||||
var testR = await fetch(url + '/api/v1/status', { headers: apiKey ? { 'X-API-Key': apiKey } : {} });
|
||||
if (!testR.ok) { toast(__('连接测试失败: HTTP ','Connection test failed: HTTP ') + testR.status, true); testBtn.textContent = __('保存 / Save','Save'); testBtn.disabled = false; return; }
|
||||
if (ctype === 'cli') {
|
||||
if (!window.homeagent.cli) throw new Error('cli bridge unavailable');
|
||||
var testR = await window.homeagent.cli.request(sock, apiKey, '/status');
|
||||
if (testR.error || testR.type === 'error') {
|
||||
toast(__('CLI 连接测试失败: ','CLI test failed: ') + (testR.error || testR.type), true);
|
||||
testBtn.textContent = __('保存','Save'); testBtn.disabled = false; return;
|
||||
}
|
||||
} else {
|
||||
var testR = await fetch(url + '/api/v1/status', { headers: apiKey ? { 'X-API-Key': apiKey } : {} });
|
||||
if (!testR.ok) { toast(__('连接测试失败: HTTP ','Connection test failed: HTTP ') + testR.status, true); testBtn.textContent = __('保存','Save'); testBtn.disabled = false; return; }
|
||||
}
|
||||
} catch(e) {
|
||||
toast(__('无法连接到 ','Cannot connect to ') + url + ': ' + e.message, true);
|
||||
testBtn.textContent = __('保存 / Save','Save'); testBtn.disabled = false; return;
|
||||
toast(__('无法连接到 ','Cannot connect to ') + (ctype === 'cli' ? sock : url) + ': ' + e.message, true);
|
||||
testBtn.textContent = __('保存','Save'); testBtn.disabled = false; return;
|
||||
}
|
||||
testBtn.textContent = __('保存 / Save','Save'); testBtn.disabled = false;
|
||||
testBtn.textContent = __('保存','Save'); testBtn.disabled = false;
|
||||
var connData = ctype === 'cli'
|
||||
? { name: name, type: 'cli', socketPath: sock, url: '', apiKey: apiKey }
|
||||
: { name: name, type: 'webui', url: url, apiKey: apiKey };
|
||||
var data;
|
||||
if (editingConnId) {
|
||||
data = await window.homeagent.connections.update(editingConnId, { name: name, url: url, apiKey: apiKey });
|
||||
data = await window.homeagent.connections.update(editingConnId, connData);
|
||||
} else {
|
||||
data = await window.homeagent.connections.add({ name: name, url: url, apiKey: apiKey });
|
||||
data = await window.homeagent.connections.add(connData);
|
||||
}
|
||||
state.connections = data.connections;
|
||||
var cur = data.connections.find(function(c) { return c.id === data.currentId });
|
||||
var switched = !!cur && (!state.currentConn || state.currentConn.id !== cur.id);
|
||||
if (cur) {
|
||||
state.currentConn = cur;
|
||||
if (!document.getElementById('app').style.display || document.getElementById('app').style.display === 'none') {
|
||||
document.getElementById('app').style.display = 'block';
|
||||
document.getElementById('conn-overlay').style.display = 'none';
|
||||
if (switched) {
|
||||
if (state.eventSource) { state.eventSource.close(); state.eventSource = null; }
|
||||
state.messages = [];
|
||||
updateConnIndicator(); connectSSE(); await loadChatHistory(); doRenderAll(); startUptimeTicker();
|
||||
} else { updateConnIndicator(); if (editingConnId) doRenderAll(); }
|
||||
switchView('chat');
|
||||
} else {
|
||||
updateConnIndicator(); doRenderAll();
|
||||
}
|
||||
}
|
||||
cancelConnForm(); renderConnList();
|
||||
cancelConnForm(); renderConnSection();
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', function(e) {
|
||||
@ -1661,6 +1917,8 @@ document.addEventListener('keydown', function(e) {
|
||||
connectSSE = function() {
|
||||
if (state.eventSource) { state.eventSource.close(); state.eventSource = null; }
|
||||
if (!state.currentConn) return;
|
||||
// CLI 连接无 SSE 通道,聊天走同步 cli:request
|
||||
if (state.currentConn.type === 'cli') return;
|
||||
connectFetchSSE(state.currentConn.url + '/api/v1/chat/events');
|
||||
};
|
||||
|
||||
@ -1688,39 +1946,72 @@ async function connectFetchSSE(url) {
|
||||
var ev = JSON.parse(raw); var p = ev.payload || {};
|
||||
if (type === 'agent_output') {
|
||||
state.chatStage = __('AI 回复中...','AI replying...');
|
||||
if (state.messages.length > 0 && state.messages[state.messages.length - 1].role === 'assistant' && !state.messages[state.messages.length - 1]._final) {
|
||||
state.messages[state.messages.length - 1].content += (p.content || '');
|
||||
if (p.kind === 'channel_output') {
|
||||
state.messages.push({ role: 'assistant', content: p.content || '', source: p.channel || '', _final: true, _grow: true });
|
||||
rerenderChatIfActive(); return;
|
||||
}
|
||||
state.messages.push({ role: 'assistant', content: p.content || '', _streaming: true });
|
||||
var last = state.messages.length > 0 ? state.messages[state.messages.length - 1] : null;
|
||||
if (last && last.role === 'assistant' && !last._final) {
|
||||
last._grow = true;
|
||||
last.content += (p.content || '');
|
||||
rerenderChatIfActive(); return;
|
||||
}
|
||||
if (last && last.role === 'assistant' && last._final) { return; }
|
||||
state.messages.push({ role: 'assistant', content: p.content || '', _streaming: true, _grow: true });
|
||||
rerenderChatIfActive();
|
||||
} else if (type === 'reasoning') {
|
||||
if (p.content && state.messages.length > 0) {
|
||||
var last = state.messages[state.messages.length - 1];
|
||||
if (last.role === 'assistant') {
|
||||
state.chatStage = __('AI 思考中...','AI thinking...');
|
||||
last.reasoning_content = (last.reasoning_content || '') + (p.content || '');
|
||||
rerenderChatIfActive();
|
||||
if (p.content) {
|
||||
state.chatStage = __('AI 思考中...','AI thinking...');
|
||||
var last = state.messages.length > 0 ? state.messages[state.messages.length - 1] : null;
|
||||
if (!last || last.role !== 'assistant' || last._final) {
|
||||
state.messages.push({ role: 'assistant', content: '', reasoning_content: '', tool_calls: [], _streaming: true });
|
||||
last = state.messages[state.messages.length - 1];
|
||||
}
|
||||
last.reasoning_content = (last.reasoning_content || '') + (p.content || '');
|
||||
rerenderChatIfActive();
|
||||
}
|
||||
} else if (type === 'tool_call') {
|
||||
if (!p.tool) return;
|
||||
var last = state.messages.length > 0 ? state.messages[state.messages.length - 1] : null;
|
||||
if (!last || last.role !== 'assistant') {
|
||||
if (!last || last.role !== 'assistant' || last._final) {
|
||||
state.messages.push({ role: 'assistant', content: '', tool_calls: [], _streaming: true });
|
||||
last = state.messages[state.messages.length - 1];
|
||||
}
|
||||
if (!last.tool_calls) last.tool_calls = [];
|
||||
last.tool_calls.push({ tool: p.tool, name: p.tool, args: p.args || {}, result: p.result || '', status: p.status || 'ok', plugin: p.plugin || '' });
|
||||
var pidx = (state.pendingTools || []).indexOf(p.tool);
|
||||
if (pidx !== -1) state.pendingTools.splice(pidx, 1);
|
||||
state.chatStage = __('工具调用: ','Tool: ') + (p.tool || '');
|
||||
rerenderChatIfActive();
|
||||
} else if (type === 'terminal_output') {
|
||||
if (!p.terminal_id) return;
|
||||
var tid = p.terminal_id;
|
||||
if (!state.termScreens) state.termScreens = {};
|
||||
var scr = state.termScreens[tid] || (state.termScreens[tid] = { output: '', running: true });
|
||||
if (p.output) scr.output += p.output;
|
||||
if (typeof p.running === 'boolean') scr.running = p.running;
|
||||
var bufel = document.getElementById('term-buf-' + tid);
|
||||
if (bufel) {
|
||||
appendTermBuf(bufel, p.output || '');
|
||||
var dot = document.getElementById('term-dot-' + tid);
|
||||
if (dot) dot.className = 'term-dot' + (scr.running ? '' : ' stopped');
|
||||
}
|
||||
} else if (type === 'stage') {
|
||||
var phase = p.phase || ''; var tool = p.tool || '';
|
||||
if (phase === 'pre_action') state.chatStage = __('AI 思考中...','AI thinking...');
|
||||
else if (phase === 'before_toolcall') state.chatStage = __('工具调用: ','Tool: ') + (tool || '');
|
||||
else if (phase === 'before_output') state.chatStage = __('生成回复中...','Generating response...');
|
||||
if (p.channel !== '_consolidation_') {
|
||||
if (phase === 'pre_action') state.chatStage = __('AI 思考中...','AI thinking...');
|
||||
else if (phase === 'before_toolcall') {
|
||||
state.chatStage = __('工具调用: ','Tool: ') + (tool || '');
|
||||
if (tool && (state.pendingTools || []).indexOf(tool) === -1) {
|
||||
if (!state.pendingTools) state.pendingTools = [];
|
||||
state.pendingTools.push(tool);
|
||||
rerenderChatIfActive();
|
||||
}
|
||||
}
|
||||
else if (phase === 'before_output') state.chatStage = __('生成回复中...','Generating response...');
|
||||
}
|
||||
var badge = document.getElementById('chat-stage');
|
||||
if (badge) { badge.textContent = state.chatStage || ''; badge.style.display = state.chatLoading ? 'inline' : 'none' }
|
||||
if (badge) { badge.textContent = state.chatStage || ''; badge.style.display = 'none'; }
|
||||
}
|
||||
} catch(err) {}
|
||||
}
|
||||
@ -1735,7 +2026,7 @@ async function connectFetchSSE(url) {
|
||||
}
|
||||
|
||||
function rerenderChatIfActive() {
|
||||
var tab = document.getElementById('tab-chat');
|
||||
var tab = document.getElementById('view-chat');
|
||||
if (tab && tab.classList.contains('active')) { renderChat(); renderChatStarmap(); renderTerminals(); renderCmdHistory(); }
|
||||
}
|
||||
|
||||
|
||||
28
cmd/gui/renderer/icon.svg
Normal file
28
cmd/gui/renderer/icon.svg
Normal 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 |
@ -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>
|
||||
<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">
|
||||
<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>
|
||||
<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>
|
||||
|
||||
|
||||
@ -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 |
BIN
cmd/gui/renderer/mascot.webp
Normal file
BIN
cmd/gui/renderer/mascot.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 130 KiB |
@ -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
5
go.mod
@ -12,6 +12,9 @@ require github.com/yanyiwu/gojieba v1.4.7
|
||||
|
||||
require github.com/yalue/onnxruntime_go v1.13.0
|
||||
|
||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.8.0
|
||||
require (
|
||||
gitcode.com/JianFeeeee/homeagent-sdk v0.8.0
|
||||
golang.org/x/sys v0.8.0
|
||||
)
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ./third_party/homeagent-sdk
|
||||
|
||||
4
go.sum
4
go.sum
@ -1,5 +1,3 @@
|
||||
github.com/mattn/go-sqlite3 v1.14.48 h1:7XHIgl0a8HwOaiK4E47ozLkST78rR9+OtNGx27D/TFs=
|
||||
github.com/mattn/go-sqlite3 v1.14.48/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
|
||||
github.com/mattn/go-sqlite3 v1.14.49 h1:B8jBHC3xhxZgxztrgruTuLucebnULQnx4W7cF7SAE9w=
|
||||
github.com/mattn/go-sqlite3 v1.14.49/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
|
||||
github.com/yalue/onnxruntime_go v1.13.0 h1:5HDXHon3EukQMyYA7yPMed/raWaDE/gjwLOwnVoiwy8=
|
||||
@ -8,6 +6,8 @@ github.com/yanyiwu/gojieba v1.4.7 h1:2YkXELcYLTE0SJetq6xv4MjpEikWga6VpFn4jIFFQ/k
|
||||
github.com/yanyiwu/gojieba v1.4.7/go.mod h1:JUq4DddFVGdHXJHxxepxRmhrKlDpaBxR8O28v6fKYLY=
|
||||
github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA=
|
||||
github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8=
|
||||
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
@ -100,6 +100,9 @@ type Agent struct {
|
||||
// 当前轮次的非文本媒体数据(图片/音频),供 describe_image 等工具访问
|
||||
pendingMedia map[string]interface{}
|
||||
|
||||
// 当前输入是否为工具提醒/中断(以 system 角色注入,避免被当成用户消息)
|
||||
interruptInput bool
|
||||
|
||||
// 非文本输入处理配置
|
||||
inputCfg types.InputProcessingConfig
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
@ -397,15 +398,19 @@ func docToTriples(doc *document.Doc, embedder nlp.Vectorizer) []memory.Triple {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 文档元数据
|
||||
triples = append(triples, memory.Triple{
|
||||
Subject: "文档",
|
||||
SubjectType: "Concept",
|
||||
Relation: "主题",
|
||||
Object: doc.Summary,
|
||||
ObjectType: "Topic",
|
||||
Confidence: 1.0,
|
||||
})
|
||||
isArchivedContext := doc.Meta != nil && doc.Meta["is_archived_context"] == "true"
|
||||
|
||||
// 文档元数据:仅当 summary 合理(非空、非模板化、长度适中)时才写「主题」
|
||||
if !isArchivedContext && doc.Summary != "" && len([]rune(doc.Summary)) < 80 && !isTemplateSummary(doc.Summary) {
|
||||
triples = append(triples, memory.Triple{
|
||||
Subject: "文档",
|
||||
SubjectType: "Concept",
|
||||
Relation: "主题",
|
||||
Object: doc.Summary,
|
||||
ObjectType: "Topic",
|
||||
Confidence: 1.0,
|
||||
})
|
||||
}
|
||||
|
||||
// NLP 通用提取
|
||||
e := nlp.NewExtractor(nil)
|
||||
@ -422,7 +427,8 @@ func docToTriples(doc *document.Doc, embedder nlp.Vectorizer) []memory.Triple {
|
||||
}
|
||||
}
|
||||
|
||||
if doc.Source != "" {
|
||||
// 仅当来源非归档上下文且非空时写「来源」——归档文档写死模板三元组属于垃圾
|
||||
if doc.Source != "" && doc.Source != "context_archived" {
|
||||
triples = append(triples, memory.Triple{
|
||||
Subject: "文档",
|
||||
SubjectType: "Concept",
|
||||
@ -436,6 +442,16 @@ func docToTriples(doc *document.Doc, embedder nlp.Vectorizer) []memory.Triple {
|
||||
return triples
|
||||
}
|
||||
|
||||
// isTemplateSummary 识别 summarizeEntries 生成的模板化摘要
|
||||
// (形如「来自 N 个来源的 M 条对话 (src1, src2) 涉及: kw1, kw2」),
|
||||
// 这类摘要无独立信息量,不应作为「主题」实体写入图库。
|
||||
func isTemplateSummary(s string) bool {
|
||||
if s == "" {
|
||||
return true
|
||||
}
|
||||
return strings.HasPrefix(s, "来自 ") && strings.Contains(s, "条对话")
|
||||
}
|
||||
|
||||
func (a *Agent) emitMemoryCandidate(source, input, response string, toolResults []ToolResultItem, toolsUsed []string) {
|
||||
a.io.EmitOutput("memory", "memory_candidate", map[string]interface{}{
|
||||
"source": source,
|
||||
|
||||
@ -287,6 +287,16 @@ func (a *Agent) processTextInput(evt *agentIO.InputEvent, input string) {
|
||||
}
|
||||
}
|
||||
|
||||
// 工具提醒/中断(terminal_watch、timer 等)不是用户发言:
|
||||
// 以 system 角色注入 LLM,且不写入用户对话履历。
|
||||
isInterrupt, _ := evt.Payload["interrupt"].(bool)
|
||||
a.mu.Lock()
|
||||
a.interruptInput = isInterrupt
|
||||
a.mu.Unlock()
|
||||
if isInterrupt {
|
||||
noMemory = true
|
||||
}
|
||||
|
||||
stageCtx := a.stageCtxFromInput(input, evt.Source, "")
|
||||
stageCtx.Extra["input_source"] = evt.Source
|
||||
stageCtx.Extra["output_channel"] = evt.OutputChannel
|
||||
@ -320,11 +330,13 @@ func (a *Agent) processTextInput(evt *agentIO.InputEvent, input string) {
|
||||
log.Printf("[agent] pruned %d low-relevance events to document memory", archived)
|
||||
}
|
||||
|
||||
a.context.Append(ContextEvent{
|
||||
Timestamp: start,
|
||||
Source: evt.Source,
|
||||
Input: input,
|
||||
})
|
||||
if !isInterrupt {
|
||||
a.context.Append(ContextEvent{
|
||||
Timestamp: start,
|
||||
Source: evt.Source,
|
||||
Input: input,
|
||||
})
|
||||
}
|
||||
|
||||
response, toolsUsed, toolResults, err := a.process(input, stageCtx)
|
||||
if err != nil {
|
||||
@ -380,7 +392,6 @@ func (a *Agent) emitResponse(evt *agentIO.InputEvent, response string) {
|
||||
if stageCtx.TokenUsage != nil {
|
||||
payload["usage"] = stageCtx.TokenUsage
|
||||
}
|
||||
|
||||
if evt.ResponseCh != nil {
|
||||
evt.ResponseCh <- &agentIO.OutputEvent{
|
||||
RequestID: evt.RequestID,
|
||||
@ -392,11 +403,15 @@ func (a *Agent) emitResponse(evt *agentIO.InputEvent, response string) {
|
||||
}
|
||||
}
|
||||
|
||||
a.publishEvent(events.EventAgentOutput, map[string]interface{}{
|
||||
out := map[string]interface{}{
|
||||
"content": response,
|
||||
"channel": ch,
|
||||
"source": evt.Source,
|
||||
})
|
||||
}
|
||||
if stageCtx.ReasoningContent != "" {
|
||||
out["reasoning_content"] = stageCtx.ReasoningContent
|
||||
}
|
||||
a.publishEvent(events.EventAgentOutput, out)
|
||||
stageCtx.Phase = sdk.StageAfterOutput
|
||||
a.runStage(sdk.StageAfterOutput, stageCtx)
|
||||
}
|
||||
|
||||
@ -27,6 +27,14 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
|
||||
tools := a.buildToolDefs()
|
||||
|
||||
msgs := a.buildMessages(sysPrompt, input, budget.ContextTokens)
|
||||
// 工具提醒(interrupt):以 system 角色注入,不让模型误认为用户发言
|
||||
if a.interruptInput {
|
||||
last := msgs[len(msgs)-1]
|
||||
last.Role = "system"
|
||||
last.Content = "[中断消息] " + last.Content
|
||||
msgs[len(msgs)-1] = last
|
||||
a.interruptInput = false
|
||||
}
|
||||
if blocks, ok := stageCtx.Extra["media_blocks"].([]agentAPI.ContentBlock); ok && len(blocks) > 0 {
|
||||
if len(msgs) > 0 {
|
||||
msgs[len(msgs)-1].Blocks = blocks
|
||||
@ -56,7 +64,16 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
|
||||
for _, interrupt := range a.drainInterrupts() {
|
||||
msgs = append(msgs, agentAPI.Message{
|
||||
Role: "system",
|
||||
Content: interrupt,
|
||||
Content: "[中断消息] " + interrupt,
|
||||
})
|
||||
}
|
||||
|
||||
// zen 兼容网关要求请求的最后一条消息必须是 user(thinking 续写模式校验),
|
||||
// 工具轮产出的 tool/assistant 消息作结尾会被 400 拒绝,故补一条 user 占位。
|
||||
if last := msgs[len(msgs)-1]; last.Role != "user" {
|
||||
msgs = append(msgs, agentAPI.Message{
|
||||
Role: "user",
|
||||
Content: "请根据以上工具结果继续。",
|
||||
})
|
||||
}
|
||||
|
||||
@ -177,6 +194,13 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
|
||||
}
|
||||
a.publishEvent(events.EventAgentLLMChain, chainPayload)
|
||||
|
||||
if resp.ReasoningContent != "" {
|
||||
a.publishEvent(events.EventReasoning, map[string]interface{}{
|
||||
"content": resp.ReasoningContent,
|
||||
"channel": a.currentOutputChannel,
|
||||
})
|
||||
}
|
||||
|
||||
if len(resp.ToolCalls) == 0 {
|
||||
return resp.Content, toolsUsed, toolResults, nil
|
||||
}
|
||||
@ -185,15 +209,16 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
|
||||
for _, tc := range resp.ToolCalls {
|
||||
if len(a.interceptCh) > 0 {
|
||||
for _, interrupt := range a.drainInterrupts() {
|
||||
msgs = append(msgs, agentAPI.Message{Role: "system", Content: interrupt})
|
||||
msgs = append(msgs, agentAPI.Message{Role: "system", Content: "[中断消息] " + interrupt})
|
||||
}
|
||||
a.publishEvent(events.EventToolCall, map[string]interface{}{
|
||||
"tool": tc.Name,
|
||||
"plugin": a.resolveToolPlugin(tc.Name),
|
||||
"args": tc.Arguments,
|
||||
"status": "interrupted",
|
||||
"reason": "user interrupt before execution",
|
||||
})
|
||||
a.publishEvent(events.EventToolCall, map[string]interface{}{
|
||||
"tool": tc.Name,
|
||||
"plugin": a.resolveToolPlugin(tc.Name),
|
||||
"args": tc.Arguments,
|
||||
"status": "interrupted",
|
||||
"reason": "user interrupt before execution",
|
||||
"channel": a.currentOutputChannel,
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
@ -208,13 +233,14 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
|
||||
result := fmt.Sprintf("工具 %s 已被插件拒绝", tc.Name)
|
||||
msgs = append(msgs, agentAPI.Message{Role: "assistant", ToolCalls: []agentAPI.ToolCall{tc}})
|
||||
msgs = append(msgs, agentAPI.Message{Role: "tool", ToolCallID: tc.ID, Content: result})
|
||||
a.publishEvent(events.EventToolCall, map[string]interface{}{
|
||||
"tool": tc.Name,
|
||||
"plugin": pluginName,
|
||||
"args": tc.Arguments,
|
||||
"result": result,
|
||||
"status": "denied",
|
||||
})
|
||||
a.publishEvent(events.EventToolCall, map[string]interface{}{
|
||||
"tool": tc.Name,
|
||||
"plugin": pluginName,
|
||||
"args": tc.Arguments,
|
||||
"result": result,
|
||||
"status": "denied",
|
||||
"channel": a.currentOutputChannel,
|
||||
})
|
||||
continue
|
||||
}
|
||||
tc.Arguments = stageCtx.ToolCalls[0].Arguments
|
||||
@ -248,16 +274,17 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
|
||||
msgs = append(msgs, agentAPI.Message{Role: "tool", ToolCallID: tc.ID, Content: result})
|
||||
|
||||
a.publishEvent(events.EventToolCall, map[string]interface{}{
|
||||
"tool": tc.Name,
|
||||
"plugin": pluginName,
|
||||
"args": tc.Arguments,
|
||||
"result": result,
|
||||
"status": "ok",
|
||||
"tool": tc.Name,
|
||||
"plugin": pluginName,
|
||||
"args": tc.Arguments,
|
||||
"result": result,
|
||||
"status": "ok",
|
||||
"channel": a.currentOutputChannel,
|
||||
})
|
||||
|
||||
if len(a.interceptCh) > 0 {
|
||||
for _, interrupt := range a.drainInterrupts() {
|
||||
msgs = append(msgs, agentAPI.Message{Role: "system", Content: interrupt})
|
||||
msgs = append(msgs, agentAPI.Message{Role: "system", Content: "[中断消息] " + interrupt})
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -49,12 +49,13 @@ func (a *Agent) buildSystemPrompt(memContext string, userInput string) string {
|
||||
}
|
||||
}
|
||||
|
||||
prompt += "\n\n【输出规则】你有多组输出门工具(type=output),每个对应一个输出通道。回复用户时必须调用对应的 output_send__{通道名} 工具。\n"
|
||||
prompt += "- payload 参数是消息载荷(文本直接填文字),type 指定载荷类型(text/voice/image/file),meta 是 JSON 发送元数据(群号/用户号等)。\n"
|
||||
prompt += "\n\n【中断消息】长任务执行期间,工具/插件/定时器等会通过中断机制向你发送提醒(如 QQ 新消息、终端输出到达、定时器到点等)。中断消息以 system 角色注入,内容带 [中断消息] 前缀,**不是用户发言,但也必须认真处理**:优先停下当前长任务,针对中断内容作出响应或决定继续执行。不要忽略带 [中断消息] 前缀的 system 消息。"
|
||||
|
||||
prompt += "\n\n【输出规则】回复会自动发送到用户的输入来源通道,直接返回纯文本即可送达,无需调用任何工具。\n"
|
||||
prompt += "- 输出门工具 output_send__{通道名} 用于主动向指定通道推送消息(如群发、主动通知、向其他通道发言),不是回复的必要步骤。除非用户要求在别的通道发送,否则不要使用。\n"
|
||||
prompt += "- 用 output_send__{通道名}_help 查看该通道的 meta 格式和 type 枚举。\n"
|
||||
prompt += "- 同一轮对话中可多次调用输出门工具。长消息应当分多次发出,而不是一口气发完。\n"
|
||||
prompt += "- 直接返回纯文本不会到达任何用户端。\n"
|
||||
prompt += "- 需要多步执行的长任务:**必须先**用 output_send__ 发一条确认消息告诉用户已收到(如「好的我去看看~」),**然后再**执行具体排查工具。确认消息不代表任务完成,发出后仍需继续执行实际工具并最终汇报结果。"
|
||||
prompt += "- 需要多步执行的长任务:**必须先**用输出门工具向当前输入通道发一条确认消息告诉用户已收到(如「好的我去看看~」,也可以直接返回文本),**然后再**执行具体排查工具。确认消息不代表任务完成,发出后仍需继续执行实际工具并最终汇报结果。"
|
||||
|
||||
if a.indexer != nil {
|
||||
prompt += "\n\n" + a.indexer.BuildToolPrompt()
|
||||
|
||||
@ -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"})
|
||||
|
||||
|
||||
@ -16,6 +16,7 @@ const (
|
||||
EventReasoning EventType = "reasoning"
|
||||
EventStage EventType = "stage"
|
||||
EventSystem EventType = "system"
|
||||
EventTerminalOutput EventType = "terminal_output"
|
||||
EventAll EventType = "*"
|
||||
)
|
||||
|
||||
|
||||
103
internal/lua/adapters/server.lua
Normal file
103
internal/lua/adapters/server.lua
Normal 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_thinking(homeagent 置 true 时网关关闭 thinking,
|
||||
-- 从而不再强制要求 reasoning_content 回传);同时保留已有 reasoning_content 双保险。
|
||||
function adapter.transform_request(raw_body)
|
||||
local ok, req = pcall(json.decode, raw_body)
|
||||
if not ok then return raw_body end
|
||||
req.extra_body = nil
|
||||
return json.encode(req)
|
||||
end
|
||||
|
||||
function adapter.transform_response(raw_body)
|
||||
local ok, resp = pcall(json.decode, raw_body)
|
||||
if not ok or resp == nil then return raw_body end
|
||||
|
||||
local unified = {
|
||||
content = "",
|
||||
finish_reason = "",
|
||||
token_usage = { prompt = 0, completion = 0, total = 0 }
|
||||
}
|
||||
|
||||
if type(resp.usage) == "table" then
|
||||
unified.token_usage.prompt = resp.usage.prompt_tokens or 0
|
||||
unified.token_usage.completion = resp.usage.completion_tokens or 0
|
||||
unified.token_usage.total = resp.usage.total_tokens or 0
|
||||
end
|
||||
|
||||
if type(resp.choices) == "table" and #resp.choices > 0 then
|
||||
local ch = resp.choices[1]
|
||||
if type(ch.message) == "table" then
|
||||
unified.content = ch.message.content or ""
|
||||
if ch.message.reasoning_content then
|
||||
unified.reasoning_content = ch.message.reasoning_content
|
||||
end
|
||||
if type(ch.message.tool_calls) == "table" then
|
||||
local tcs = {}
|
||||
for _, tc in ipairs(ch.message.tool_calls) do
|
||||
local fn = tc["function"]
|
||||
local name = tc.name
|
||||
local raw_args = tc.arguments
|
||||
if type(fn) == "table" then
|
||||
name = fn.name or name
|
||||
raw_args = fn.arguments or raw_args
|
||||
end
|
||||
local args = {}
|
||||
if type(raw_args) == "table" then
|
||||
args = raw_args
|
||||
elseif type(raw_args) == "string" and raw_args ~= "" then
|
||||
local args_ok, decoded = pcall(json.decode, raw_args)
|
||||
if args_ok and type(decoded) == "table" then
|
||||
args = decoded
|
||||
elseif args_ok then
|
||||
args = { value = decoded }
|
||||
else
|
||||
args = { raw = raw_args }
|
||||
end
|
||||
end
|
||||
if name ~= nil and name ~= "" then
|
||||
table.insert(tcs, {
|
||||
id = tc.id,
|
||||
type = tc.type or "function",
|
||||
name = name,
|
||||
arguments = args
|
||||
})
|
||||
end
|
||||
end
|
||||
unified.tool_calls = tcs
|
||||
end
|
||||
end
|
||||
unified.finish_reason = ch.finish_reason or ""
|
||||
end
|
||||
|
||||
return json.encode(unified)
|
||||
end
|
||||
|
||||
function adapter.transform_stream_chunk(raw_chunk)
|
||||
local ok, chunk = pcall(json.decode, raw_chunk)
|
||||
if not ok then return "" end
|
||||
|
||||
if not chunk.choices or #chunk.choices == 0 then return "" end
|
||||
local delta = chunk.choices[1].delta or {}
|
||||
local fr = chunk.choices[1].finish_reason
|
||||
|
||||
local unified = {
|
||||
content = delta.content or "",
|
||||
done = (fr ~= nil)
|
||||
}
|
||||
if delta.reasoning_content then
|
||||
unified.reasoning_content = delta.reasoning_content
|
||||
end
|
||||
if delta.tool_calls then
|
||||
unified.tool_calls = delta.tool_calls
|
||||
end
|
||||
return json.encode(unified)
|
||||
end
|
||||
|
||||
return adapter
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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)")
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -46,8 +46,17 @@ func terminalRunning(t *TerminalSession) bool {
|
||||
return t.cmd != nil && (t.cmd.ProcessState == nil || !t.cmd.ProcessState.Exited())
|
||||
}
|
||||
|
||||
// terminalWatch 终端提醒规则(由 terminal_watch 工具设置)。
|
||||
type terminalWatch struct {
|
||||
interval time.Duration // 固定时间反馈间隔,0 禁用
|
||||
onExit bool // 命令执行结束提醒(默认 true)
|
||||
bufferBytes int // 该终端专用缓冲阈值(字节),0 使用全局 notify_bytes
|
||||
quiet bool // 静默模式:不随输出流通知,仅定时反馈/结束提醒/空闲汇总
|
||||
}
|
||||
|
||||
type TerminalSession struct {
|
||||
id string
|
||||
command string
|
||||
cmd *exec.Cmd
|
||||
session ptyTerm
|
||||
mu sync.Mutex
|
||||
@ -59,8 +68,15 @@ type TerminalSession struct {
|
||||
done chan struct{}
|
||||
|
||||
// 通知节流字段
|
||||
unreadBytes int // 最近一次通知后积累的未读字节数
|
||||
lastNotify time.Time // 最近一次通知时间
|
||||
unreadBytes int // 最近一次通知后积累的未读字节数
|
||||
lastNotify time.Time // 最近一次通知时间
|
||||
lastData time.Time // 最近一次读到的数据时间(用于判定输出停止)
|
||||
lastFeedback time.Time // 最近一次定时反馈时间
|
||||
backoff time.Duration // 输出风暴退避:持续高速输出时通知间隔翻倍
|
||||
watch terminalWatch // 该终端的提醒规则
|
||||
|
||||
// 实时画面推流(terminal_output 事件)
|
||||
stream bytes.Buffer // 待推送的增量输出,由 readLoop 每 200ms flush 一次
|
||||
}
|
||||
|
||||
func (t *TerminalSession) Write(input string) (int, error) {
|
||||
@ -85,12 +101,13 @@ func (t *TerminalSession) Close() {
|
||||
t.mu.Unlock()
|
||||
|
||||
close(t.stopCh)
|
||||
// 先终止进程(各平台实现:Linux 信号 / Windows TerminateProcess,幂等),再释放资源。
|
||||
// 不能依赖 cmd.Process.Kill():Windows 后端 cmd.Process 为占位(仅 Pid)。
|
||||
if t.session != nil {
|
||||
_ = t.session.Kill()
|
||||
}
|
||||
t.session.Close()
|
||||
<-t.done
|
||||
|
||||
if t.cmd != nil && t.cmd.Process != nil {
|
||||
t.cmd.Process.Kill()
|
||||
}
|
||||
}
|
||||
|
||||
func (t *TerminalSession) ReadOutput() string {
|
||||
@ -119,6 +136,17 @@ func (t *TerminalSession) appendOutput(data []byte) {
|
||||
}
|
||||
}
|
||||
t.buf.Write(data)
|
||||
// 同步追加到实时画面推流缓冲(最大 64KB,超出丢弃最旧部分)
|
||||
const maxStream = 64 * 1024
|
||||
if t.stream.Len()+len(data) > maxStream {
|
||||
excess := t.stream.Len() + len(data) - maxStream
|
||||
if t.stream.Len() > excess {
|
||||
t.stream.Next(excess)
|
||||
} else {
|
||||
t.stream.Reset()
|
||||
}
|
||||
}
|
||||
t.stream.Write(data)
|
||||
}
|
||||
|
||||
func (t *TerminalSession) IsExpired() bool {
|
||||
@ -194,8 +222,10 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
}
|
||||
|
||||
s.RegisterTool("terminal_create", sdk.ToolDef{
|
||||
Name: "terminal_create",
|
||||
Description: "创建一个新的交互式终端会话。返回终端 ID,后续通过此 ID 进行读写操作。适用于运行交互式程序如 vim、ssh、top、nano 等。终端默认 5 分钟后自动关闭,可通过 timeout 参数调整。",
|
||||
Name: "terminal_create",
|
||||
Description: "创建一个新的交互式终端会话。返回终端 ID,后续通过此 ID 进行读写操作。适用于运行交互式程序如 vim、ssh、top、nano 等。" +
|
||||
"通知模式通过 notify 参数选择(默认 exit):exit=仅命令执行结束后提醒一次;interval=定时反馈(如 interval=30s 每 30 秒反馈一次状态摘要);" +
|
||||
"buffer=未读输出积累到指定字节数后提醒(如 buffer=8192);多个模式用逗号组合(如 interval=30s,buffer=8192)。终端默认 5 分钟后自动关闭,可通过 timeout 参数调整。",
|
||||
NoMemory: true,
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
@ -204,6 +234,10 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
"type": "string",
|
||||
"description": "要执行的命令(默认 bash)。如需运行特定程序直接传入即可,例如:vim /tmp/test.txt",
|
||||
},
|
||||
"notify": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "通知模式(可选):exit(默认,命令结束后提醒);interval=时长(定时反馈,如 30s/1m);buffer=字节数(缓冲阈值提醒);可逗号组合",
|
||||
},
|
||||
"timeout": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "终端自动关闭时间,例如 5m, 10m, 30m, 1h(默认 5m)",
|
||||
@ -249,8 +283,8 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
})
|
||||
|
||||
s.RegisterTool("terminal_read", sdk.ToolDef{
|
||||
Name: "terminal_read",
|
||||
Description: "读取指定终端的当前屏幕内容。返回自上次读取以来的新输出。如需持续监控请多次调用。",
|
||||
Name: "terminal_read",
|
||||
Description: "读取指定终端的输出。mode=new(默认)返回自上次读取以来的新输出并清空缓冲;mode=now 返回终端当前显示的全部屏幕内容(不清空缓冲)。如需持续监控请多次调用。",
|
||||
NoMemory: true,
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
@ -259,9 +293,13 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
"type": "string",
|
||||
"description": "终端 ID",
|
||||
},
|
||||
"mode": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "读取模式:new(默认,新输出并清空缓冲)或 now(当前屏幕全部内容,不清理)",
|
||||
},
|
||||
"clear": map[string]interface{}{
|
||||
"type": "boolean",
|
||||
"description": "读取后是否清除缓冲区(默认 true)",
|
||||
"description": "读取后是否清除缓冲区(默认与 mode 一致:new 清除,now 不清除)",
|
||||
},
|
||||
},
|
||||
"required": []string{"id"},
|
||||
@ -326,6 +364,48 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
return p.handleList()
|
||||
})
|
||||
|
||||
s.RegisterTool("terminal_watch", sdk.ToolDef{
|
||||
Name: "terminal_watch",
|
||||
Description: "为指定终端设置提醒规则,避免长时间运行任务(编译/下载/构建等)的输出造成通知风暴。" +
|
||||
"可选规则:interval=固定时间反馈(每隔该时长向 agent 反馈一次终端状态摘要);" +
|
||||
"on_exit=命令执行结束提醒;buffer_bytes=未读输出积累到该字节数时提醒一次;" +
|
||||
"quiet=静默模式(抑制随输出流的通知,仅保留定时反馈与结束提醒,推荐长任务使用)。" +
|
||||
"未提供的字段保持原值,clear=true 清除全部规则。默认 on_exit=true。",
|
||||
NoMemory: true,
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"id": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "终端 ID,来自 terminal_create 的返回值",
|
||||
},
|
||||
"interval": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "固定时间反馈间隔,如 30s, 1m, 5m(可选,0 禁用)",
|
||||
},
|
||||
"on_exit": map[string]interface{}{
|
||||
"type": "boolean",
|
||||
"description": "命令执行结束时是否提醒(默认 true)",
|
||||
},
|
||||
"buffer_bytes": map[string]interface{}{
|
||||
"type": "integer",
|
||||
"description": "未读输出积累阈值(字节),达到后提醒一次(可选,默认全局 2048)",
|
||||
},
|
||||
"quiet": map[string]interface{}{
|
||||
"type": "boolean",
|
||||
"description": "静默模式:不随输出流通知,仅保留定时反馈与结束提醒(推荐编译/下载等长任务)",
|
||||
},
|
||||
"clear": map[string]interface{}{
|
||||
"type": "boolean",
|
||||
"description": "清除该终端全部提醒规则(恢复默认行为)",
|
||||
},
|
||||
},
|
||||
"required": []string{"id"},
|
||||
},
|
||||
}, func(args map[string]interface{}) (interface{}, error) {
|
||||
return p.handleWatch(args)
|
||||
})
|
||||
|
||||
p.wg.Add(1)
|
||||
go p.cleanupLoop(s)
|
||||
|
||||
@ -378,18 +458,28 @@ func (p *Plugin) handleCreate(s *sdk.PluginSDK, args map[string]interface{}) (in
|
||||
cols = uint16(c)
|
||||
}
|
||||
|
||||
// 通知模式:默认 exit(命令执行结束后提醒一次)。
|
||||
// 支持 interval=30s / buffer=8192 / quiet,可逗号组合。
|
||||
watch := terminalWatch{onExit: true, quiet: true}
|
||||
if notifyStr, ok := args["notify"].(string); ok && notifyStr != "" {
|
||||
watch = parseNotifyMode(notifyStr, watch)
|
||||
}
|
||||
|
||||
term, cmd, err := newCommandPty(command, rows, cols)
|
||||
if err != nil {
|
||||
return map[string]interface{}{"error": fmt.Sprintf("创建终端失败: %v", err)}, nil
|
||||
}
|
||||
|
||||
session := &TerminalSession{
|
||||
id: "",
|
||||
command: command,
|
||||
cmd: cmd,
|
||||
session: term,
|
||||
createdAt: time.Now(),
|
||||
timeout: timeout,
|
||||
stopCh: make(chan struct{}),
|
||||
done: make(chan struct{}),
|
||||
watch: watch,
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
@ -404,15 +494,34 @@ func (p *Plugin) handleCreate(s *sdk.PluginSDK, args map[string]interface{}) (in
|
||||
log.Printf("[agentcli] created terminal %s: command=%q timeout=%v rows=%d cols=%d", id, command, timeout, rows, cols)
|
||||
|
||||
return map[string]interface{}{
|
||||
"id": id,
|
||||
"status": "created",
|
||||
"command": command,
|
||||
"timeout": timeout.String(),
|
||||
"rows": rows,
|
||||
"cols": cols,
|
||||
"id": id,
|
||||
"status": "created",
|
||||
"command": command,
|
||||
"timeout": timeout.String(),
|
||||
"rows": rows,
|
||||
"cols": cols,
|
||||
"notify_mode": notifyModeString(watch),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// notifyModeString 输出可读的通知模式描述。
|
||||
func notifyModeString(w terminalWatch) string {
|
||||
var parts []string
|
||||
if w.onExit {
|
||||
parts = append(parts, "exit")
|
||||
}
|
||||
if w.interval > 0 {
|
||||
parts = append(parts, "interval="+w.interval.String())
|
||||
}
|
||||
if w.bufferBytes > 0 {
|
||||
parts = append(parts, fmt.Sprintf("buffer=%d", w.bufferBytes))
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "quiet"
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
func (p *Plugin) handleWrite(s *sdk.PluginSDK, args map[string]interface{}) (interface{}, error) {
|
||||
id, _ := args["id"].(string)
|
||||
if id == "" {
|
||||
@ -455,13 +564,49 @@ func (p *Plugin) handleWrite(s *sdk.PluginSDK, args map[string]interface{}) (int
|
||||
}, nil
|
||||
}
|
||||
|
||||
// parseNotifyMode 解析 notify 参数并合并进 watch。
|
||||
// 支持:exit / quiet / interval=时长 / buffer=字节数,逗号分隔组合。
|
||||
func parseNotifyMode(s string, base terminalWatch) terminalWatch {
|
||||
w := base
|
||||
for _, part := range strings.Split(s, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
kv := strings.SplitN(part, "=", 2)
|
||||
key := strings.TrimSpace(kv[0])
|
||||
val := ""
|
||||
if len(kv) == 2 {
|
||||
val = strings.TrimSpace(kv[1])
|
||||
}
|
||||
switch key {
|
||||
case "exit":
|
||||
w.onExit = true
|
||||
w.quiet = false
|
||||
case "quiet", "silent":
|
||||
w.quiet = true
|
||||
case "interval":
|
||||
if d, err := time.ParseDuration(val); err == nil && d > 0 {
|
||||
w.interval = d
|
||||
}
|
||||
case "buffer":
|
||||
var n int
|
||||
if _, err := fmt.Sscanf(val, "%d", &n); err == nil && n > 0 {
|
||||
w.bufferBytes = n
|
||||
}
|
||||
}
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
func (p *Plugin) handleRead(args map[string]interface{}) (interface{}, error) {
|
||||
id, _ := args["id"].(string)
|
||||
if id == "" {
|
||||
return map[string]interface{}{"error": "id is required"}, nil
|
||||
}
|
||||
|
||||
clear := true
|
||||
mode, _ := args["mode"].(string)
|
||||
clear := mode != "now"
|
||||
if v, ok := args["clear"].(bool); ok {
|
||||
clear = v
|
||||
}
|
||||
@ -474,19 +619,29 @@ func (p *Plugin) handleRead(args map[string]interface{}) (interface{}, error) {
|
||||
}
|
||||
|
||||
var output string
|
||||
session.mu.Lock()
|
||||
if clear {
|
||||
output = session.ReadAndClearOutput()
|
||||
output = session.buf.String()
|
||||
session.buf.Reset()
|
||||
// 实时画面推流缓冲同步清空,避免 terminal_output 事件与读取结果重复
|
||||
session.stream.Reset()
|
||||
} else {
|
||||
output = session.ReadOutput()
|
||||
output = session.buf.String()
|
||||
}
|
||||
session.mu.Unlock()
|
||||
|
||||
if output == "" {
|
||||
output = "[终端无新输出]"
|
||||
if mode == "now" {
|
||||
output = "[终端当前无屏幕内容]"
|
||||
} else {
|
||||
output = "[终端无新输出]"
|
||||
}
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"status": "ok",
|
||||
"terminal": id,
|
||||
"mode": mode,
|
||||
"output": output,
|
||||
"running": terminalRunning(session),
|
||||
"uptime": time.Since(session.createdAt).String(),
|
||||
@ -550,6 +705,55 @@ func (p *Plugin) handleClose(args map[string]interface{}) (interface{}, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) handleWatch(args map[string]interface{}) (interface{}, error) {
|
||||
id, _ := args["id"].(string)
|
||||
if id == "" {
|
||||
return map[string]interface{}{"error": "id is required"}, nil
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
session, ok := p.sessions[id]
|
||||
p.mu.Unlock()
|
||||
if !ok {
|
||||
return map[string]interface{}{"error": fmt.Sprintf("终端 %s 不存在或已关闭", id)}, nil
|
||||
}
|
||||
|
||||
session.mu.Lock()
|
||||
if v, ok := args["clear"].(bool); ok && v {
|
||||
session.watch = terminalWatch{onExit: true}
|
||||
} else {
|
||||
if v, ok := args["interval"].(string); ok && v != "" {
|
||||
if d, err := time.ParseDuration(v); err == nil && d >= 0 {
|
||||
session.watch.interval = d
|
||||
}
|
||||
}
|
||||
if v, ok := args["on_exit"].(bool); ok {
|
||||
session.watch.onExit = v
|
||||
}
|
||||
if v, ok := args["buffer_bytes"].(float64); ok && v >= 0 {
|
||||
session.watch.bufferBytes = int(v)
|
||||
}
|
||||
if v, ok := args["quiet"].(bool); ok {
|
||||
session.watch.quiet = v
|
||||
}
|
||||
if session.watch.interval == 0 && session.watch.bufferBytes == 0 && !session.watch.quiet {
|
||||
session.watch.onExit = true
|
||||
}
|
||||
}
|
||||
w := session.watch
|
||||
session.mu.Unlock()
|
||||
|
||||
log.Printf("[agentcli] watch updated for %s: %+v", id, w)
|
||||
return map[string]interface{}{
|
||||
"status": "ok",
|
||||
"terminal": id,
|
||||
"interval": w.interval.String(),
|
||||
"on_exit": w.onExit,
|
||||
"buffer_bytes": w.bufferBytes,
|
||||
"quiet": w.quiet,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) handleList() (interface{}, error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
@ -571,6 +775,7 @@ func (p *Plugin) handleList() (interface{}, error) {
|
||||
}
|
||||
terms = append(terms, termInfo{
|
||||
ID: t.id,
|
||||
Command: t.command,
|
||||
Uptime: time.Since(t.createdAt).Round(time.Second).String(),
|
||||
ExpiresIn: remaining.Round(time.Second).String(),
|
||||
Running: running,
|
||||
@ -598,9 +803,24 @@ func (p *Plugin) readLoop(t *TerminalSession, s *sdk.PluginSDK) {
|
||||
readCh := make(chan readResult, 4)
|
||||
go p.reader(t, buf, readCh)
|
||||
|
||||
// 实时画面推流 ticker:每 200ms 批量发布一次 terminal_output 事件
|
||||
flushTicker := time.NewTicker(200 * time.Millisecond)
|
||||
defer flushTicker.Stop()
|
||||
|
||||
// 立即发送首次"终端已启动"通知,让 agent 感知存在
|
||||
s.InjectText("agentcli", "agentcli", fmt.Sprintf("[终端 %s 已启动]", t.id))
|
||||
t.lastNotify = time.Now()
|
||||
now := time.Now()
|
||||
t.mu.Lock()
|
||||
t.lastNotify = now
|
||||
t.lastData = now
|
||||
t.lastFeedback = now
|
||||
t.mu.Unlock()
|
||||
|
||||
// 硬上限:未读输出积累达到该值也通知一次(防大输出静默丢失),频率极低
|
||||
hardNotifyBytes := 64 * 1024
|
||||
hardNotifyInterval := 10 * time.Second
|
||||
// 输出停止判定:超过该时长无新数据则视为输出停止
|
||||
quietLatency := 2 * time.Second
|
||||
|
||||
for {
|
||||
if t.IsExpired() {
|
||||
@ -613,16 +833,52 @@ func (p *Plugin) readLoop(t *TerminalSession, s *sdk.PluginSDK) {
|
||||
}
|
||||
|
||||
if !terminalRunning(t) {
|
||||
s.InjectText("agentcli", "agentcli", fmt.Sprintf("[终端 %s 中的进程已退出]", t.id))
|
||||
if t.watch.onExit {
|
||||
s.InjectText("agentcli", "agentcli", fmt.Sprintf("[终端 %s 中的命令已执行结束]", t.id))
|
||||
} else {
|
||||
s.InjectText("agentcli", "agentcli", fmt.Sprintf("[终端 %s 中的进程已退出]", t.id))
|
||||
}
|
||||
p.mu.Lock()
|
||||
delete(p.sessions, t.id)
|
||||
p.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
// 固定时间反馈:watch.interval > 0 时每隔该时长主动反馈一次状态摘要
|
||||
t.mu.Lock()
|
||||
if t.watch.interval > 0 && time.Since(t.lastFeedback) >= t.watch.interval {
|
||||
t.lastFeedback = time.Now()
|
||||
t.lastNotify = t.lastFeedback
|
||||
unread := t.unreadBytes
|
||||
t.unreadBytes = 0
|
||||
preview := previewTail(t.buf.String(), 120)
|
||||
t.mu.Unlock()
|
||||
s.InjectText("agentcli", "agentcli",
|
||||
fmt.Sprintf("[终端 %s 定时反馈: 运行中, 期间新输出约 %d 字节]\n%s", t.id, unread, preview))
|
||||
continue
|
||||
}
|
||||
t.mu.Unlock()
|
||||
|
||||
select {
|
||||
case <-t.stopCh:
|
||||
return
|
||||
case <-flushTicker.C:
|
||||
// 批量推送终端实时画面增量(独立 ticker,避免被高密度数据饿死)
|
||||
var streamData string
|
||||
t.mu.Lock()
|
||||
if t.stream.Len() > 0 {
|
||||
streamData = t.stream.String()
|
||||
t.stream.Reset()
|
||||
}
|
||||
t.mu.Unlock()
|
||||
if streamData != "" {
|
||||
s.Publish(&sdk.Event{
|
||||
Type: sdk.EventTerminalOutput,
|
||||
Source: "agentcli",
|
||||
Payload: map[string]interface{}{"terminal_id": t.id, "output": streamData, "running": terminalRunning(t)},
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
})
|
||||
}
|
||||
case r := <-readCh:
|
||||
if r.err != nil {
|
||||
// 读取错误/EOF → 立即通知(进程可能已结束)
|
||||
@ -634,32 +890,66 @@ func (p *Plugin) readLoop(t *TerminalSession, s *sdk.PluginSDK) {
|
||||
copy(data, buf[:r.n])
|
||||
t.appendOutput(data)
|
||||
|
||||
// 语义通知:累积未读字节数
|
||||
// 缓冲阈值通知(仅当 agent 显式选择 buffer 模式,或未读积累达到硬上限)。
|
||||
// 默认模式(仅 exit 提醒)下不随输出流通知,杜绝通知风暴。
|
||||
t.mu.Lock()
|
||||
t.lastData = time.Now()
|
||||
t.unreadBytes += r.n
|
||||
needNotify := t.unreadBytes >= p.notifyBytes ||
|
||||
time.Since(t.lastNotify) >= p.notifyInterval
|
||||
t.mu.Unlock()
|
||||
|
||||
if needNotify {
|
||||
t.mu.Lock()
|
||||
preview := t.buf.String()
|
||||
if len(preview) > 200 {
|
||||
preview = preview[len(preview)-200:] // 取最新 200 字符
|
||||
bufThr := t.watch.bufferBytes
|
||||
if bufThr <= 0 {
|
||||
bufThr = p.notifyBytes
|
||||
}
|
||||
minInterval := p.notifyInterval
|
||||
if t.watch.interval > 0 {
|
||||
minInterval = t.watch.interval
|
||||
}
|
||||
// 风暴退避:距上次通知不足 1s 说明输出极速,通知间隔翻倍(上限 30s)
|
||||
if time.Since(t.lastNotify) < time.Second && t.unreadBytes >= bufThr {
|
||||
if t.backoff == 0 {
|
||||
t.backoff = minInterval
|
||||
} else if t.backoff < 30*time.Second {
|
||||
t.backoff *= 2
|
||||
if t.backoff > 30*time.Second {
|
||||
t.backoff = 30 * time.Second
|
||||
}
|
||||
}
|
||||
preview = sanitizePreview(preview)
|
||||
t.unreadBytes = 0
|
||||
}
|
||||
interval := t.backoff + minInterval
|
||||
isHard := t.watch.bufferBytes <= 0 && t.unreadBytes >= hardNotifyBytes
|
||||
if isHard && hardNotifyInterval > interval {
|
||||
interval = hardNotifyInterval
|
||||
}
|
||||
need := t.unreadBytes >= bufThr && time.Since(t.lastNotify) >= interval
|
||||
if need {
|
||||
t.lastNotify = time.Now()
|
||||
t.unreadBytes = 0
|
||||
preview := previewTail(t.buf.String(), 200)
|
||||
t.mu.Unlock()
|
||||
s.InjectText("agentcli", "agentcli",
|
||||
fmt.Sprintf("[终端 %s 有新输出]\n%s", t.id, preview))
|
||||
} else {
|
||||
t.mu.Unlock()
|
||||
|
||||
s.InjectText("agentcli", "agentcli", fmt.Sprintf("[终端 %s 有新输出]\n%s", t.id, preview))
|
||||
}
|
||||
}
|
||||
case <-time.After(pollInterval):
|
||||
// 空闲轮询:输出已停止时复位退避
|
||||
t.mu.Lock()
|
||||
if t.backoff > 0 && time.Since(t.lastData) >= quietLatency {
|
||||
t.backoff = 0
|
||||
}
|
||||
t.mu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// previewTail 返回 s 末尾最多 n 字符,并转义控制字符保证可读。
|
||||
func previewTail(s string, n int) string {
|
||||
if len(s) > n {
|
||||
s = s[len(s)-n:]
|
||||
}
|
||||
return sanitizePreview(s)
|
||||
}
|
||||
|
||||
type readResult struct {
|
||||
n int
|
||||
err error
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -8,208 +8,31 @@ import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/ptywin"
|
||||
)
|
||||
|
||||
var (
|
||||
kernel32 = syscall.NewLazyDLL("kernel32.dll")
|
||||
|
||||
procCreatePseudoConsole = kernel32.NewProc("CreatePseudoConsole")
|
||||
procResizePseudoConsole = kernel32.NewProc("ResizePseudoConsole")
|
||||
procClosePseudoConsole = kernel32.NewProc("ClosePseudoConsole")
|
||||
procInitializeProcThreadAttributeList = kernel32.NewProc("InitializeProcThreadAttributeList")
|
||||
procUpdateProcThreadAttribute = kernel32.NewProc("UpdateProcThreadAttribute")
|
||||
procDeleteProcThreadAttributeList = kernel32.NewProc("DeleteProcThreadAttributeList")
|
||||
procCreateProcessW = kernel32.NewProc("CreateProcessW")
|
||||
procGetExitCodeProcess = kernel32.NewProc("GetExitCodeProcess")
|
||||
procTerminateProcess = kernel32.NewProc("TerminateProcess")
|
||||
procCloseHandle = kernel32.NewProc("CloseHandle")
|
||||
)
|
||||
|
||||
const (
|
||||
procThreadAttributePseudoConsole = 0x16 // PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE (22)
|
||||
extendedStartupinfoPresent = 0x00080000
|
||||
createUnicodeEnvironment = 0x00000400
|
||||
stillActive = 259 // STILL_ACTIVE
|
||||
)
|
||||
|
||||
type coord struct {
|
||||
x int16
|
||||
y int16
|
||||
}
|
||||
|
||||
type processInformation struct {
|
||||
process syscall.Handle
|
||||
thread syscall.Handle
|
||||
pid uint32
|
||||
tid uint32
|
||||
}
|
||||
|
||||
// startupInfoEx 对应 STARTUPINFOEXW:STARTUPINFOW 之后追加 attribute list 指针。
|
||||
type startupInfoEx struct {
|
||||
cb uint32
|
||||
lpReserved *uint16
|
||||
lpDesktop *uint16
|
||||
lpTitle *uint16
|
||||
dwX uint32
|
||||
dwY uint32
|
||||
dwXSize uint32
|
||||
dwYSize uint32
|
||||
dwXCountChars uint32
|
||||
dwYCountChars uint32
|
||||
dwFillAttribute uint32
|
||||
dwFlags uint32
|
||||
wShowWindow uint16
|
||||
cbReserved2 uint16
|
||||
lpReserved2 *byte
|
||||
hStdInput syscall.Handle
|
||||
hStdOutput syscall.Handle
|
||||
hStdErr syscall.Handle
|
||||
lpAttributeList uintptr
|
||||
}
|
||||
|
||||
func defaultShell() string { return "cmd.exe" }
|
||||
|
||||
// windowsPty 基于 Windows ConPTY(Pseudo Console)的终端后端。
|
||||
//
|
||||
// ConPTY 通过 CreatePseudoConsole 创建伪控制台,子进程以
|
||||
// PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE 挂到伪控制台。宿主侧使用两根
|
||||
// 管道与伪控制台通信:我们写 inW(输入)、读 outR(输出)。
|
||||
// windowsPty 基于 internal/ptywin(ConPTY)的终端后端。
|
||||
type windowsPty struct {
|
||||
hpc syscall.Handle // 伪控制台句柄
|
||||
inW *os.File // 我们向伪控制台写输入
|
||||
outR *os.File // 我们读伪控制台输出
|
||||
proc syscall.Handle // 子进程句柄
|
||||
procID int
|
||||
c *ptywin.ConPty
|
||||
cmd *exec.Cmd
|
||||
attrList []byte
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
// newCommandPty 创建 ConPTY 并在其上运行命令(cmd.exe /c <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,28 +65,15 @@ func (p *windowsPty) Kill() error {
|
||||
func (p *windowsPty) Close() error {
|
||||
var errs []string
|
||||
p.closeOnce.Do(func() {
|
||||
if p.inW != nil {
|
||||
if err := p.inW.Close(); err != nil {
|
||||
if p.c != nil {
|
||||
if err := p.c.Close(); err != nil {
|
||||
errs = append(errs, err.Error())
|
||||
}
|
||||
p.c = nil
|
||||
}
|
||||
if p.outR != nil {
|
||||
if err := p.outR.Close(); err != nil {
|
||||
errs = append(errs, err.Error())
|
||||
}
|
||||
}
|
||||
if p.hpc != 0 {
|
||||
procClosePseudoConsole.Call(uintptr(p.hpc))
|
||||
p.hpc = 0
|
||||
}
|
||||
if len(p.attrList) > 0 {
|
||||
procDeleteProcThreadAttributeList.Call(uintptr(unsafe.Pointer(&p.attrList[0])))
|
||||
p.attrList = nil
|
||||
}
|
||||
_ = p.Kill()
|
||||
})
|
||||
if len(errs) > 0 {
|
||||
return fmt.Errorf("close: %s", strings.Join(errs, "; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
65
internal/plugins/agentcli/pty_windows_test.go
Normal file
65
internal/plugins/agentcli/pty_windows_test.go
Normal 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
|
||||
}
|
||||
@ -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
|
||||
})
|
||||
|
||||
@ -159,16 +159,19 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
// Windows 上预置 chcp 65001 确保控制台输出为 UTF-8,避免 GBK 乱码
|
||||
execCmd := command
|
||||
// Windows 上必须经 cmd.exe /c 执行(chcp 65001 预置为 UTF-8 输出),
|
||||
// 直接 exec 会把整条命令当成一个程序路径导致所有命令失败。
|
||||
var cmd *exec.Cmd
|
||||
if isWindows {
|
||||
execCmd = "chcp 65001>nul & " + command
|
||||
execCmd := "chcp 65001>nul & " + command
|
||||
cmd = exec.CommandContext(ctx, "cmd.exe", "/d", "/c", execCmd)
|
||||
} else {
|
||||
parts := shellUnquote(command)
|
||||
if len(parts) == 0 {
|
||||
return map[string]interface{}{"error": "command is required"}, nil
|
||||
}
|
||||
cmd = exec.CommandContext(ctx, parts[0], parts[1:]...)
|
||||
}
|
||||
parts := shellUnquote(execCmd)
|
||||
if len(parts) == 0 {
|
||||
return map[string]interface{}{"error": "command is required"}, nil
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, parts[0], parts[1:]...)
|
||||
if workdir != "" {
|
||||
cmd.Dir = workdir
|
||||
}
|
||||
|
||||
@ -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
@ -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 · 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
|
||||
@ -63,11 +92,21 @@ type Handler struct {
|
||||
}
|
||||
|
||||
type ChatMsg struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
ReasoningContent string `json:"reasoning_content,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Time string `json:"time"`
|
||||
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 {
|
||||
@ -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()
|
||||
h.termStates[id] = term
|
||||
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:]
|
||||
}
|
||||
// 只持 latest 50 条做持久化(写放大防护),全量仍保留在内存
|
||||
if h.settings != nil {
|
||||
persistLen := len(h.chatHistory)
|
||||
if persistLen > 50 {
|
||||
persistLen = 50
|
||||
drop := len(h.chatHistory) - maxChatHistory
|
||||
h.chatHistory = h.chatHistory[drop:]
|
||||
if h.pendingIdx >= 0 {
|
||||
h.pendingIdx -= drop
|
||||
if h.pendingIdx < 0 {
|
||||
h.pendingIdx = -1
|
||||
}
|
||||
}
|
||||
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)
|
||||
|
||||
@ -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')")
|
||||
}
|
||||
})
|
||||
|
||||
@ -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
389
internal/ptywin/conpty.go
Normal 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
|
||||
}
|
||||
@ -16,5 +16,6 @@ const (
|
||||
EventReasoning = events.EventReasoning
|
||||
EventStage = events.EventStage
|
||||
EventSystem = events.EventSystem
|
||||
EventTerminalOutput = events.EventTerminalOutput
|
||||
EventAll = events.EventAll
|
||||
)
|
||||
|
||||
@ -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
50
plan.md
@ -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 4:Pipeline Distiller 行为对齐文档(可选,低优)✅ **计划中**
|
||||
### Phase 4:Pipeline 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` bash(PID 3716282),验证 QQ 消息恢复响应。
|
||||
- [x] 单测:mock ptyTerm + capture IOInjector 三用例——`TestReadLoopNotifyThrottle`(3s 持续 2KB/s 吐进度仅 ≤3 条通知,远低于 500ms/条的 6 条)、`TestReadLoopNotifyOnExit`(进程退出立即通知)、`TestReadLoopNotifyOnReadError`(读取错误立即通知)全绿。
|
||||
- [ ] 运维止血:杀掉残留 `term_3` bash(PID 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 tokens(sakura/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/构建链
|
||||
|
||||
Reference in New Issue
Block a user