feat: output channel redesign - per-channel output gates, LLM chain events, SDKConfig

- Output channels generate per-channel tools: output_send__{name} (type=output) + output_send__{name}_help
- content is JSON string transparently passed to plugin handler for routing
- EventAgentLLMChain: full LLM response forwarded after each turn for webui/logs
- sdk.New refactored to SDKConfig struct (no more 13 positional args)
- RegisterOutputChannel adds desc param for JSON format documentation
- channelDevice simplified (no Tools method), desc field added
- Child agent permission updated for output_send__ prefix
- System prompt: output gates, multi-call, long messages split
- WebUI: subscribes to EventAgentLLMChain in SSE, no output channel
- Tests updated for new naming convention
This commit is contained in:
root
2026-07-16 12:11:16 +08:00
parent a7cd92d24c
commit 4d619bf89c
45 changed files with 3292 additions and 423 deletions

103
gui/main.js Normal file
View File

@ -0,0 +1,103 @@
const { app, BrowserWindow, ipcMain, dialog } = require('electron');
const path = require('path');
const fs = require('fs');
const CONNECTIONS_FILE = path.join(app.getPath('userData'), 'connections.json');
function loadConnections() {
try {
if (fs.existsSync(CONNECTIONS_FILE)) {
return JSON.parse(fs.readFileSync(CONNECTIONS_FILE, 'utf-8'));
}
} catch (e) {
console.error('Failed to load connections:', e);
}
return { connections: [], currentId: null };
}
function saveConnections(data) {
try {
fs.writeFileSync(CONNECTIONS_FILE, JSON.stringify(data, null, 2), 'utf-8');
} catch (e) {
console.error('Failed to save connections:', e);
}
}
let mainWindow;
function createWindow() {
mainWindow = new BrowserWindow({
width: 1280,
height: 860,
minWidth: 900,
minHeight: 600,
title: 'HomeAgent',
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
},
});
mainWindow.loadFile(path.join(__dirname, 'renderer', 'index.html'));
if (process.argv.includes('--dev')) {
mainWindow.webContents.openDevTools();
}
mainWindow.on('closed', () => {
mainWindow = null;
});
}
ipcMain.handle('connections:list', () => {
return loadConnections();
});
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 });
if (!data.currentId) data.currentId = id;
saveConnections(data);
return data;
});
ipcMain.handle('connections:update', (_, { id, updates }) => {
const data = loadConnections();
const idx = data.connections.findIndex(c => c.id === id);
if (idx !== -1) {
data.connections[idx] = { ...data.connections[idx], ...updates };
saveConnections(data);
}
return data;
});
ipcMain.handle('connections:delete', (_, id) => {
const data = loadConnections();
data.connections = data.connections.filter(c => c.id !== id);
if (data.currentId === id) {
data.currentId = data.connections.length > 0 ? data.connections[0].id : null;
}
saveConnections(data);
return data;
});
ipcMain.handle('connections:setCurrent', (_, id) => {
const data = loadConnections();
if (data.connections.some(c => c.id === id)) {
data.currentId = id;
saveConnections(data);
}
return data;
});
app.whenReady().then(createWindow);
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});
app.on('activate', () => {
if (mainWindow === null) createWindow();
});

801
gui/package-lock.json generated Normal file
View File

@ -0,0 +1,801 @@
{
"name": "homeagent-gui",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "homeagent-gui",
"version": "1.0.0",
"dependencies": {
"electron": "^33.0.0"
}
},
"node_modules/@electron/get": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz",
"integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==",
"license": "MIT",
"dependencies": {
"debug": "^4.1.1",
"env-paths": "^2.2.0",
"fs-extra": "^8.1.0",
"got": "^11.8.5",
"progress": "^2.0.3",
"semver": "^6.2.0",
"sumchecker": "^3.0.1"
},
"engines": {
"node": ">=12"
},
"optionalDependencies": {
"global-agent": "^3.0.0"
}
},
"node_modules/@sindresorhus/is": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz",
"integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sindresorhus/is?sponsor=1"
}
},
"node_modules/@szmarczak/http-timer": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz",
"integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==",
"license": "MIT",
"dependencies": {
"defer-to-connect": "^2.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@types/cacheable-request": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz",
"integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==",
"license": "MIT",
"dependencies": {
"@types/http-cache-semantics": "*",
"@types/keyv": "^3.1.4",
"@types/node": "*",
"@types/responselike": "^1.0.0"
}
},
"node_modules/@types/http-cache-semantics": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
"integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==",
"license": "MIT"
},
"node_modules/@types/keyv": {
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz",
"integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/node": {
"version": "20.19.43",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
"integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/responselike": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz",
"integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/yauzl": {
"version": "2.10.3",
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
"integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==",
"license": "MIT",
"optional": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/boolean": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz",
"integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==",
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
"license": "MIT",
"optional": true
},
"node_modules/buffer-crc32": {
"version": "0.2.13",
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
"integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/cacheable-lookup": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz",
"integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==",
"license": "MIT",
"engines": {
"node": ">=10.6.0"
}
},
"node_modules/cacheable-request": {
"version": "7.0.4",
"resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz",
"integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==",
"license": "MIT",
"dependencies": {
"clone-response": "^1.0.2",
"get-stream": "^5.1.0",
"http-cache-semantics": "^4.0.0",
"keyv": "^4.0.0",
"lowercase-keys": "^2.0.0",
"normalize-url": "^6.0.1",
"responselike": "^2.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/clone-response": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz",
"integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==",
"license": "MIT",
"dependencies": {
"mimic-response": "^1.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"license": "MIT",
"dependencies": {
"mimic-response": "^3.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/decompress-response/node_modules/mimic-response": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/defer-to-connect": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz",
"integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/define-data-property": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
"license": "MIT",
"optional": true,
"dependencies": {
"es-define-property": "^1.0.0",
"es-errors": "^1.3.0",
"gopd": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/define-properties": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
"integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
"license": "MIT",
"optional": true,
"dependencies": {
"define-data-property": "^1.0.1",
"has-property-descriptors": "^1.0.0",
"object-keys": "^1.1.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/detect-node": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz",
"integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==",
"license": "MIT",
"optional": true
},
"node_modules/electron": {
"version": "33.4.11",
"resolved": "https://registry.npmjs.org/electron/-/electron-33.4.11.tgz",
"integrity": "sha512-xmdAs5QWRkInC7TpXGNvzo/7exojubk+72jn1oJL7keNeIlw7xNglf8TGtJtkR4rWC5FJq0oXiIXPS9BcK2Irg==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"@electron/get": "^2.0.0",
"@types/node": "^20.9.0",
"extract-zip": "^2.0.1"
},
"bin": {
"electron": "cli.js"
},
"engines": {
"node": ">= 12.20.55"
}
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"license": "MIT",
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/env-paths": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
"integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es6-error": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz",
"integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==",
"license": "MIT",
"optional": true
},
"node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/extract-zip": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
"integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==",
"license": "BSD-2-Clause",
"dependencies": {
"debug": "^4.1.1",
"get-stream": "^5.1.0",
"yauzl": "^2.10.0"
},
"bin": {
"extract-zip": "cli.js"
},
"engines": {
"node": ">= 10.17.0"
},
"optionalDependencies": {
"@types/yauzl": "^2.9.1"
}
},
"node_modules/fd-slicer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
"integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==",
"license": "MIT",
"dependencies": {
"pend": "~1.2.0"
}
},
"node_modules/fs-extra": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
"integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==",
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^4.0.0",
"universalify": "^0.1.0"
},
"engines": {
"node": ">=6 <7 || >=8"
}
},
"node_modules/get-stream": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
"integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==",
"license": "MIT",
"dependencies": {
"pump": "^3.0.0"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/global-agent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz",
"integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==",
"license": "BSD-3-Clause",
"optional": true,
"dependencies": {
"boolean": "^3.0.1",
"es6-error": "^4.1.1",
"matcher": "^3.0.0",
"roarr": "^2.15.3",
"semver": "^7.3.2",
"serialize-error": "^7.0.1"
},
"engines": {
"node": ">=10.0"
}
},
"node_modules/global-agent/node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"optional": true,
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/globalthis": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz",
"integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"define-properties": "^1.2.1",
"gopd": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/got": {
"version": "11.8.6",
"resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz",
"integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==",
"license": "MIT",
"dependencies": {
"@sindresorhus/is": "^4.0.0",
"@szmarczak/http-timer": "^4.0.5",
"@types/cacheable-request": "^6.0.1",
"@types/responselike": "^1.0.0",
"cacheable-lookup": "^5.0.3",
"cacheable-request": "^7.0.2",
"decompress-response": "^6.0.0",
"http2-wrapper": "^1.0.0-beta.5.2",
"lowercase-keys": "^2.0.0",
"p-cancelable": "^2.0.0",
"responselike": "^2.0.0"
},
"engines": {
"node": ">=10.19.0"
},
"funding": {
"url": "https://github.com/sindresorhus/got?sponsor=1"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC"
},
"node_modules/has-property-descriptors": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
"license": "MIT",
"optional": true,
"dependencies": {
"es-define-property": "^1.0.0"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/http-cache-semantics": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
"integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==",
"license": "BSD-2-Clause"
},
"node_modules/http2-wrapper": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz",
"integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==",
"license": "MIT",
"dependencies": {
"quick-lru": "^5.1.1",
"resolve-alpn": "^1.0.0"
},
"engines": {
"node": ">=10.19.0"
}
},
"node_modules/json-buffer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
"integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
"license": "MIT"
},
"node_modules/json-stringify-safe": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
"integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==",
"license": "ISC",
"optional": true
},
"node_modules/jsonfile": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
"integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
"license": "MIT",
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
"integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
"license": "MIT",
"dependencies": {
"json-buffer": "3.0.1"
}
},
"node_modules/lowercase-keys": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz",
"integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/matcher": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz",
"integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==",
"license": "MIT",
"optional": true,
"dependencies": {
"escape-string-regexp": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/mimic-response": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz",
"integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/normalize-url": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz",
"integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/object-keys": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">= 0.4"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/p-cancelable": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz",
"integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/pend": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
"integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
"license": "MIT"
},
"node_modules/progress": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
"integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/pump": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/quick-lru": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz",
"integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/resolve-alpn": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz",
"integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==",
"license": "MIT"
},
"node_modules/responselike": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz",
"integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==",
"license": "MIT",
"dependencies": {
"lowercase-keys": "^2.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/roarr": {
"version": "2.15.4",
"resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz",
"integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==",
"license": "BSD-3-Clause",
"optional": true,
"dependencies": {
"boolean": "^3.0.1",
"detect-node": "^2.0.4",
"globalthis": "^1.0.1",
"json-stringify-safe": "^5.0.1",
"semver-compare": "^1.0.0",
"sprintf-js": "^1.1.2"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
}
},
"node_modules/semver-compare": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz",
"integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==",
"license": "MIT",
"optional": true
},
"node_modules/serialize-error": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz",
"integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==",
"license": "MIT",
"optional": true,
"dependencies": {
"type-fest": "^0.13.1"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/sprintf-js": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
"integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==",
"license": "BSD-3-Clause",
"optional": true
},
"node_modules/sumchecker": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz",
"integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==",
"license": "Apache-2.0",
"dependencies": {
"debug": "^4.1.0"
},
"engines": {
"node": ">= 8.0"
}
},
"node_modules/type-fest": {
"version": "0.13.1",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz",
"integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==",
"license": "(MIT OR CC0-1.0)",
"optional": true,
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"license": "MIT"
},
"node_modules/universalify": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
"integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
"license": "MIT",
"engines": {
"node": ">= 4.0.0"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/yauzl": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
"integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==",
"license": "MIT",
"dependencies": {
"buffer-crc32": "~0.2.3",
"fd-slicer": "~1.1.0"
}
}
}
}

13
gui/package.json Normal file
View File

@ -0,0 +1,13 @@
{
"name": "homeagent-gui",
"version": "1.0.0",
"description": "HomeAgent Desktop GUI - Multi-connection management dashboard",
"main": "main.js",
"scripts": {
"start": "electron . --no-sandbox",
"dev": "electron . --no-sandbox --dev"
},
"dependencies": {
"electron": "^33.0.0"
}
}

11
gui/preload.js Normal file
View File

@ -0,0 +1,11 @@
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('homeagent', {
connections: {
list: () => ipcRenderer.invoke('connections:list'),
add: (conn) => ipcRenderer.invoke('connections:add', conn),
update: (id, updates) => ipcRenderer.invoke('connections:update', { id, updates }),
delete: (id) => ipcRenderer.invoke('connections:delete', id),
setCurrent: (id) => ipcRenderer.invoke('connections:setCurrent', id),
},
});

713
gui/renderer/app.js Normal file
View File

@ -0,0 +1,713 @@
// ===== State =====
let state = {
connections: [], currentConn: null,
status: {}, kernel: null,
settings: {}, meta: {}, pluginMeta: {}, settingsPlugins: ['core'],
selectedSection: 'core',
messages: [], chatLoading: false, chatStage: '',
installedPlugins: [], pluginInfo: null,
startedAt: null, uptimeTick: null, sidebarRefreshTick: null, eventSource: null,
_chatHistoryLoaded: false, _starmapData: null,
};
// ===== Markdown Renderer (lightweight, no dependencies) =====
function renderMarkdown(t) {
if (!t) return '';
let s = String(t)
.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
// code blocks (fenced)
s = s.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code>$2</code></pre>');
// inline code
s = s.replace(/`([^`]+)`/g, '<code>$1</code>');
// headers
s = s.replace(/^### (.+)$/gm, '<h3>$1</h3>');
s = s.replace(/^## (.+)$/gm, '<h2>$1</h2>');
s = s.replace(/^# (.+)$/gm, '<h1>$1</h1>');
// bold & italic
s = s.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>');
s = s.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
s = s.replace(/\*(.+?)\*/g, '<em>$1</em>');
// links
s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank">$1</a>');
// images
s = s.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" style="max-width:100%">');
// blockquote
s = s.replace(/^> (.+)$/gm, '<blockquote>$1</blockquote>');
// horizontal rule
s = s.replace(/^---$/gm, '<hr>');
// unordered list
s = s.replace(/^[\s]*[-*] (.+)$/gm, '<li>$1</li>');
s = s.replace(/(<li>.*<\/li>\n?)+/g, '<ul>$&</ul>');
// ordered list
s = s.replace(/^[\s]*\d+\. (.+)$/gm, '<li>$1</li>');
// paragraphs: double newlines
s = s.replace(/\n\n/g, '</p><p>');
s = '<p>' + s + '</p>';
// clean nested ps from lists
s = s.replace(/<\/p>\n?<ul>/g, '<ul>').replace(/<\/ul>\n?<p>/g, '</ul>');
s = s.replace(/<\/p>\n?<li>/g, '<li>').replace(/<\/li>\n?<p>/g, '</li>');
s = s.replace(/<p><\/p>/g, '');
return s;
}
// ===== Connection Management =====
async function initApp() {
const data = await window.homeagent.connections.list();
state.connections = data.connections || [];
if (data.currentId) state.currentConn = state.connections.find(c => c.id === data.currentId) || null;
if (state.currentConn) {
document.getElementById('app').style.display = 'block';
document.getElementById('conn-overlay').style.display = 'none';
updateConnIndicator();
await renderAll();
startUptimeTicker(); startSidebarRefresh(); connectSSE();
} else {
document.getElementById('conn-overlay').style.display = 'flex';
}
renderConnList();
}
function updateConnIndicator() {
const el = document.getElementById('conn-name-display');
const dot = document.getElementById('conn-dot');
if (state.currentConn) {
el.textContent = state.currentConn.name;
dot.className = 'status-dot ' + (state.status.status === 'running' ? 'dot-green' : 'dot-yellow');
} else { el.textContent = '未连接'; dot.className = 'status-dot dot-gray'; }
}
function openConnManager() { renderConnList(); document.getElementById('conn-overlay').style.display = 'flex'; }
async function selectConnection(id) {
disconnectSSE();
const data = await window.homeagent.connections.setCurrent(id);
state.currentConn = data.connections.find(c => c.id === id) || null;
state.connections = data.connections;
state.messages = []; state._chatHistoryLoaded = false;
document.getElementById('app').style.display = 'block';
document.getElementById('conn-overlay').style.display = 'none';
updateConnIndicator();
await renderAll();
startUptimeTicker(); startSidebarRefresh(); connectSSE();
}
async function deleteConnection(id, e) {
e.stopPropagation();
if (!confirm('确定删除此连接?')) return;
const wasCurrent = state.currentConn && state.currentConn.id === id;
const data = await window.homeagent.connections.delete(id);
state.connections = data.connections;
state.currentConn = data.currentId ? state.connections.find(c => c.id === data.currentId) : null;
if (wasCurrent) { disconnectSSE(); if (state.sidebarRefreshTick) { clearInterval(state.sidebarRefreshTick); state.sidebarRefreshTick = null; } }
if (state.currentConn) {
updateConnIndicator(); await renderAll(); startSidebarRefresh(); connectSSE();
} else {
document.getElementById('app').style.display = 'none'; document.getElementById('conn-overlay').style.display = 'flex';
}
renderConnList();
}
function renderConnList() {
document.getElementById('conn-list').innerHTML = state.connections.map(c =>
'<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)">编辑</button>'
+ '<button class="btn btn-danger btn-sm" onclick="deleteConnection(\'' + c.id + '\', event)">删除</button></div></div>'
).join('');
}
let editingConnId = null;
function showConnForm() {
editingConnId = null;
document.getElementById('conn-form-title').textContent = '添加连接 / Add Connection';
document.getElementById('conn-name').value = 'My HomeAgent';
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();
const c = state.connections.find(x => 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(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(el => el.style.opacity = '1');
}
async function saveConnForm() {
const name = document.getElementById('conn-name').value.trim();
const url = document.getElementById('conn-url').value.trim().replace(/\/+$/, '');
const apiKey = document.getElementById('conn-key').value.trim();
if (!name || !url) { toast('名称和地址不能为空', true); return; }
// test connection before saving
const testBtn = document.querySelector('#conn-form .btn-primary');
testBtn.textContent = '测试中...'; testBtn.disabled = true;
try {
const testR = await fetch(url + '/api/v1/status', {
headers: apiKey ? { 'X-API-Key': apiKey } : {}
});
if (!testR.ok) { toast('连接测试失败: HTTP ' + testR.status, true); testBtn.textContent = '保存 / Save'; testBtn.disabled = false; return; }
} catch(e) {
toast('无法连接到 ' + url + ': ' + e.message, true);
testBtn.textContent = '保存 / Save'; testBtn.disabled = false; return;
}
testBtn.textContent = '保存 / Save'; testBtn.disabled = false;
let data;
if (editingConnId) {
data = await window.homeagent.connections.update(editingConnId, { name, url, apiKey });
} else {
data = await window.homeagent.connections.add({ name, url, apiKey });
}
state.connections = data.connections;
const curId = data.currentId;
const cur = data.connections.find(c => c.id === curId);
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';
updateConnIndicator(); await renderAll(); startUptimeTicker(); startSidebarRefresh(); connectSSE();
} else { updateConnIndicator(); if (editingConnId) await renderAll(); }
}
cancelConnForm(); renderConnList();
}
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && document.getElementById('conn-form').style.display === 'block') cancelConnForm();
});
// ===== API Client =====
async function api(path, opts = {}) {
if (!state.currentConn) throw new Error('No connection selected');
const headers = { 'Content-Type': 'application/json', ...opts.headers };
if (state.currentConn.apiKey) headers['X-API-Key'] = state.currentConn.apiKey;
const url = state.currentConn.url + '/api/v1' + path;
const res = await fetch(url, { ...opts, headers });
if (res.status === 401) throw new Error('unauthorized');
if (opts.raw) return res;
const ct = res.headers.get('content-type') || '';
if (ct.includes('json')) return res.json();
return res.text();
}
// ===== Toast =====
function toast(msg, isError) {
const t = document.getElementById('toast');
t.textContent = msg; t.className = 'toast' + (isError ? ' error' : ''); t.style.display = 'block';
setTimeout(function() { t.style.display = 'none' }, 3000);
}
// ===== Utility =====
function escHtml(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
function fmtUptime(ms) {
const s = Math.floor(ms / 1000);
if (s < 60) return s + 's'; const m = Math.floor(s / 60); s = s % 60;
if (m < 60) return m + 'm ' + s + 's'; const h = Math.floor(m / 60); m = m % 60;
return h + 'h ' + m + 'm ' + s + 's';
}
// ===== Theme =====
function setTheme(name) {
document.documentElement.setAttribute('data-theme', name);
localStorage.setItem('ha-theme', name);
document.getElementById('theme-btn').textContent = name === 'light' ? '☀️' : '🌙';
}
function toggleTheme() { setTheme(document.documentElement.getAttribute('data-theme') === 'light' ? 'dark' : 'light'); }
(function() { setTheme(localStorage.getItem('ha-theme') || 'dark') })();
// ===== Navigation =====
function switchTab(n) {
document.querySelectorAll('.tab-content').forEach(function(e) { e.classList.remove('active') });
const el = document.getElementById('tab-' + n); if (el) el.classList.add('active');
document.querySelectorAll('nav a').forEach(function(e) { e.classList.remove('active') });
const m = document.querySelector('nav a[onclick*="' + n + '"]'); if (m) m.classList.add('active');
renderAll();
}
// ===== Tab Render Dispatch =====
async function renderAll() {
if (!state.currentConn) return;
try { const s = await api('/status'); state.status = s; state.startedAt = s.startedAt ? new Date(s.startedAt).getTime() : null; updateConnIndicator() } catch(e) {}
try { state.kernel = await api('/kernel') } catch(e) {}
try {
const s = await api('/settings'); state.settings = s.settings || {}; state.meta = s.meta || {};
state.settingsPlugins = s.plugins || ['core']; state.pluginMeta = s.plugin_meta || {};
} catch(e) {}
try { state.installedPlugins = await api('/plugins') } catch(e) {}
try { renderOverview() } catch(e) {} try { renderChat() } catch(e) {} try { renderPlugins() } catch(e) {}
try { renderKernel() } catch(e) {} try { renderOneSettings() } catch(e) {} try { renderAdapters() } catch(e) {}
}
function startUptimeTicker() {
if (state.uptimeTick) clearInterval(state.uptimeTick);
state.uptimeTick = setInterval(function() {
const el = document.querySelector('#uptime-val');
if (el && state.startedAt) el.textContent = fmtUptime(Date.now() - state.startedAt);
}, 1000);
}
// ===== SSE =====
function disconnectSSE() { if (state.eventSource) { state.eventSource.close(); state.eventSource = null; } }
function connectSSE() {
disconnectSSE(); if (!state.currentConn) return;
connectFetchSSE(state.currentConn.url + '/api/v1/chat/events');
}
async function connectFetchSSE(url) {
try {
const headers = {};
if (state.currentConn && state.currentConn.apiKey) headers['X-API-Key'] = state.currentConn.apiKey;
const resp = await fetch(url, { headers, cache: 'no-store' });
if (!resp.ok || !resp.body) { setTimeout(function() { connectSSE() }, 5000); return; }
const reader = resp.body.getReader(); const decoder = new TextDecoder();
let buffer = ''; let reconnectTimer = null;
state.eventSource = { close: function() { reader.cancel(); if (reconnectTimer) clearTimeout(reconnectTimer) } };
function processLines() {
const lines = buffer.split('\n'); buffer = lines.pop() || '';
let eventType = '', data = '';
for (const line of lines) {
if (line.startsWith('event: ')) eventType = line.slice(7).trim();
else if (line.startsWith('data: ')) data = line.slice(6).trim();
else if (line === '' && eventType && data) { handleSSEEvent(eventType, data); eventType = ''; data = ''; }
}
}
function handleSSEEvent(type, raw) {
try {
const p = JSON.parse(raw);
if (type === 'agent_output') {
const last = state.messages[state.messages.length - 1];
if (last && last.role === 'assistant' && last._streaming) {
last.content = (last.content || '') + (p.content || '');
rerenderChatIfActive();
}
} else if (type === 'stage') {
const phase = p.payload?.phase;
if (phase === 'thinking') state.chatStage = 'Thinking...';
else if (phase === 'before_toolcall') state.chatStage = 'Tool: ' + (p.payload?.tool || '');
else if (phase === 'before_output') state.chatStage = 'Output...';
updateChatStageBadge();
} else if (type === 'reasoning') {
const last = state.messages[state.messages.length - 1];
if (last && last.role === 'assistant' && last._streaming) {
last.reasoning_content = (last.reasoning_content || '') + (p.payload?.content || '');
}
}
} catch(err) {}
}
async function pump() {
while (true) {
try { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); processLines(); } catch(e) { break; }
}
reconnectTimer = setTimeout(function() { connectSSE() }, 3000);
}
pump();
} catch(e) { setTimeout(function() { connectSSE() }, 5000); }
}
function startSidebarRefresh() {
if (state.sidebarRefreshTick) clearInterval(state.sidebarRefreshTick);
state.sidebarRefreshTick = setInterval(async function() {
try { await loadSidebarData() } catch(e) {}
}, 5000);
}
// ===== Overview =====
function statCard(l, v) { return '<div class="card stat-card"><div class="stat-value">' + v + '</div><div class="stat-label">' + l + '</div></div>'; }
function renderOverview() {
const s = state.status || {}; const k = state.kernel;
let html = '<div class="grid-4">' + statCard('Status', s.status || 'unknown')
+ statCard('Uptime', '<span id="uptime-val">' + (state.startedAt ? fmtUptime(Date.now() - state.startedAt) : '-') + '</span>')
+ statCard('Plugins', (k?.plugins || []).length || 0)
+ statCard('Version', s.version || '-') + '</div>';
if (k) {
html += '<div class="grid-2">'
+ '<div class="card"><h2>LLM Status</h2>'
+ '<div class="kv-row"><span class="key">Provider</span><span class="val">' + (k.llm?.provider || 'Not configured') + '</span></div>'
+ '<div class="kv-row"><span class="key">Sources</span><span class="val">' + (k.llm?.sources || 0) + '</span></div>'
+ '<div class="kv-row"><span class="key">Status</span><span class="val"><span class="status-dot ' + (k.llm?.available ? 'dot-green' : 'dot-red') + '"></span>' + (k.llm?.available ? 'Running' : 'Unavailable') + '</span></div></div>'
+ '<div class="card"><h2>Memory Status</h2>'
+ '<div class="kv-row"><span class="key">Graph Memory</span><span class="val"><span class="status-dot ' + (k.memory?.available ? 'dot-green' : 'dot-gray') + '"></span>' + (k.memory?.available ? k.memory.entity_count + ' entities, ' + k.memory.relation_count + ' relations' : 'Uninitialized') + '</span></div>'
+ '<div class="kv-row"><span class="key">Document Memory</span><span class="val">' + (k.documents?.available ? k.documents.doc_count + ' docs' : 'Uninitialized') + '</span></div>'
+ '<div class="kv-row"><span class="key">Text Memory</span><span class="val">' + (k.text_memory?.available ? k.text_memory.file_count + ' files' : 'Uninitialized') + '</span></div>'
+ '<div class="kv-row"><span class="key">Knowledge</span><span class="val">' + (k.knowledge?.available ? k.knowledge.item_count + ' items' : 'Uninitialized') + '</span></div></div></div>';
}
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' : '-') + statCard('Go Version', k?.runtime?.go_version || '-') + '</div></div>'
+ '<div class="card"><h2>Memory Graph</h2><div id="starmap-container" style="height:280px;background:var(--bg-input);border-radius:8px;display:flex;align-items:center;justify-content:center;color:var(--text-muted);font-size:13px">'
+ '<span id="starmap-placeholder">Loading memory graph...</span></div></div>';
document.getElementById('tab-overview').innerHTML = html;
loadStarmapData();
}
async function loadStarmapData() {
try {
const resp = await api('/memory/graph');
if (resp && resp.success && resp.data && resp.data.nodes && resp.data.nodes.length > 0) {
state._starmapData = resp.data;
document.getElementById('starmap-placeholder').textContent = resp.data.nodes.length + ' nodes, ' + (resp.data.edges?.length || 0) + ' edges';
} else {
document.getElementById('starmap-placeholder').textContent = 'No memory data yet';
}
} catch(e) {
document.getElementById('starmap-placeholder').textContent = 'Failed to load: ' + e.message;
}
}
// ===== Chat =====
let _chatLayoutBuilt = false;
let _terminals = [], _cmdHistory = [];
function buildChatLayout() {
const k = state.kernel || {};
document.getElementById('tab-chat').innerHTML =
'<div class="chat-layout"><div class="chat-main">'
+ '<div class="card"><h2>Chat <span id="chat-stage" class="badge" style="font-size:10px;font-weight:400;display:none"></span></h2>'
+ '<div class="chat-messages" id="chat-msgs"><div class="empty-state" style="flex:1;display:flex;align-items:center;justify-content:center"><p>Start a conversation</p></div></div>'
+ '<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></div>'
+ '<div class="chat-sidebar">'
+ '<div class="card" style="padding:12px"><h2 style="font-size:13px;margin-bottom:8px">Terminals <span id="term-count-badge" class="badge badge-blue">0</span></h2><div id="term-list" style="max-height:140px;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:100px;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 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:160px;overflow:auto"></div></div>'
+ '<div id="chat-sub-context" style="display:none"><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:180px;overflow:auto"></div></div>'
+ '<div id="chat-sub-knowledge" style="display:none">'
+ '<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"><button class="btn btn-primary btn-sm" onclick="searchKnowledgeChat()">Search</button></div>'
+ '<div id="know-result-chat" style="margin-top:8px;max-height:140px;overflow:auto"></div>'
+ '<div style="margin-top:12px;border-top:1px solid var(--border-color);padding-top:8px">'
+ '<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>';
_chatLayoutBuilt = true;
}
function renderChat() {
if (!_chatLayoutBuilt) { buildChatLayout(); renderTerminalsList(); renderCmdHistoryList(); }
const msgsEl = document.getElementById('chat-msgs');
if (!msgsEl) return;
if (state.messages.length === 0) {
msgsEl.innerHTML = '<div class="empty-state" style="flex:1;display:flex;align-items:center;justify-content:center"><p>Start a conversation</p></div>'; return;
}
let html = '';
state.messages.forEach(function(m) {
const role = m.role || 'user'; let c = m.content || '';
if (role === 'assistant') { c = renderMarkdown(c) } else { c = '<pre>' + escHtml(c) + '</pre>' }
const rc = m.reasoning_content ? '<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">' + renderMarkdown(m.reasoning_content) + '</div></div>' : '';
html += '<div class="msg msg-' + role + '"><div class="msg-avatar">' + (role === 'user' ? 'U' : 'A') + '</div>'
+ '<div class="msg-content"><div class="msg-bubble">' + rc + '<div class="text">' + c + '</div></div></div></div>';
});
msgsEl.innerHTML = html; msgsEl.scrollTop = msgsEl.scrollHeight;
updateChatStageBadge();
}
function switchChatSub(name, el) {
document.querySelectorAll('.sidebar-subnav span').forEach(function(e) { e.classList.remove('active') });
if (el) el.classList.add('active');
['memory','context','knowledge'].forEach(function(s) { document.getElementById('chat-sub-' + s).style.display = s === name ? 'block' : 'none' });
}
async function sendChat() {
const inp = document.getElementById('chat-input'); const btn = document.getElementById('chat-send-btn');
const text = inp.value.trim(); if (!text || state.chatLoading) return;
state.messages.push({ role: 'user', content: text }); inp.value = '';
const streamingMsg = { role: 'assistant', content: '', reasoning_content: '', _streaming: true };
state.messages.push(streamingMsg); renderChat();
state.chatLoading = true; btn.disabled = true; btn.textContent = '...';
try {
const r = await api('/chat', { method: 'POST', body: JSON.stringify({ message: text }) });
if (streamingMsg._streaming) {
streamingMsg.content = r.response || '(no response)'; streamingMsg.reasoning_content = r.reasoning_content || '';
} else {
streamingMsg.content = (streamingMsg.content || '') + (r.response || ''); streamingMsg.reasoning_content = (streamingMsg.reasoning_content || '') + (r.reasoning_content || '');
}
delete streamingMsg._streaming; saveChatHistory(); renderChat();
} catch(e) {
if (streamingMsg._streaming) { streamingMsg.content = 'Error: ' + e.message; delete streamingMsg._streaming; }
renderChat(); toast('Request failed: ' + e.message, true);
} finally {
state.chatLoading = false; btn.disabled = false; btn.textContent = 'Send'; renderChat();
}
}
function updateChatStageBadge() {
const badge = document.getElementById('chat-stage');
if (!badge) return; badge.textContent = state.chatStage || ''; badge.style.display = state.chatStage ? 'inline' : 'none';
}
function rerenderChatIfActive() {
const tab = document.getElementById('tab-chat');
if (tab && tab.classList.contains('active')) renderChat();
}
async function loadSidebarData() {
try { const d = await api('/terminals'); _terminals = d?.terminals || []; renderTerminalsList() } catch(e) {}
try { const d = await api('/cmd/history'); _cmdHistory = d?.history || []; renderCmdHistoryList() } catch(e) {}
}
function renderTerminalsList() {
const el = document.getElementById('term-list'); const badge = document.getElementById('term-count-badge');
if (!el) return; if (badge) badge.textContent = _terminals.length;
if (_terminals.length === 0) { el.innerHTML = '<p style="color:var(--text-muted);font-size:11px">No active terminals</p>'; return; }
el.innerHTML = _terminals.map(function(t) {
const status = t.running ? '<span class="status-dot dot-green"></span>' : '<span class="status-dot dot-gray"></span>';
return '<div style="padding:3px 0;border-bottom:1px solid var(--border-color);font-size:11px">' + status + ' ' + escHtml((t.command || t.id || '').substring(0, 40)) + ' <span style="color:var(--text-muted)">' + (t.uptime || '') + '</span></div>';
}).join('');
}
function renderCmdHistoryList() {
const el = document.getElementById('cmd-list'); const badge = document.getElementById('cmd-count-badge');
if (!el) return; if (badge) badge.textContent = _cmdHistory.length;
if (_cmdHistory.length === 0) { el.innerHTML = '<p style="color:var(--text-muted);font-size:11px">No command history</p>'; return; }
el.innerHTML = _cmdHistory.slice(-10).reverse().map(function(c) {
const status = c.status === 'completed' ? '<span class="badge badge-green">OK</span>' : '<span class="badge badge-red">' + escHtml(c.status || 'FAIL') + '</span>';
return '<div style="padding:3px 0;border-bottom:1px solid var(--kv-border);font-size:11px;display:flex;justify-content:space-between"><span>' + escHtml((c.command || '').substring(0, 40)) + '</span><span>' + status + '</span></div>';
}).join('');
}
// ===== Chat History Persistence =====
async function saveChatHistory() {
try {
const msgs = state.messages.filter(function(m) { return !m._streaming }).map(function(m) {
return { role: m.role, content: m.content, reasoning_content: m.reasoning_content, time: new Date().toISOString() };
}).slice(-100);
await api('/settings', { method: 'PUT', body: JSON.stringify({ key: 'plugin.webui.chathistory', value: JSON.stringify(msgs) }) });
} catch(e) {}
}
async function loadChatHistory() {
if (state._chatHistoryLoaded || !state.currentConn) return;
try {
const s = await api('/settings?prefix=plugin.webui');
const raw = s?.settings?.['plugin.webui.chathistory'];
if (raw) {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed) && parsed.length > 0) {
state.messages = parsed.map(function(m) { return { role: m.role, content: m.content || '', reasoning_content: m.reasoning_content || '' } });
state._chatHistoryLoaded = true;
renderChat();
}
}
} catch(e) {}
state._chatHistoryLoaded = true;
}
// hook into renderChat init
const _origRenderChat = renderChat;
renderChat = function() {
loadChatHistory();
return _origRenderChat.apply(this, arguments);
};
async function queryMemoryChat() {
const q = document.getElementById('mem-query')?.value; const r = document.getElementById('mem-result-chat');
if (!r || !q) return; r.innerHTML = '<div class="loading"></div>';
try { const d = await api('/memory?q=' + encodeURIComponent(q) + '&depth=2'); r.innerHTML = '<pre style="font-size:11px">' + escHtml(JSON.stringify(d, null, 2)) + '</pre>'; }
catch(e) { r.innerHTML = '<p style="color:#fca5a5">Query failed: ' + escHtml(e.message) + '</p>'; }
}
async function queryMemoryContext() {
const q = document.getElementById('ctx-query')?.value; const r = document.getElementById('ctx-result');
if (!r) return; r.innerHTML = '<div class="loading"></div>';
try {
const d = await api('/memory/context?q=' + encodeURIComponent(q || ''));
let html = '<div style="font-size:11px">';
if (d?.summary) html += '<div class="kv-row"><span class="key">Summary</span><span class="val">' + escHtml(d.summary) + '</span></div>';
html += '<div class="kv-row"><span class="key">Token Estimate</span><span class="val">' + (d?.token_estimate || 0) + '</span></div>';
if (d?.entities?.length) html += '<div class="kv-row"><span class="key">Entities</span><span class="val">' + d.entities.map(function(e) { return escHtml(e.name || e.id || '') }).join(', ') + '</span></div>';
html += '<h3 style="font-size:12px;margin:8px 0 4px">Context</h3><pre>' + escHtml(d?.context || 'No context') + '</pre></div>';
r.innerHTML = html;
} catch(e) { r.innerHTML = '<p style="color:#fca5a5">Query failed: ' + escHtml(e.message) + '</p>'; }
}
async function searchKnowledgeChat() {
const q = document.getElementById('know-query')?.value; const r = document.getElementById('know-result-chat');
if (!r || !q) return; r.innerHTML = '<div class="loading"></div>';
try { const d = await api('/knowledge?q=' + encodeURIComponent(q)); r.innerHTML = '<pre style="font-size:11px">' + escHtml(JSON.stringify(d, null, 2)) + '</pre>'; }
catch(e) { r.innerHTML = '<p style="color:#fca5a5">Search failed: ' + escHtml(e.message) + '</p>'; }
}
async function createKnowledgeChat() {
const name = document.getElementById('know-name')?.value; const content = document.getElementById('know-content')?.value;
if (!name || !content) { toast('Name and content required', true); return; }
try { await api('/knowledge', { method: 'POST', body: JSON.stringify({ name, content }) }); toast('Knowledge created'); document.getElementById('know-name').value = ''; document.getElementById('know-content').value = ''; }
catch(e) { toast('Create failed: ' + e.message, true); }
}
// ===== Plugins =====
async function renderPlugins() {
const list = state.installedPlugins?.plugins || []; const info = state.pluginInfo;
let html = '<div class="card"><div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">'
+ '<h2 style="margin-bottom:0">Installed Plugins</h2><div style="display:flex;gap:8px">'
+ '<button class="btn btn-ghost btn-sm" onclick="document.getElementById(\'plugin-file-input\').click()">Upload .hmap</button>'
+ '<input type="file" id="plugin-file-input" accept=".hmap,.so,.dll" style="display:none" onchange="installPluginFile(this.files[0])">'
+ '<button class="btn btn-ghost btn-sm" onclick="reloadPlugins()">Reload</button></div></div>';
if (list.length === 0) { html += '<p class="empty-state">No plugins installed</p>'; }
else {
html += '<table><tr><th>Name</th><th>Type</th><th>Status</th><th></th></tr>';
list.forEach(function(p) {
const status = p.loaded ? '<span class="badge badge-green">Loaded</span>' : '<span class="badge badge-red">Error</span>';
html += '<tr><td>' + escHtml(p.name || '') + '</td><td>' + escHtml(p.type || '') + '</td><td>' + status + '</td>'
+ '<td><button class="btn btn-ghost btn-sm" onclick="showPluginInfo(\'' + p.name + '\')">Info</button></td></tr>';
}); html += '</table>';
}
html += '</div>';
if (info) {
html += '<div class="card"><h2>' + escHtml(info.name || '') + ' Details</h2><pre>' + escHtml(JSON.stringify(info, null, 2)) + '</pre>'
+ '<button class="btn btn-ghost btn-sm" onclick="state.pluginInfo=null;renderPlugins()" style="margin-top:8px">Close</button></div>';
}
document.getElementById('tab-plugins').innerHTML = html;
}
async function showPluginInfo(name) {
try { const d = await api('/plugins/' + encodeURIComponent(name)); state.pluginInfo = d; renderPlugins(); }
catch(e) { toast('Failed: ' + e.message, true); }
}
async function installPluginFile(file) {
if (!file) return;
try {
const form = new FormData(); form.append('plugin', file);
await fetch(state.currentConn.url + '/api/v1/plugins', { method: 'POST', body: form, headers: state.currentConn.apiKey ? { 'X-API-Key': state.currentConn.apiKey } : {} });
toast('Plugin uploaded'); state.installedPlugins = await api('/plugins'); renderPlugins();
} catch(e) { toast('Upload failed: ' + e.message, true); }
}
async function reloadPlugins() {
try { await api('/plugins/reload', { method: 'POST' }); state.kernel = await api('/kernel'); state.installedPlugins = await api('/plugins'); toast('Plugins reloaded'); renderPlugins(); renderOverview(); }
catch(e) { toast('Reload failed: ' + e.message, true); }
}
// ===== Knowledge Browser =====
async function renderKnowledgeBrowser() {
if (document.getElementById('tab-knowledge')) return;
const tab = document.getElementById('tab-kernel');
if (!tab || !tab.classList.contains('active')) return;
const cont = document.getElementById('knowledge-browser');
if (!cont) return;
try {
const d = await api('/knowledge');
let html = '<div class="card"><h2>Knowledge Base</h2>';
if (d?.categories) {
html += '<table><tr><th>Name</th><th>Size</th></tr>';
(d.categories || []).forEach(function(c) {
html += '<tr><td>' + escHtml(c.name || c) + '</td><td>' + (c.content_length || '-') + '</td></tr>';
});
html += '</table>';
}
if (d?.stats) {
html += '<div class="grid-3" style="margin-top:12px">' + statCard('Categories', d.stats.categories || 0) + statCard('Items', d.stats.items || 0) + statCard('Size', d.stats.size || 0) + '</div>';
}
html += '</div>';
cont.innerHTML = html;
} catch(e) { cont.innerHTML = '<p style="color:#fca5a5">' + escHtml(e.message) + '</p>'; }
}
// ===== Settings =====
function renderOneSettings() {
const section = state.selectedSection || 'core';
const isPlugin = section.startsWith('plugin.'); const prefix = isPlugin ? section : (section === 'core' ? '' : section);
const values = {}; const meta = {};
if (isPlugin) { const pname = section.substring(7); Object.entries(state.settings).filter(function(e) { return e[0].startsWith('plugin.' + pname + '.') }).forEach(function(e) { values[e[0]] = e[1] }) }
else if (section === 'core') { Object.entries(state.settings).filter(function(e) { return !e[0].startsWith('plugin.') }).forEach(function(e) { values[e[0]] = e[1] }) }
else { Object.entries(state.settings).filter(function(e) { return e[0].startsWith(section + '.') || (!e[0].startsWith('plugin.') && e[0].startsWith(section)) }).forEach(function(e) { values[e[0]] = e[1] }) }
Object.assign(meta, state.meta);
let html = '<div class="card"><h2>' + (isPlugin ? 'Plugin: ' + section.substring(7) : 'Core Settings') + '</h2>';
const keys = Object.keys(values);
if (keys.length === 0) { html += '<p class="empty-state">No settings</p>' }
else {
keys.sort().forEach(function(k) {
const v = values[k]; const m = meta[k]; const display = m?.displayName || k.split('.').pop() || k; const desc = m?.description || '';
html += '<div style="margin-bottom:12px;padding-bottom:12px;border-bottom:1px solid var(--kv-border)"><div class="settings-key">' + escHtml(k) + '</div><label>' + escHtml(display) + '</label>'
+ '<input value="' + escHtml(v != null ? String(v) : '') + '" onchange="saveSetting(\'' + escHtml(k) + '\', this.value)" placeholder="' + escHtml(desc) + '">'
+ (desc ? '<div style="font-size:10px;color:var(--text-muted);margin-top:-8px;margin-bottom:4px">' + escHtml(desc) + '</div>' : '') + '</div>';
});
}
html += '</div>';
document.getElementById('tab-settings').innerHTML = '<div class="settings-layout"><div class="settings-sidebar">'
+ state.settingsPlugins.map(function(p) { const label = p === 'core' ? 'Core' : p.replace('plugin.', ''); return '<a class="' + (section === p ? 'active' : '') + '" onclick="state.selectedSection=\'' + p + '\';renderOneSettings()">' + escHtml(label) + '</a>' }).join('')
+ '</div><div class="settings-content">' + html + '</div></div>';
}
async function saveSetting(key, value) {
try { await api('/settings', { method: 'PUT', body: JSON.stringify({ key, value }) }); toast('Saved'); const s = await api('/settings'); state.settings = s.settings || {}; state.meta = s.meta || {}; renderOneSettings(); }
catch(e) { toast('Save failed: ' + e.message, true); }
}
// ===== Adapters =====
async function renderAdapters() {
let html = '<div class="card"><div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px"><h2 style="margin-bottom:0">LLM Adapters</h2><button class="btn btn-primary btn-sm" onclick="showUploadAdapter()">Upload</button></div>';
try {
const d = await api('/adapters'); const adapters = d?.adapters || [];
if (adapters.length === 0) { html += '<p class="empty-state">No adapters</p>' }
else {
html += '<table><tr><th>Name</th><th>Type</th><th></th></tr>';
adapters.forEach(function(a) { html += '<tr><td>' + escHtml(a.name || a) + '</td><td>' + escHtml(a.type || 'lua') + '</td><td><button class="btn btn-danger btn-sm" onclick="deleteAdapter(\'' + escHtml(a.name || a) + '\')">Delete</button></td></tr>' });
html += '</table>';
}
} catch(e) { html += '<p class="empty-state">Failed to load: ' + escHtml(e.message) + '</p>' }
html += '</div>';
document.getElementById('tab-adapters').innerHTML = html;
}
function showUploadAdapter() { const name = prompt('Adapter name:'); if (!name) return; const code = prompt('Paste Lua adapter code:'); if (!code) return; uploadAdapter(name, code); }
async function uploadAdapter(name, code) { try { await api('/adapters', { method: 'POST', body: JSON.stringify({ name, code }) }); toast('Uploaded'); renderAdapters(); } catch(e) { toast('Failed: ' + e.message, true); } }
async function deleteAdapter(name) { if (!confirm('Delete: ' + name + '?')) return; try { await api('/adapters/' + encodeURIComponent(name), { method: 'DELETE' }); toast('Deleted'); renderAdapters(); } catch(e) { toast('Failed: ' + e.message, true); } }
// ===== Kernel =====
async function renderKernel() {
const k = state.kernel;
let html = '<div class="card"><h2>Kernel Status</h2>';
if (!k) { html += '<p class="empty-state">Unavailable</p>' }
else {
html += '<div class="grid-2"><div><h3>LLM</h3><div class="kv-row"><span class="key">Provider</span><span class="val">' + escHtml(k.llm?.provider || '-') + '</span></div><div class="kv-row"><span class="key">Sources</span><span class="val">' + (k.llm?.sources || 0) + '</span></div><div class="kv-row"><span class="key">Available</span><span class="val"><span class="status-dot ' + (k.llm?.available ? 'dot-green' : 'dot-red') + '"></span>' + (k.llm?.available ? 'Yes' : 'No') + '</span></div></div>'
+ '<div><h3>Memory</h3><div class="kv-row"><span class="key">Available</span><span class="val"><span class="status-dot ' + (k.memory?.available ? 'dot-green' : 'dot-gray') + '"></span>' + (k.memory?.available ? 'Yes' : 'No') + '</span></div>'
+ (k.memory?.available ? '<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></div>';
html += '<h3>Runtime</h3><div class="kv-row"><span class="key">Goroutines</span><span class="val">' + (k.runtime?.goroutines || '-') + '</span></div><div class="kv-row"><span class="key">Memory</span><span class="val">' + (k.runtime?.memory_mb || '-') + ' MB</span></div><div class="kv-row"><span class="key">Go Version</span><span class="val">' + escHtml(k.runtime?.go_version || '-') + '</span></div>';
html += '<h3>Plugins</h3>';
if (k.plugins && k.plugins.length > 0) { html += '<div>' + k.plugins.map(function(p) { return '<span class="badge badge-blue" style="margin:2px">' + escHtml(p.name || p) + '</span>' }).join('') + '</div>' }
}
html += '</div><div class="card"><h2>Actions</h2><button class="btn btn-primary" onclick="runHealthcheck()" style="margin-right:8px">Run Healthcheck</button>'
+ '<button class="btn btn-ghost" onclick="loadTextMemory()">View Text Memory</button></div>'
+ '<div id="health-result"></div><div id="text-memory-result"></div>'
+ '<div id="knowledge-browser"></div>';
document.getElementById('tab-kernel').innerHTML = html;
renderKnowledgeBrowser();
}
async function runHealthcheck() {
const el = document.getElementById('health-result'); el.innerHTML = '<div class="loading-spinner"></div>';
try { const r = await api('/chat', { method: 'POST', body: JSON.stringify({ message: 'Please run the healthcheck tool for a full system check and report the results' }) }); el.innerHTML = '<div class="card"><h2>Healthcheck Result</h2><pre>' + escHtml(r.response || '') + '</pre></div>'; }
catch(e) { el.innerHTML = '<div class="card"><p style="color:#fca5a5">' + escHtml(e.message) + '</p></div>'; }
}
async function loadTextMemory() {
const el = document.getElementById('text-memory-result'); el.innerHTML = '<div class="loading-spinner"></div>';
try { const d = await api('/memory/text'); el.innerHTML = '<div class="card"><h2>Text Memory</h2><pre>' + escHtml(JSON.stringify(d, null, 2)) + '</pre></div>'; }
catch(e) { el.innerHTML = '<div class="card"><p style="color:#fca5a5">' + escHtml(e.message) + '</p></div>'; }
}
// ===== Init =====
document.addEventListener('DOMContentLoaded', initApp);

68
gui/renderer/index.html Normal file
View File

@ -0,0 +1,68 @@
<!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>
<link rel="stylesheet" href="style.css">
</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</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>
</div>
<!-- Main App -->
<div id="app" style="display:none">
<!-- Top Bar -->
<nav>
<h1>HomeAgent</h1>
<a class="active" onclick="switchTab('overview')">概览</a>
<a onclick="switchTab('chat')">对话</a>
<a onclick="switchTab('plugins')">插件</a>
<a onclick="switchTab('settings')">设置</a>
<a onclick="switchTab('adapters')">适配器</a>
<a onclick="switchTab('kernel')">内核</a>
<div style="margin-left:auto;display:flex;align-items:center;gap:8px">
<span id="conn-status" class="conn-indicator" onclick="openConnManager()" 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>
<button class="theme-btn" onclick="toggleTheme()" id="theme-btn">🌙</button>
</div>
</nav>
<div class="container" id="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>
</div>
<div id="toast" class="toast"></div>
<script src="app.js"></script>
</body>
</html>

203
gui/renderer/style.css Normal file
View File

@ -0,0 +1,203 @@
: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;
}
[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;
--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;
}
* { 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; 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 }
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:all .15s }
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; 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 }
.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 }
.status-dot { display:inline-block; width:8px; height:8px; border-radius:50%; margin-right:6px }
.dot-green { background:#22c55e }
.dot-yellow { background:#eab308 }
.dot-red { background:#ef4444 }
.dot-gray { background:#475569 }
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) }
.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:all .15s }
.btn-primary { background:var(--accent); color:#fff }
.btn-primary:hover { filter:brightness(1.1) }
.btn-primary:disabled { opacity:.5; cursor:not-allowed }
.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(--border-color); color:var(--text-secondary); cursor:pointer; padding:6px 14px; border-radius:6px; font-size:12px }
.btn-ghost:hover { background:var(--bg-hover); color:var(--text-primary) }
.tab-content { display:none }
.tab-content.active { display:block }
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 }
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; 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 }
.msg-content .text h1,.msg-content .text h2,.msg-content .text h3 { font-size:1em; margin:8px 0 4px }
.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 }
.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:9999; box-shadow:0 4px 12px rgba(0,0,0,.3) }
.toast.error { background:var(--toast-error-bg); color:var(--toast-error-color) }
.empty-state { text-align:center; padding:40px 20px; color:var(--text-muted) }
.chat-layout { display:flex; gap:16px; height:calc(100vh - 100px); min-height:60vh; overflow:hidden }
.chat-main { flex:2; min-width:0; display:flex; flex-direction:column }
.chat-main .card { flex:1; display:flex; flex-direction:column; margin-bottom:0 }
.chat-messages { flex:1; overflow-y:auto; padding:12px; border:1px solid var(--border-color); border-radius:8px; background:var(--chat-bg); display:flex; flex-direction:column; gap:4px }
.msg { display:flex; gap:8px; margin-bottom:2px; align-items:flex-start; max-width:85% }
.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:6px; display:flex; align-items:center; justify-content:center; font-size:12px; flex-shrink:0 }
.msg-user .msg-avatar { background:var(--msg-user-bg); color:var(--msg-user-color) }
.msg-assistant .msg-avatar { background:var(--msg-assistant-bg); color:var(--msg-assistant-color) }
.msg-system .msg-avatar { background:var(--msg-system-bg); color:var(--msg-system-color) }
.msg-bubble { padding:8px 12px; border-radius:10px; font-size:13px; line-height:1.5; word-break:break-word }
.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 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 .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 }
.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 }
.loading { display:inline-block; width:16px; height:16px; border:2px solid var(--border-color); border-radius:50%; border-top-color:var(--accent); animation:spin .6s linear infinite }
@keyframes spin { to { transform:rotate(360deg) } }
.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 }
.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; margin:20px auto }
#starmap-container { position:relative; overflow:hidden }
#starmap-container canvas { display:block }
#starmap-placeholder { display:flex; align-items:center; justify-content:center; height:100%; width:100% }
.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) }
/* 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 }
.overlay-content h2 { font-size:18px; margin-bottom:16px; color:var(--text-primary) }
.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:all .15s }
.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) }
@media(max-width:768px) {
nav { padding:0 8px; gap:2px }
nav h1 { font-size:13px; margin-right:8px }
nav a { padding:8px 6px; font-size:11px }
.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 }
.settings-sidebar a { display:inline-block; padding:6px 12px; border-left:none; border-bottom:2px solid transparent }
.settings-sidebar a.active { border-left:none; border-bottom-color:var(--accent) }
.kv-row { flex-direction:column; gap:2px }
.kv-row .key { width:auto }
.chat-layout { flex-direction:column; height:auto }
.chat-sidebar { max-width:none }
.msg { max-width:95% }
}