diff --git a/go.mod b/go.mod
index 57c206c..5a09fa8 100644
--- a/go.mod
+++ b/go.mod
@@ -8,6 +8,8 @@ require (
gopkg.in/yaml.v3 v3.0.1
)
+require github.com/yanyiwu/gojieba v1.4.7 // indirect
+
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0-20260708004841-e9bdcf9304b0 // direct
-replace gitcode.com/JianFeeeee/homeagent-sdk => ../homeagentsdk
+replace gitcode.com/JianFeeeee/homeagent-sdk => /tmp/opencode/homeagent-sdk-repo
diff --git a/go.sum b/go.sum
index d9009d1..362e015 100644
--- a/go.sum
+++ b/go.sum
@@ -14,6 +14,8 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+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/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
diff --git a/go.work b/go.work
index 269ee9b..855a0b3 100644
--- a/go.work
+++ b/go.work
@@ -2,5 +2,4 @@ go 1.25.0
use (
.
- ../homeagentsdk
)
diff --git a/gui/main.js b/gui/main.js
new file mode 100644
index 0000000..3e3d629
--- /dev/null
+++ b/gui/main.js
@@ -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();
+});
diff --git a/gui/package-lock.json b/gui/package-lock.json
new file mode 100644
index 0000000..dc53aa0
--- /dev/null
+++ b/gui/package-lock.json
@@ -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"
+ }
+ }
+ }
+}
diff --git a/gui/package.json b/gui/package.json
new file mode 100644
index 0000000..9be4e12
--- /dev/null
+++ b/gui/package.json
@@ -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"
+ }
+}
diff --git a/gui/preload.js b/gui/preload.js
new file mode 100644
index 0000000..63ed161
--- /dev/null
+++ b/gui/preload.js
@@ -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),
+ },
+});
diff --git a/gui/renderer/app.js b/gui/renderer/app.js
new file mode 100644
index 0000000..e48a734
--- /dev/null
+++ b/gui/renderer/app.js
@@ -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,'&').replace(//g,'>');
+ // code blocks (fenced)
+ s = s.replace(/```(\w*)\n([\s\S]*?)```/g, '
$2 ');
+ // inline code
+ s = s.replace(/`([^`]+)`/g, '$1');
+ // headers
+ s = s.replace(/^### (.+)$/gm, '$1 ');
+ s = s.replace(/^## (.+)$/gm, '$1 ');
+ s = s.replace(/^# (.+)$/gm, '$1 ');
+ // bold & italic
+ s = s.replace(/\*\*\*(.+?)\*\*\*/g, '$1 ');
+ s = s.replace(/\*\*(.+?)\*\*/g, '$1 ');
+ s = s.replace(/\*(.+?)\*/g, '$1 ');
+ // links
+ s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1 ');
+ // images
+ s = s.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, ' ');
+ // blockquote
+ s = s.replace(/^> (.+)$/gm, '$1 ');
+ // horizontal rule
+ s = s.replace(/^---$/gm, ' ');
+ // unordered list
+ s = s.replace(/^[\s]*[-*] (.+)$/gm, '$1 ');
+ s = s.replace(/(.*<\/li>\n?)+/g, '');
+ // ordered list
+ s = s.replace(/^[\s]*\d+\. (.+)$/gm, ' $1 ');
+ // paragraphs: double newlines
+ s = s.replace(/\n\n/g, '');
+ s = '
' + s + '
';
+ // clean nested ps from lists
+ s = s.replace(/<\/p>\n?/g, '').replace(/<\/ul>\n?/g, '
');
+ s = s.replace(/<\/p>\n?/g, ' ').replace(/<\/li>\n?/g, '
');
+ s = s.replace(/<\/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 =>
+ '
'
+ + '
'
+ + '
' + escHtml(c.name) + '
' + escHtml(c.url) + '
'
+ + '
'
+ + '编辑 '
+ + '删除
'
+ ).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,'&').replace(//g,'>').replace(/"/g,'"'); }
+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 ''; }
+
+function renderOverview() {
+ const s = state.status || {}; const k = state.kernel;
+ let html = '' + statCard('Status', s.status || 'unknown')
+ + statCard('Uptime', '' + (state.startedAt ? fmtUptime(Date.now() - state.startedAt) : '-') + ' ')
+ + statCard('Plugins', (k?.plugins || []).length || 0)
+ + statCard('Version', s.version || '-') + '
';
+ if (k) {
+ html += ''
+ + '
LLM Status '
+ + '
Provider ' + (k.llm?.provider || 'Not configured') + '
'
+ + '
Sources ' + (k.llm?.sources || 0) + '
'
+ + '
Status ' + (k.llm?.available ? 'Running' : 'Unavailable') + '
'
+ + '
Memory Status '
+ + '
Graph Memory ' + (k.memory?.available ? k.memory.entity_count + ' entities, ' + k.memory.relation_count + ' relations' : 'Uninitialized') + '
'
+ + '
Document Memory ' + (k.documents?.available ? k.documents.doc_count + ' docs' : 'Uninitialized') + '
'
+ + '
Text Memory ' + (k.text_memory?.available ? k.text_memory.file_count + ' files' : 'Uninitialized') + '
'
+ + '
Knowledge ' + (k.knowledge?.available ? k.knowledge.item_count + ' items' : 'Uninitialized') + '
';
+ }
+ html += 'Runtime ' + statCard('Goroutines', k?.runtime?.goroutines || '-') + statCard('Memory', k?.runtime?.memory_mb ? k.runtime.memory_mb + ' MB' : '-') + statCard('Go Version', k?.runtime?.go_version || '-') + '
'
+ + 'Memory Graph '
+ + 'Loading memory graph...
';
+ 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 =
+ ''
+ + '
Chat '
+ + '
'
+ + '
'
+ + 'Send
'
+ + '
';
+ _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 = ''; 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 = ' ' + escHtml(c) + ' ' }
+ const rc = m.reasoning_content ? 'Collapse
' + renderMarkdown(m.reasoning_content) + '
' : '';
+ html += '' + (role === 'user' ? 'U' : 'A') + '
'
+ + '
';
+ });
+ 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 = 'No active terminals
'; return; }
+ el.innerHTML = _terminals.map(function(t) {
+ const status = t.running ? ' ' : ' ';
+ return '' + status + ' ' + escHtml((t.command || t.id || '').substring(0, 40)) + ' ' + (t.uptime || '') + '
';
+ }).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 = 'No command history
'; return; }
+ el.innerHTML = _cmdHistory.slice(-10).reverse().map(function(c) {
+ const status = c.status === 'completed' ? 'OK ' : '' + escHtml(c.status || 'FAIL') + ' ';
+ return '' + escHtml((c.command || '').substring(0, 40)) + ' ' + status + '
';
+ }).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 = '
';
+ try { const d = await api('/memory?q=' + encodeURIComponent(q) + '&depth=2'); r.innerHTML = '' + escHtml(JSON.stringify(d, null, 2)) + ' '; }
+ catch(e) { r.innerHTML = 'Query failed: ' + escHtml(e.message) + '
'; }
+}
+
+async function queryMemoryContext() {
+ const q = document.getElementById('ctx-query')?.value; const r = document.getElementById('ctx-result');
+ if (!r) return; r.innerHTML = '
';
+ try {
+ const d = await api('/memory/context?q=' + encodeURIComponent(q || ''));
+ let html = '';
+ if (d?.summary) html += '
Summary ' + escHtml(d.summary) + '
';
+ html += '
Token Estimate ' + (d?.token_estimate || 0) + '
';
+ if (d?.entities?.length) html += '
Entities ' + d.entities.map(function(e) { return escHtml(e.name || e.id || '') }).join(', ') + '
';
+ html += '
Context ' + escHtml(d?.context || 'No context') + ' ';
+ r.innerHTML = html;
+ } catch(e) { r.innerHTML = 'Query failed: ' + escHtml(e.message) + '
'; }
+}
+
+async function searchKnowledgeChat() {
+ const q = document.getElementById('know-query')?.value; const r = document.getElementById('know-result-chat');
+ if (!r || !q) return; r.innerHTML = '
';
+ try { const d = await api('/knowledge?q=' + encodeURIComponent(q)); r.innerHTML = '' + escHtml(JSON.stringify(d, null, 2)) + ' '; }
+ catch(e) { r.innerHTML = 'Search failed: ' + escHtml(e.message) + '
'; }
+}
+
+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 = '';
+ if (list.length === 0) { html += '
No plugins installed
'; }
+ else {
+ html += '
Name Type Status ';
+ list.forEach(function(p) {
+ const status = p.loaded ? 'Loaded ' : 'Error ';
+ html += '' + escHtml(p.name || '') + ' ' + escHtml(p.type || '') + ' ' + status + ' '
+ + 'Info ';
+ }); html += '
';
+ }
+ html += '
';
+ if (info) {
+ html += '' + escHtml(info.name || '') + ' Details ' + escHtml(JSON.stringify(info, null, 2)) + ' '
+ + '
Close ';
+ }
+ 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 = 'Knowledge Base ';
+ if (d?.categories) {
+ html += '
Name Size ';
+ (d.categories || []).forEach(function(c) {
+ html += '' + escHtml(c.name || c) + ' ' + (c.content_length || '-') + ' ';
+ });
+ html += '
';
+ }
+ if (d?.stats) {
+ html += '
' + statCard('Categories', d.stats.categories || 0) + statCard('Items', d.stats.items || 0) + statCard('Size', d.stats.size || 0) + '
';
+ }
+ html += '
';
+ cont.innerHTML = html;
+ } catch(e) { cont.innerHTML = '' + escHtml(e.message) + '
'; }
+}
+
+// ===== 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 = '' + (isPlugin ? 'Plugin: ' + section.substring(7) : 'Core Settings') + ' ';
+ const keys = Object.keys(values);
+ if (keys.length === 0) { html += '
No settings
' }
+ 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 += '
' + escHtml(k) + '
' + escHtml(display) + ' '
+ + '
'
+ + (desc ? '
' + escHtml(desc) + '
' : '') + '
';
+ });
+ }
+ html += '
';
+ document.getElementById('tab-settings').innerHTML = '';
+}
+
+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 = '
LLM Adapters Upload ';
+ try {
+ const d = await api('/adapters'); const adapters = d?.adapters || [];
+ if (adapters.length === 0) { html += '
No adapters
' }
+ else {
+ html += '
Name Type ';
+ adapters.forEach(function(a) { html += '' + escHtml(a.name || a) + ' ' + escHtml(a.type || 'lua') + ' Delete ' });
+ html += '
';
+ }
+ } catch(e) { html += '
Failed to load: ' + escHtml(e.message) + '
' }
+ html += '
';
+ 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 = 'Kernel Status ';
+ if (!k) { html += '
Unavailable
' }
+ else {
+ html += '
LLM Provider ' + escHtml(k.llm?.provider || '-') + '
Sources ' + (k.llm?.sources || 0) + '
Available ' + (k.llm?.available ? 'Yes' : 'No') + '
'
+ + '
Memory Available ' + (k.memory?.available ? 'Yes' : 'No') + '
'
+ + (k.memory?.available ? '
Entities ' + k.memory.entity_count + '
Relations ' + k.memory.relation_count + '
' : '') + '
';
+ html += '
Runtime Goroutines ' + (k.runtime?.goroutines || '-') + '
Memory ' + (k.runtime?.memory_mb || '-') + ' MB
Go Version ' + escHtml(k.runtime?.go_version || '-') + '
';
+ html += '
Plugins ';
+ if (k.plugins && k.plugins.length > 0) { html += '
' + k.plugins.map(function(p) { return '' + escHtml(p.name || p) + ' ' }).join('') + '
' }
+ }
+ html += '
Actions Run Healthcheck '
+ + 'View Text Memory '
+ + '
'
+ + '
';
+ document.getElementById('tab-kernel').innerHTML = html;
+ renderKnowledgeBrowser();
+}
+
+async function runHealthcheck() {
+ const el = document.getElementById('health-result'); el.innerHTML = '
';
+ 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 = 'Healthcheck Result ' + escHtml(r.response || '') + ' '; }
+ catch(e) { el.innerHTML = '' + escHtml(e.message) + '
'; }
+}
+async function loadTextMemory() {
+ const el = document.getElementById('text-memory-result'); el.innerHTML = '
';
+ try { const d = await api('/memory/text'); el.innerHTML = 'Text Memory ' + escHtml(JSON.stringify(d, null, 2)) + ' '; }
+ catch(e) { el.innerHTML = '' + escHtml(e.message) + '
'; }
+}
+
+// ===== Init =====
+document.addEventListener('DOMContentLoaded', initApp);
diff --git a/gui/renderer/index.html b/gui/renderer/index.html
new file mode 100644
index 0000000..caca1aa
--- /dev/null
+++ b/gui/renderer/index.html
@@ -0,0 +1,68 @@
+
+
+
+
+
+HomeAgent
+
+
+
+
+
+
+
+
连接管理 / Connections
+
+
+
+ 添加连接 / Add Connection
+
+
+
+
+
+
+
+ HomeAgent
+ 概览
+ 对话
+ 插件
+ 设置
+ 适配器
+ 内核
+
+
+
+ 未连接
+ ▼
+
+ 🌙
+
+
+
+
+
+
+
+
+
+
+
diff --git a/gui/renderer/style.css b/gui/renderer/style.css
new file mode 100644
index 0000000..a5e1ce9
--- /dev/null
+++ b/gui/renderer/style.css
@@ -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% }
+}
diff --git a/internal/agent/core/agent.go b/internal/agent/core/agent.go
index 536a9cb..0bfb738 100644
--- a/internal/agent/core/agent.go
+++ b/internal/agent/core/agent.go
@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"log"
+ "runtime/debug"
"sort"
"strings"
"sync"
@@ -75,8 +76,9 @@ type Agent struct {
currentOutputChannel string
// 阶段管道:插件消息流编辑
- stageHost *StageHost
- eventBus *events.Bus
+ stageHost *StageHost
+ eventBus *events.Bus
+ pluginHealth *pluginHealthTracker
// 自循环输入通道:核心内部任务(记忆消歧、系统维护),不经过 IO 层
selfInputCh chan string
@@ -190,6 +192,7 @@ func New(cfg AgentConfig) *Agent {
selfInputCh: make(chan string, 64),
childResults: make(map[string]string),
interceptCh: make(chan *agentIO.InputEvent, 64),
+ pluginHealth: newPluginHealthTracker(),
thinkingEnabled: cfg.ThinkingEnabled,
inputCfg: cfg.InputProcessing,
noMergeMarkers: make(map[string]int),
@@ -227,6 +230,13 @@ func (a *Agent) injectSelf(task string) {
}
func (a *Agent) eventLoop() {
+ defer func() {
+ if r := recover(); r != nil {
+ log.Printf("[agent] eventLoop panic recovered: %v\n%s", r, debug.Stack())
+ time.Sleep(time.Second)
+ go a.eventLoop()
+ }
+ }()
for {
select {
case evt := <-a.io.InputChan():
@@ -244,6 +254,13 @@ func (a *Agent) eventLoop() {
// a) 通过 cancelLLM + interceptCh 直接打断进行中的 LLM 请求
// b) 通过 a.io.InjectInput() → InputChan → eventLoop(代理空闲时触发新处理循环)
func (a *Agent) interceptLoop() {
+ defer func() {
+ if r := recover(); r != nil {
+ log.Printf("[agent] interceptLoop panic recovered: %v\n%s", r, debug.Stack())
+ time.Sleep(time.Second)
+ go a.interceptLoop()
+ }
+ }()
for {
select {
case evt := <-a.io.InputInterruptChan():
@@ -619,7 +636,6 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
a.personality != nil && a.personality.Content != "",
a.docStoreSize())
- // === Stage: pre_action — 上下文就绪,即将调用 LLM ===
if a.runStage(sdk.StagePreAction, stageCtx) {
return *stageCtx.Response, toolsUsed, nil
}
@@ -745,6 +761,23 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
resp.Content = stageCtx.LLMText
resp.ToolCalls = convertBackToolCalls(stageCtx.ToolCalls)
+ // === 发布完整 LLM 响应(含 tool_calls)供插件消费(如 webui 展示) ===
+ chainPayload := map[string]interface{}{
+ "content": resp.Content,
+ "reasoning": resp.ReasoningContent,
+ "tool_calls": resp.ToolCalls,
+ "phase": "intermediate",
+ "turn": turn,
+ }
+ if resp.TokenUsage.Total > 0 {
+ chainPayload["usage"] = map[string]int{
+ "prompt": resp.TokenUsage.Prompt,
+ "completion": resp.TokenUsage.Completion,
+ "total": resp.TokenUsage.Total,
+ }
+ }
+ a.publishEvent(events.EventAgentLLMChain, chainPayload)
+
if len(resp.ToolCalls) == 0 {
return resp.Content, toolsUsed, nil
}
@@ -754,7 +787,6 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
pluginName := a.resolveToolPlugin(tc.Name)
log.Printf("[agent] executing tool: %s (plugin=%s, id=%s)", tc.Name, pluginName, tc.ID)
- // === Stage: before_toolcall — 插件可拒绝/改参 ===
sdkTC := sdk.ToolCall{ID: tc.ID, Name: tc.Name, Plugin: pluginName, Arguments: tc.Arguments}
stageCtx.ToolCalls = []sdk.ToolCall{sdkTC}
stageCtx.ToolResults = nil
@@ -773,6 +805,14 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
}
tc.Arguments = stageCtx.ToolCalls[0].Arguments
+ if pluginName != "" && !a.pluginHealth.isHealthy(pluginName) {
+ result := fmt.Sprintf("插件 %s 处于崩溃状态,已跳过执行,等待自动恢复重载", pluginName)
+ log.Printf("[agent] skip tool %s: plugin %s unhealthy", tc.Name, pluginName)
+ msgs = append(msgs, agentAPI.Message{Role: "assistant", Content: resp.Content, ToolCalls: []agentAPI.ToolCall{tc}})
+ msgs = append(msgs, agentAPI.Message{Role: "tool", ToolCallID: tc.ID, Content: result})
+ continue
+ }
+
result := a.executeToolCall(tc)
log.Printf("[agent] tool %s result: %s", tc.Name, truncateStr(result, 100))
@@ -972,7 +1012,21 @@ func (a *Agent) buildMessages(sysPrompt, input string) []agentAPI.Message {
return msgs
}
-func (a *Agent) executeToolCall(tc agentAPI.ToolCall) string {
+func (a *Agent) executeToolCall(tc agentAPI.ToolCall) (ret string) {
+ defer func() {
+ if r := recover(); r != nil {
+ stack := debug.Stack()
+ log.Printf("[agent] tool %s panic: %v\n%s", tc.Name, r, stack)
+
+ if pluginName := a.resolveToolPlugin(tc.Name); pluginName != "" {
+ if a.pluginHealth.recordCrash(pluginName) {
+ log.Printf("[agent] plugin %s exceeded crash threshold, scheduling reload", pluginName)
+ }
+ }
+
+ ret = fmt.Sprintf("工具 %s 执行崩溃: %v", tc.Name, r)
+ }
+ }()
switch {
case strings.HasPrefix(tc.Name, "memory_"):
return a.executeMemoryTool(tc)
@@ -982,7 +1036,9 @@ func (a *Agent) executeToolCall(tc agentAPI.ToolCall) string {
return a.executeKnowledgeTool(tc)
case strings.HasPrefix(tc.Name, "doc_"):
return a.executeDocTool(tc)
- case tc.Name == "output_send":
+ case strings.HasPrefix(tc.Name, "output_send__") && strings.HasSuffix(tc.Name, "_help"):
+ return a.executeOutputSendHelp(tc)
+ case strings.HasPrefix(tc.Name, "output_send__"):
return a.executeOutputSendTool(tc)
case tc.Name == "output_list_channels":
return a.executeOutputListChannels()
@@ -1512,6 +1568,13 @@ func (a *Agent) buildSystemPrompt(memContext string, userInput string) string {
}
}
+ // 输出指令:使用 output_send__{channel} 作为回复手段
+ prompt += "\n\n【输出规则】你有多组输出门工具(type=output),每个对应一个输出通道。回复用户时必须调用对应的 output_send__{通道名} 工具。\n"
+ prompt += "- content 参数是 JSON 字符串,包含要发送的内容。具体格式因通道而异,用 output_send__{通道名}_help 查看每个通道的 JSON 格式说明。\n"
+ prompt += "- output_send__{通道名}_help 是普通 function 类型工具,调用后返回该通道的 JSON 格式详情和示例。\n"
+ prompt += "- 同一轮对话中可多次调用输出门工具。长消息应当分多次发出,而不是一口气发完。\n"
+ prompt += "- 直接返回纯文本不会到达任何用户端。"
+
if a.skills != nil {
if sp := a.skills.GetInjectedPrompt(); sp != "" {
prompt += "\n\n" + sp
@@ -1943,56 +2006,65 @@ func (a *Agent) buildToolDefs() []interface{} {
})
}
- // 输出通道工具 — 从已注册 Device 动态生成
+ // 输出通道工具 — 每注册通道生成两个工具:
+ // output_send__{name} (type=output) — 向该通道发送内容
+ // output_send__{name}_help (type=function) — 查看该通道的 JSON 格式说明
channels := a.io.ListChannels()
- chanNames := make([]interface{}, 0, len(channels))
- chanDesc := "输出通道名称: "
- for i, ch := range channels {
- if ch.Type == agentIO.DeviceOutput || ch.Type == agentIO.DeviceIO {
- chanNames = append(chanNames, ch.Name)
- if i > 0 {
- chanDesc += ", "
- }
- chanDesc += ch.Name
+ for _, ch := range channels {
+ if ch.Type != agentIO.DeviceOutput && ch.Type != agentIO.DeviceIO {
+ continue
}
- }
- if len(chanNames) == 0 {
- chanNames = []interface{}{"default"}
- chanDesc = "输出通道名称: default"
+ capStr := a.io.GetChannelCapabilities(ch.Name).String()
+ desc := ch.Description
+ if desc == "" {
+ desc = ch.Name + " 输出通道"
+ }
+
+ // 输出门工具
+ tools = append(tools, map[string]interface{}{
+ "type": "output",
+ "function": map[string]interface{}{
+ "name": "output_send__" + ch.Name,
+ "description": desc + "。能力: " + capStr + "。content 参数为 JSON 字符串,具体格式请调用 output_send__" + ch.Name + "_help 查看。",
+ "parameters": map[string]interface{}{
+ "type": "object",
+ "properties": map[string]interface{}{
+ "content": map[string]interface{}{
+ "type": "string",
+ "description": "JSON 字符串,包含要发送的内容和路由信息。格式因通道而异,用 _help 工具查看详情。",
+ },
+ },
+ "required": []string{"content"},
+ },
+ },
+ })
+
+ // 帮助工具
+ tools = append(tools, map[string]interface{}{
+ "type": "function",
+ "function": map[string]interface{}{
+ "name": "output_send__" + ch.Name + "_help",
+ "description": "查看 " + ch.Name + " 输出通道的 JSON 格式说明和示例",
+ "parameters": map[string]interface{}{
+ "type": "object",
+ "properties": map[string]interface{}{},
+ },
+ },
+ })
}
+ // output_list_channels — 列出所有可用输出通道
tools = append(tools, map[string]interface{}{
"type": "function",
"function": map[string]interface{}{
"name": "output_list_channels",
- "description": "列出所有可用输出通道及其能力(如 text/file/image/audio)和可调用工具。",
+ "description": "列出所有可用输出通道及其能力(如 text/file/image/audio)和对应的输出门工具名称。",
"parameters": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{},
},
},
})
- tools = append(tools, map[string]interface{}{
- "type": "function",
- "function": map[string]interface{}{
- "name": "output_send",
- "description": "通过指定输出通道立即发送一条消息,不等待主回复。用于异步通知、中间进度等场景。",
- "parameters": map[string]interface{}{
- "type": "object",
- "properties": map[string]interface{}{
- "channel": map[string]interface{}{
- "type": "string",
- "description": chanDesc,
- },
- "content": map[string]interface{}{
- "type": "string",
- "description": "消息内容",
- },
- },
- "required": []string{"channel", "content"},
- },
- },
- })
// 媒体处理工具:仅当本轮有未处理的媒体数据时注册
if a.pendingMedia != nil {
@@ -2075,6 +2147,13 @@ func (a *Agent) enqueueConsolidationTask(task ConsolidationTask) {
// distillLoop — 定期心跳:上下文→文档 + 图→文档 + 图重整
func (a *Agent) distillLoop() {
+ defer func() {
+ if r := recover(); r != nil {
+ log.Printf("[agent] distillLoop panic recovered: %v\n%s", r, debug.Stack())
+ time.Sleep(time.Second)
+ go a.distillLoop()
+ }
+ }()
if a.docStore == nil && a.memory == nil {
return
}
@@ -2088,6 +2167,7 @@ func (a *Agent) distillLoop() {
a.distillContext()
a.syncGraphToDocs()
a.reorgGraph()
+ a.autoReloadPlugins()
case <-a.ctx.Done():
return
}
@@ -2461,24 +2541,90 @@ func (a *Agent) processConsolidation(input string) {
// executeOutputSendTool — AI 通过指定通道发送消息(校验通道能力)
func (a *Agent) executeOutputSendTool(tc agentAPI.ToolCall) string {
- channel, _ := tc.Arguments["channel"].(string)
+ // tool name is "output_send__{channel}"
+ channel := strings.TrimPrefix(tc.Name, "output_send__")
content, _ := tc.Arguments["content"].(string)
if channel == "" || content == "" {
- return "channel 和 content 不能为空"
+ return "工具名称格式: output_send__{channel},content 不能为空"
}
+ // content 是一个 JSON 字符串,插件通过解析它确定如何发送消息
+ // === Stage: before_output — 输出前插件可审查/改写/拦截 ===
+ stageCtx := &sdk.StageContext{
+ FinalText: content,
+ Phase: sdk.StageBeforeOutput,
+ }
+ a.runStage(sdk.StageBeforeOutput, stageCtx)
+ if stageCtx.Response != nil {
+ return fmt.Sprintf("输出被插件拦截: %s", *stageCtx.Response)
+ }
+ content = stageCtx.FinalText
+ if content == "" {
+ return "输出被插件清空"
+ }
+ tc.Arguments["content"] = content
+
+ // 通道能力检查
caps := a.io.GetChannelCapabilities(channel)
if caps == 0 {
- return fmt.Sprintf("通道 [%s] 不存在或不可用。可用通道请用 output_list_channels 查看", channel)
+ return fmt.Sprintf("通道 [%s] 不存在或不可用。可用输出工具列表见 output_list_channels", channel)
}
if !caps.Supports(agentIO.CapText) {
return fmt.Sprintf("通道 [%s] 不支持文本输出(能力: %s)", channel, caps.String())
}
+ // 通过设备处理器投递
+ if dev := a.io.GetDevice(channel); dev != nil {
+ result, err := dev.Execute("output", tc.Arguments)
+ if err != nil {
+ return fmt.Sprintf("通过 [%s] 通道发送失败: %v", channel, err)
+ }
+ return fmt.Sprintf("已通过 [%s] 通道发送: %v", channel, result)
+ }
+
+ // 降级:发送到 outputCh(供 OutputChan 消费者)
a.io.EmitTextTo("agent_io", channel, content)
return fmt.Sprintf("已通过 [%s] 通道发送", channel)
}
+// executeOutputSendHelp — 返回指定通道的 JSON 格式说明
+func (a *Agent) executeOutputSendHelp(tc agentAPI.ToolCall) string {
+ // tool name is "output_send__{channel}_help"
+ suffix := strings.TrimPrefix(tc.Name, "output_send__")
+ channel := strings.TrimSuffix(suffix, "_help")
+ if channel == "" {
+ return "工具名称格式: output_send__{channel}_help"
+ }
+
+ dev := a.io.GetDevice(channel)
+ if dev == nil {
+ return fmt.Sprintf("通道 [%s] 不存在", channel)
+ }
+
+ caps := a.io.GetChannelCapabilities(channel)
+ capStr := "无"
+ if caps != 0 {
+ capStr = caps.String()
+ }
+
+ desc := dev.Description()
+ if desc == "" {
+ desc = channel + " 输出通道"
+ }
+
+ return fmt.Sprintf(`通道 [%s]
+描述: %s
+能力: %s
+
+【content JSON 格式说明】
+发送到此通道时 content 必须是 JSON 字符串,包含以下字段:
+- "content": 消息正文(必填)
+- 根据通道不同可能还需要路由字段(如 "group_id", "user_id" 等)
+
+请在通道描述中查看具体字段要求。
+示例: {"content":"你好"}`, channel, desc, capStr)
+}
+
// executeOutputListChannels — 列出所有可用通道及其能力
func (a *Agent) executeOutputListChannels() string {
channels := a.io.ListChannels()
@@ -2520,6 +2666,28 @@ func (a *Agent) executePluginReload() string {
return msg
}
+func (a *Agent) autoReloadPlugins() {
+ if a.pluginReg == nil {
+ return
+ }
+ for _, name := range a.pluginHealth.pendingReloads() {
+ if !a.pluginReg.AutoRestartEnabled(name) {
+ log.Printf("[agent] skip auto-reload plugin %s: auto-restart disabled by plugin", name)
+ continue
+ }
+ log.Printf("[agent] auto-reloading unhealthy plugin: %s", name)
+ if a.stageHost != nil {
+ a.stageHost.UnregisterPluginTools(name)
+ }
+ if err := a.pluginReg.ReloadOne(name); err != nil {
+ log.Printf("[agent] auto-reload plugin %s failed: %v", name, err)
+ } else {
+ a.pluginHealth.markReloaded(name)
+ log.Printf("[agent] plugin %s reloaded successfully", name)
+ }
+ }
+}
+
// executeSpawnChild 创建子 Agent 异步执行独立任务
// 不阻塞主 Agent,子任务完成后通过 selfInputCh 通知主 Agent 查看结果
func (a *Agent) executeSpawnChild(tc agentAPI.ToolCall) string {
@@ -2560,7 +2728,6 @@ func (a *Agent) runChildTask(taskID, task string) {
// 子 Agent 可调用核心以外的全部工具(记忆/知识/文档/社交),但不能调用输出工具
allTools := a.buildToolDefs()
childTools := make([]interface{}, 0, len(allTools))
- outputTools := map[string]bool{"output_send": true, "output_list_channels": true, "spawn_child": true, "plgreload": true}
for _, t := range allTools {
toolMap, ok := t.(map[string]interface{})
if !ok {
@@ -2571,9 +2738,10 @@ func (a *Agent) runChildTask(taskID, task string) {
continue
}
name, _ := fn["name"].(string)
- if !outputTools[name] {
- childTools = append(childTools, t)
+ if strings.HasPrefix(name, "output_send__") || name == "output_list_channels" || name == "spawn_child" || name == "plgreload" {
+ continue
}
+ childTools = append(childTools, t)
}
var finalResult string
@@ -2604,7 +2772,7 @@ func (a *Agent) runChildTask(taskID, task string) {
for _, ct := range resp.ToolCalls {
var result string
switch {
- case ct.Name == "output_send" || ct.Name == "output_list_channels":
+ case strings.HasPrefix(ct.Name, "output_send__") || ct.Name == "output_list_channels":
result = fmt.Sprintf("子 Agent 不允许调用输出工具: %s", ct.Name)
case ct.Name == "spawn_child" || ct.Name == "plgreload":
result = fmt.Sprintf("子 Agent 不允许调用系统工具: %s", ct.Name)
@@ -2796,7 +2964,14 @@ func (a *Agent) runStage(stage sdk.Stage, ctx *sdk.StageContext) bool {
return false
}
ctx.Phase = stage
- a.stageHost.RunStage(stage, ctx)
+ func() {
+ defer func() {
+ if r := recover(); r != nil {
+ log.Printf("[agent] stage %q plugin panic: %v\n%s", stage, r, debug.Stack())
+ }
+ }()
+ a.stageHost.RunStage(stage, ctx)
+ }()
return ctx.Response != nil
}
diff --git a/internal/agent/core/agent_tools_test.go b/internal/agent/core/agent_tools_test.go
index 221b5c2..4d8a403 100644
--- a/internal/agent/core/agent_tools_test.go
+++ b/internal/agent/core/agent_tools_test.go
@@ -1,13 +1,13 @@
package core
import (
+ "strings"
"testing"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
)
-// mockOutputDevice implements agentIO.Device for testing output tools
type mockOutputDevice struct {
name string
caps agentIO.OutputCapability
@@ -61,24 +61,11 @@ func TestExecuteOutputSendTool(t *testing.T) {
})
a := &Agent{io: io}
- tc := agentAPI.ToolCall{Name: "output_send", Arguments: map[string]interface{}{
- "channel": "screen",
- "content": "hello world",
+ tc := agentAPI.ToolCall{Name: "output_send__screen", Arguments: map[string]interface{}{
+ "content": `{"content":"hello world"}`,
}}
result := a.executeOutputSendTool(tc)
- if result != "已通过 [screen] 通道发送" {
- t.Errorf("unexpected result: %s", result)
- }
-}
-
-func TestExecuteOutputSendToolMissingChannel(t *testing.T) {
- io := agentIO.NewIOManager()
- a := &Agent{io: io}
- tc := agentAPI.ToolCall{Name: "output_send", Arguments: map[string]interface{}{
- "content": "hello",
- }}
- result := a.executeOutputSendTool(tc)
- if result != "channel 和 content 不能为空" {
+ if !strings.Contains(result, "screen") {
t.Errorf("unexpected result: %s", result)
}
}
@@ -86,25 +73,22 @@ func TestExecuteOutputSendToolMissingChannel(t *testing.T) {
func TestExecuteOutputSendToolEmptyContent(t *testing.T) {
io := agentIO.NewIOManager()
a := &Agent{io: io}
- tc := agentAPI.ToolCall{Name: "output_send", Arguments: map[string]interface{}{
- "channel": "screen",
- }}
+ tc := agentAPI.ToolCall{Name: "output_send__screen", Arguments: map[string]interface{}{}}
result := a.executeOutputSendTool(tc)
- if result != "channel 和 content 不能为空" {
- t.Errorf("unexpected result: %s", result)
+ if result == "" || strings.Contains(result, "已通过") {
+ t.Errorf("expected error for empty content, got: %s", result)
}
}
func TestExecuteOutputSendToolChannelNotExist(t *testing.T) {
io := agentIO.NewIOManager()
a := &Agent{io: io}
- tc := agentAPI.ToolCall{Name: "output_send", Arguments: map[string]interface{}{
- "channel": "nonexistent",
+ tc := agentAPI.ToolCall{Name: "output_send__nonexistent", Arguments: map[string]interface{}{
"content": "hello",
}}
result := a.executeOutputSendTool(tc)
- if result != "通道 [nonexistent] 不存在或不可用。可用通道请用 output_list_channels 查看" {
- t.Errorf("unexpected result: %s", result)
+ if !strings.Contains(result, "不存在") && !strings.Contains(result, "不可用") {
+ t.Errorf("expected error for nonexistent channel, got: %s", result)
}
}
@@ -116,23 +100,26 @@ func TestExecuteOutputSendToolNoTextCap(t *testing.T) {
})
a := &Agent{io: io}
- tc := agentAPI.ToolCall{Name: "output_send", Arguments: map[string]interface{}{
- "channel": "camera",
+ tc := agentAPI.ToolCall{Name: "output_send__camera", Arguments: map[string]interface{}{
"content": "hello",
}}
result := a.executeOutputSendTool(tc)
- if result == "已通过 [camera] 通道发送" {
+ if strings.Contains(result, "已通过") {
t.Errorf("should reject channel without text capability")
}
}
-func TestBuildToolDefsOutputToolsAlwaysPresent(t *testing.T) {
+func TestBuildToolDefsOutputToolsWithDevice(t *testing.T) {
io := agentIO.NewIOManager()
+ io.RegisterDevice(&mockOutputDevice{
+ name: "screen",
+ caps: agentIO.CapText,
+ })
a := &Agent{io: io, knowledge: nil, docStore: nil, pluginReg: nil}
tools := a.buildToolDefs()
foundSend := false
- foundList := false
+ foundHelp := false
for _, td := range tools {
m, ok := td.(map[string]interface{})
if !ok {
@@ -144,17 +131,17 @@ func TestBuildToolDefsOutputToolsAlwaysPresent(t *testing.T) {
}
name, _ := fn["name"].(string)
switch name {
- case "output_send":
+ case "output_send__screen":
foundSend = true
- case "output_list_channels":
- foundList = true
+ case "output_send__screen_help":
+ foundHelp = true
}
}
if !foundSend {
- t.Error("output_send should always be in tools")
+ t.Error("output_send__screen should be in tools when device registered")
}
- if !foundList {
- t.Error("output_list_channels should always be in tools")
+ if !foundHelp {
+ t.Error("output_send__screen_help should be in tools when device registered")
}
}
@@ -162,8 +149,7 @@ func TestGetAllToolsEmpty(t *testing.T) {
io := agentIO.NewIOManager()
a := &Agent{io: io}
tools := a.buildToolDefs()
- // should have at least output_send, output_list_channels
- if len(tools) < 2 {
- t.Errorf("expected at least 2 tools, got %d", len(tools))
+ if len(tools) < 1 {
+ t.Errorf("expected at least 1 tool, got %d", len(tools))
}
}
diff --git a/internal/agent/core/context.go b/internal/agent/core/context.go
index cb1ff7f..b7a7ec0 100644
--- a/internal/agent/core/context.go
+++ b/internal/agent/core/context.go
@@ -10,6 +10,7 @@ import (
"sync"
"time"
+ "gitcode.com/JianFeeeee/HomeAgent/internal/memory"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
)
@@ -28,18 +29,18 @@ const contextFlushInterval = 5 * time.Second
// RelevanceContext — 基于相关性的上下文管理,非固定阈值
type RelevanceContext struct {
- mu sync.Mutex
- events []*ContextEvent
- veczer *vector.TFIDFVectorizer
- trained bool
- savePath string // 持久化路径,空则不持久化
+ mu sync.Mutex
+ events []*ContextEvent
+ embedder *memory.LocalWordEmbedder
+ trained bool
+ savePath string
saveTimer *time.Timer
- dirty bool
+ dirty bool
}
func NewRelevanceContext(savePath string) *RelevanceContext {
rc := &RelevanceContext{
- veczer: vector.NewTFIDFVectorizer(2),
+ embedder: memory.NewLocalWordEmbedder(),
savePath: savePath,
}
if savePath != "" {
@@ -59,7 +60,7 @@ func (c *RelevanceContext) load() {
return
}
for _, evt := range events {
- evt.Vector = c.veczer.Vectorize(evt.Input + " " + evt.Response)
+ evt.Vector = c.embedder.Vectorize(evt.Input + " " + evt.Response)
}
c.events = events
}
@@ -83,10 +84,9 @@ func (c *RelevanceContext) Append(evt ContextEvent) {
c.mu.Lock()
defer c.mu.Unlock()
- evt.Vector = c.veczer.Vectorize(evt.Input + " " + evt.Response)
+ evt.Vector = c.embedder.Vectorize(evt.Input + " " + evt.Response)
c.events = append(c.events, &evt)
- // 增量训练向量化器
c.trained = false
c.save()
@@ -146,10 +146,9 @@ func (c *RelevanceContext) Prune(currentInput string, topK int, docStore *docume
return 0
}
- // 确保向量化器已训练
c.ensureTrained()
- queryVec := c.veczer.Vectorize(currentInput)
+ queryVec := c.embedder.Vectorize(currentInput)
// 计算每条候选上下文与当前输入的相关性
type scored struct {
@@ -263,10 +262,9 @@ func (c *RelevanceContext) ensureTrained() {
for i, evt := range c.events {
texts[i] = evt.Input + " " + evt.Response
}
- c.veczer.Train(texts)
- // 重算所有事件向量,与新的向量化器特征空间对齐
+ c.embedder.Train(texts)
for _, evt := range c.events {
- evt.Vector = c.veczer.Vectorize(evt.Input + " " + evt.Response)
+ evt.Vector = c.embedder.Vectorize(evt.Input + " " + evt.Response)
}
c.trained = true
}
diff --git a/internal/agent/core/plugin_health.go b/internal/agent/core/plugin_health.go
new file mode 100644
index 0000000..39bbe27
--- /dev/null
+++ b/internal/agent/core/plugin_health.go
@@ -0,0 +1,128 @@
+package core
+
+import (
+ "log"
+ "sync"
+ "time"
+)
+
+const (
+ maxPluginCrashes = 3
+ crashWindow = 5 * time.Minute
+ reloadCooldown = 30 * time.Second
+)
+
+type pluginHealthTracker struct {
+ mu sync.Mutex
+ records map[string]*pluginHealthRecord
+}
+
+type pluginHealthRecord struct {
+ CrashCount int
+ FirstCrash time.Time
+ LastCrash time.Time
+ Unhealthy bool
+ LastReload time.Time
+}
+
+func newPluginHealthTracker() *pluginHealthTracker {
+ return &pluginHealthTracker{
+ records: make(map[string]*pluginHealthRecord),
+ }
+}
+
+// recordCrash 记录一次崩溃,返回 true 表示需要触发重载
+func (t *pluginHealthTracker) recordCrash(plugin string) bool {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+
+ now := time.Now()
+ r, ok := t.records[plugin]
+ if !ok {
+ r = &pluginHealthRecord{}
+ t.records[plugin] = r
+ }
+
+ if now.Sub(r.LastCrash) > crashWindow {
+ r.CrashCount = 0
+ r.FirstCrash = now
+ }
+
+ r.CrashCount++
+ r.LastCrash = now
+
+ if r.CrashCount >= maxPluginCrashes {
+ r.Unhealthy = true
+ log.Printf("[plugin] %s: %d crashes within %v, marking unhealthy", plugin, r.CrashCount, crashWindow)
+ return true
+ }
+
+ log.Printf("[plugin] %s: crash #%d", plugin, r.CrashCount)
+ return false
+}
+
+// isHealthy 检查插件是否健康;冷却期后自动恢复
+func (t *pluginHealthTracker) isHealthy(plugin string) bool {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+
+ r, ok := t.records[plugin]
+ if !ok {
+ return true
+ }
+ if !r.Unhealthy {
+ return true
+ }
+ if time.Since(r.LastReload) > reloadCooldown {
+ r.Unhealthy = false
+ r.CrashCount = 0
+ log.Printf("[plugin] %s: cooldown passed, restored to healthy", plugin)
+ return true
+ }
+ return false
+}
+
+// markReloaded 标记插件已重载
+func (t *pluginHealthTracker) markReloaded(plugin string) {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+
+ r, ok := t.records[plugin]
+ if ok {
+ r.Unhealthy = false
+ r.CrashCount = 0
+ r.LastReload = time.Now()
+ }
+}
+
+// pendingReloads 返回已过冷却期、需要重载的插件列表
+func (t *pluginHealthTracker) pendingReloads() []string {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+
+ var result []string
+ now := time.Now()
+ for name, r := range t.records {
+ if !r.Unhealthy {
+ continue
+ }
+ if now.Sub(r.LastReload) > reloadCooldown {
+ result = append(result, name)
+ }
+ }
+ return result
+}
+
+// unhealthyPlugins 返回当前所有不健康的插件名
+func (t *pluginHealthTracker) unhealthyPlugins() []string {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+
+ var result []string
+ for name, r := range t.records {
+ if r.Unhealthy {
+ result = append(result, name)
+ }
+ }
+ return result
+}
diff --git a/internal/agent/core/stages.go b/internal/agent/core/stages.go
index a16a328..de8adb4 100644
--- a/internal/agent/core/stages.go
+++ b/internal/agent/core/stages.go
@@ -3,6 +3,7 @@ package core
import (
"fmt"
"log"
+ "runtime/debug"
"sync"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
@@ -53,7 +54,13 @@ func (h *StageHost) GetToolDefs() []sdk.ToolDef {
return defs
}
-func (h *StageHost) ExecuteTool(name string, args map[string]interface{}) (interface{}, error) {
+func (h *StageHost) ExecuteTool(name string, args map[string]interface{}) (ret interface{}, err error) {
+ defer func() {
+ if r := recover(); r != nil {
+ log.Printf("[stage] tool %s handler panic: %v\n%s", name, r, debug.Stack())
+ err = fmt.Errorf("tool %s handler panic: %v", name, r)
+ }
+ }()
h.mu.RLock()
handler, ok := h.tools[name]
h.mu.RUnlock()
@@ -72,6 +79,22 @@ func (h *StageHost) ToolPlugin(name string) string {
return h.toolPlugins[name]
}
+func (h *StageHost) UnregisterPluginTools(pluginName string) {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+
+ var keepDefs []sdk.ToolDef
+ for _, def := range h.toolDefs {
+ if def.Plugin == pluginName {
+ delete(h.tools, def.Name)
+ delete(h.toolPlugins, def.Name)
+ } else {
+ keepDefs = append(keepDefs, def)
+ }
+ }
+ h.toolDefs = keepDefs
+}
+
func inferToolPlugin(name string) string {
for i := 0; i < len(name); i++ {
if name[i] == '_' {
@@ -100,6 +123,11 @@ func (h *StageHost) RunStage(stage sdk.Stage, ctx *sdk.StageContext) {
wg.Add(1)
go func(fn sdk.StageHandler) {
defer wg.Done()
+ defer func() {
+ if r := recover(); r != nil {
+ log.Printf("[stage] handler panic: %v", r)
+ }
+ }()
if err := fn(ctx); err != nil {
errCh <- err
}
diff --git a/internal/agent/io/channel.go b/internal/agent/io/channel.go
index 589582b..7a66ecb 100644
--- a/internal/agent/io/channel.go
+++ b/internal/agent/io/channel.go
@@ -3,6 +3,7 @@ package io
import (
"fmt"
"log"
+ "runtime/debug"
"sync"
"time"
)
@@ -143,6 +144,12 @@ func (m *IOManager) RegisterDevice(dev Device) error {
return nil
}
+func (m *IOManager) GetDevice(name string) Device {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ return m.devices[name]
+}
+
func (m *IOManager) StartAll() error {
m.mu.RLock()
devices := make([]Device, 0, len(m.devices))
@@ -336,7 +343,7 @@ func (m *IOManager) GetAllTools() []ToolDef {
return tools
}
-func (m *IOManager) ExecuteTool(name string, args map[string]interface{}) (interface{}, error) {
+func (m *IOManager) ExecuteTool(name string, args map[string]interface{}) (ret interface{}, err error) {
m.mu.RLock()
type nameDevice struct {
name string
@@ -356,6 +363,12 @@ func (m *IOManager) ExecuteTool(name string, args map[string]interface{}) (inter
if len(candidates) == 0 {
return nil, fmt.Errorf("tool %s not found", name)
}
+ defer func() {
+ if r := recover(); r != nil {
+ log.Printf("[io] tool %s execute panic: %v\n%s", name, r, debug.Stack())
+ err = fmt.Errorf("tool %s execute panic: %v", name, r)
+ }
+ }()
return candidates[0].dev.Execute(name, args)
}
diff --git a/internal/config/registry.go b/internal/config/registry.go
index 2f8983b..d7dec02 100644
--- a/internal/config/registry.go
+++ b/internal/config/registry.go
@@ -350,8 +350,9 @@ func (r *ConfigRegistry) seedDBValues(dataDir string) {
set("core.agent.workdir", "")
set("core.agent.system_prompt", `你是 HomeAgent,一个持续运行的个人管家。
你的回复默认发送到用户的输入来源,无需额外工具。
-如需异步发送消息到其他通道,使用 output_send。
-使用 output_list_channels 查看可用通道。
+输出回复请使用 output_send__{通道名} 工具,content 为 JSON 字符串。用 output_list_channels 查看可用通道。
+使用 output_send__{通道名}_help 查看每个通道的 JSON 格式说明。
+输出通道可多次调用,长消息应当分多次发出而不是一口气发完。
当用户上传图片或音频时,系统会自动附着媒体内容。如果模型不支持直接处理多媒体,请调用对应的媒体处理工具。
diff --git a/internal/events/bus.go b/internal/events/bus.go
index 6b87b4b..214b459 100644
--- a/internal/events/bus.go
+++ b/internal/events/bus.go
@@ -2,18 +2,21 @@ package events
import (
"fmt"
+ "log"
"sync"
)
type EventType string
const (
- EventRawInput EventType = "raw_input"
- EventAgentOutput EventType = "agent_output"
- EventToolCall EventType = "tool_call"
- EventReasoning EventType = "reasoning"
- EventSystem EventType = "system"
- EventAll EventType = "*"
+ EventRawInput EventType = "raw_input"
+ EventAgentOutput EventType = "agent_output"
+ EventAgentLLMChain EventType = "agent_llm_chain"
+ EventToolCall EventType = "tool_call"
+ EventReasoning EventType = "reasoning"
+ EventStage EventType = "stage"
+ EventSystem EventType = "system"
+ EventAll EventType = "*"
)
type Event struct {
@@ -45,13 +48,22 @@ func (b *Bus) Publish(evt *Event) {
b.mu.RUnlock()
for _, h := range allHandlers {
- h(evt)
+ b.safeCall(h, evt)
}
for _, h := range typeHandlers {
- h(evt)
+ b.safeCall(h, evt)
}
}
+func (b *Bus) safeCall(h Handler, evt *Event) {
+ defer func() {
+ if r := recover(); r != nil {
+ log.Printf("[bus] handler panic: %v", r)
+ }
+ }()
+ h(evt)
+}
+
func (b *Bus) Subscribe(eventType EventType, handler Handler) func() {
b.mu.Lock()
b.subs[eventType] = append(b.subs[eventType], handler)
diff --git a/internal/knowledge/knowledge.go b/internal/knowledge/knowledge.go
index c883e7e..a90d60d 100644
--- a/internal/knowledge/knowledge.go
+++ b/internal/knowledge/knowledge.go
@@ -11,6 +11,7 @@ import (
"sync"
"time"
+ "gitcode.com/JianFeeeee/HomeAgent/internal/memory"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
)
@@ -165,7 +166,7 @@ func (s *Store) Add(name, content string) error {
Content: content,
Path: path,
Category: sanitize(category),
- Tags: extractKeywords(name + " " + content),
+ Tags: memory.ExtractKeywords(name + " " + content),
UpdatedAt: now,
}
s.items[id] = k
@@ -381,7 +382,7 @@ func (s *Store) scanDir(category, dirName string) {
Content: content,
Path: contentPath,
Category: category,
- Tags: extractKeywords(dirName + " " + content),
+ Tags: memory.ExtractKeywords(dirName + " " + content),
UpdatedAt: now,
}
s.items[name] = k
@@ -411,33 +412,4 @@ func sanitize(name string) string {
return name
}
-func extractKeywords(text string) []string {
- stopWords := map[string]bool{
- "的": true, "了": true, "是": true, "在": true, "有": true,
- "和": true, "就": true, "不": true, "都": true,
- "一": true, "一个": true, "也": true, "很": true,
- "到": true, "说": true, "要": true, "去": true,
- "会": true, "着": true, "没有": true, "看": true, "好": true,
- "自己": true, "这": true, "他": true, "她": true, "它": true,
- "什么": true, "怎么": true, "为什么": true, "如何": true,
- "我们": true, "你们": true, "他们": true, "这个": true,
- "那个": true, "可以": true, "吗": true, "吧": true, "啊": true,
- }
- var keywords []string
- runes := []rune(text)
- seen := make(map[string]bool)
-
- for i := 0; i < len(runes)-1; i++ {
- word := string(runes[i : i+2])
- if !stopWords[word] && len(strings.TrimSpace(word)) == len(word) && !seen[word] {
- seen[word] = true
- keywords = append(keywords, word)
- }
- }
-
- if len(keywords) > 10 {
- keywords = keywords[:10]
- }
- return keywords
-}
diff --git a/internal/memory/cut.go b/internal/memory/cut.go
new file mode 100644
index 0000000..a1f5918
--- /dev/null
+++ b/internal/memory/cut.go
@@ -0,0 +1,118 @@
+package memory
+
+import (
+ "log"
+ "os"
+ "path/filepath"
+ "sync"
+
+ "github.com/yanyiwu/gojieba"
+)
+
+var (
+ jiebaOnce sync.Once
+ jiebaInst *gojieba.Jieba
+)
+
+func GetJieba() *gojieba.Jieba {
+ jiebaOnce.Do(func() {
+ defer func() {
+ if r := recover(); r != nil {
+ log.Printf("[jieba] init panic recovered: %v", r)
+ }
+ }()
+ d := jiebaDictDir()
+ if d == "" {
+ log.Printf("[jieba] no dictionary directory found, jieba disabled")
+ return
+ }
+ jiebaInst = gojieba.NewJieba(
+ filepath.Join(d, "jieba.dict.utf8"),
+ filepath.Join(d, "hmm_model.utf8"),
+ filepath.Join(d, "user.dict.utf8"),
+ filepath.Join(d, "idf.utf8"),
+ filepath.Join(d, "stop_words.utf8"),
+ )
+ })
+ return jiebaInst
+}
+
+func jiebaDictDir() string {
+ candidates := []string{
+ os.Getenv("GOMODCACHE"),
+ os.Getenv("GOPATH"),
+ filepath.Join(os.Getenv("HOME"), "go"),
+ "/root/go",
+ "/go",
+ "/home/program/go",
+ }
+ for _, base := range candidates {
+ if base == "" {
+ continue
+ }
+ d := filepath.Join(base, "pkg", "mod", "github.com", "yanyiwu", "gojieba@v1.4.7", "deps", "cppjieba", "dict")
+ if info, err := os.Stat(d); err == nil && info.IsDir() {
+ return d
+ }
+ // also try without "pkg/mod" (in case GOPATH is already the mod cache)
+ d2 := filepath.Join(base, "github.com", "yanyiwu", "gojieba@v1.4.7", "deps", "cppjieba", "dict")
+ if info, err := os.Stat(d2); err == nil && info.IsDir() {
+ return d2
+ }
+ }
+ return ""
+}
+
+var stopWords = map[string]bool{
+ "的": true, "了": true, "是": true, "在": true, "有": true,
+ "和": true, "就": true, "不": true, "人": true, "都": true,
+ "一": true, "一个": true, "上": true, "也": true, "很": true,
+ "到": true, "说": true, "要": true, "去": true, "你": true,
+ "会": true, "着": true, "没有": true, "看": true, "好": true,
+ "自己": true, "这": true, "他": true, "她": true, "它": true,
+ "我": true, "我们": true, "你们": true, "他们": true,
+ "吗": true, "吧": true, "啊": true,
+ "嗯": true, "哦": true, "哈": true, "呀": true, "嘛": true,
+ "然后": true, "因为": true, "所以": true, "如果": true, "但是": true,
+ "可能": true, "还是": true, "已经": true,
+ "就是": true, "不是": true, "是的": true,
+ "非常": true, "比较": true, "应该": true, "需要": true,
+ "能够": true, "目前": true, "现在": true, "今天": true, "昨天": true,
+ "明天": true, "知道": true, "觉得": true, "认为": true,
+ "能": true, "没": true, "对": true,
+ "the": true, "a": true, "an": true, "is": true, "are": true,
+ "was": true, "were": true, "be": true, "been": true, "being": true,
+ "have": true, "has": true, "had": true, "do": true, "does": true,
+ "did": true, "will": true, "would": true, "could": true, "should": true,
+ "may": true, "might": true, "can": true, "shall": true, "this": true,
+ "that": true, "these": true, "those": true, "it": true, "its": true,
+ "and": true, "or": true, "but": true, "in": true, "on": true,
+ "at": true, "to": true, "for": true, "of": true, "with": true,
+ "what": true, "how": true, "why": true, "which": true, "where": true,
+ "when": true, "who": true, "whom": true,
+}
+
+func ExtractKeywords(text string) []string {
+ x := GetJieba()
+ if x == nil {
+ return nil
+ }
+ words := x.Cut(text, true)
+ var keywords []string
+ seen := make(map[string]bool)
+ for _, w := range words {
+ if stopWords[w] || seen[w] {
+ continue
+ }
+ r := []rune(w)
+ if len(r) < 2 {
+ continue
+ }
+ seen[w] = true
+ keywords = append(keywords, w)
+ }
+ if len(keywords) > 5 {
+ keywords = keywords[:5]
+ }
+ return keywords
+}
diff --git a/internal/memory/document/document.go b/internal/memory/document/document.go
index b7957a2..3d494e6 100644
--- a/internal/memory/document/document.go
+++ b/internal/memory/document/document.go
@@ -11,6 +11,7 @@ import (
"sync"
"time"
+ "gitcode.com/JianFeeeee/HomeAgent/internal/memory"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
)
@@ -388,7 +389,7 @@ func summarizeEntries(entries []ContextEntry) string {
var topics []string
for _, e := range entries {
sources[e.Source]++
- words := extractKeywords(e.Content)
+ words := memory.ExtractKeywords(e.Content)
topics = append(topics, words...)
}
@@ -420,7 +421,7 @@ func summarizeEntries(entries []ContextEntry) string {
func extractTags(entries []ContextEntry) []string {
tagSet := make(map[string]bool)
for _, e := range entries {
- for _, kw := range extractKeywords(e.Content) {
+ for _, kw := range memory.ExtractKeywords(e.Content) {
tagSet[kw] = true
}
}
@@ -439,7 +440,7 @@ func extractEntities(entries []ContextEntry) []string {
var entities []string
seen := make(map[string]bool)
for _, e := range entries {
- for _, kw := range extractKeywords(e.Content) {
+ for _, kw := range memory.ExtractKeywords(e.Content) {
if len(kw) >= 2 && !seen[kw] {
seen[kw] = true
entities = append(entities, kw)
@@ -452,32 +453,6 @@ func extractEntities(entries []ContextEntry) []string {
return entities
}
-func extractKeywords(text string) []string {
- stopWords := map[string]bool{
- "的": true, "了": true, "是": true, "在": true, "有": true,
- "和": true, "就": true, "不": true, "人": true, "都": true,
- "一": true, "一个": true, "上": true, "也": true, "很": true,
- "到": true, "说": true, "要": true, "去": true, "你": true,
- "会": true, "着": true, "没有": true, "看": true, "好": true,
- "自己": true, "这": true, "他": true, "她": true, "它": true,
- "什么": true, "怎么": true, "为什么": true, "如何": true,
- "我": true, "我们": true, "你们": true, "他们": true, "这个": true,
- "那个": true, "可以": true, "吗": true, "吧": true, "啊": true,
- }
-
- var keywords []string
- runes := []rune(text)
-
- // bi-gram
- for i := 0; i < len(runes)-1; i++ {
- word := string(runes[i : i+2])
- if !stopWords[word] && len(strings.TrimSpace(word)) == len(word) {
- keywords = append(keywords, word)
- }
- }
- return keywords
-}
-
func truncate(s string, max int) string {
runes := []rune(s)
if len(runes) > max {
diff --git a/internal/memory/document/document_test.go b/internal/memory/document/document_test.go
index 582707f..a3d1513 100644
--- a/internal/memory/document/document_test.go
+++ b/internal/memory/document/document_test.go
@@ -4,6 +4,8 @@ import (
"os"
"testing"
"time"
+
+ "gitcode.com/JianFeeeee/HomeAgent/internal/memory"
)
func TestInsertAndQuery(t *testing.T) {
@@ -186,7 +188,7 @@ func TestSummarizeEntries(t *testing.T) {
}
func TestExtractKeywords(t *testing.T) {
- kws := extractKeywords("今天天气很好")
+ kws := memory.ExtractKeywords("今天天气很好")
if len(kws) == 0 {
t.Error("should extract keywords from Chinese text")
}
diff --git a/internal/memory/embedder.go b/internal/memory/embedder.go
new file mode 100644
index 0000000..fab68f9
--- /dev/null
+++ b/internal/memory/embedder.go
@@ -0,0 +1,221 @@
+package memory
+
+import (
+ "math"
+ "sort"
+ "strings"
+ "sync"
+
+ "github.com/yanyiwu/gojieba"
+ "gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
+)
+
+type LocalWordEmbedder struct {
+ mu sync.RWMutex
+ jieba *gojieba.Jieba
+ stopWords map[string]bool
+
+ docFreq map[string]float64
+ totalDocs int
+
+ coOccur map[string]map[string]float64
+
+ vocab map[string]bool
+ trained bool
+}
+
+func NewLocalWordEmbedder() *LocalWordEmbedder {
+ sw := make(map[string]bool)
+ for k, v := range stopWords {
+ sw[k] = v
+ }
+ return &LocalWordEmbedder{
+ jieba: GetJieba(),
+ stopWords: sw,
+ docFreq: make(map[string]float64),
+ coOccur: make(map[string]map[string]float64),
+ vocab: make(map[string]bool),
+ }
+}
+
+func (e *LocalWordEmbedder) tokenize(text string) []string {
+ if e.jieba == nil {
+ return nil
+ }
+ words := e.jieba.Cut(text, true)
+ var result []string
+ seen := make(map[string]bool)
+ for _, w := range words {
+ w = strings.TrimSpace(w)
+ if w == "" || e.stopWords[w] || seen[w] {
+ continue
+ }
+ runes := []rune(w)
+ if len(runes) < 2 {
+ continue
+ }
+ seen[w] = true
+ result = append(result, w)
+ }
+ return result
+}
+
+func (e *LocalWordEmbedder) Train(docs []string) {
+ if e.jieba == nil {
+ return
+ }
+ e.mu.Lock()
+ defer e.mu.Unlock()
+
+ e.docFreq = make(map[string]float64)
+ e.coOccur = make(map[string]map[string]float64)
+ e.vocab = make(map[string]bool)
+
+ tokenized := make([][]string, len(docs))
+
+ for i, doc := range docs {
+ tokens := e.tokenize(doc)
+ tokenized[i] = tokens
+
+ seen := make(map[string]bool)
+ for _, t := range tokens {
+ e.vocab[t] = true
+ if !seen[t] {
+ e.docFreq[t]++
+ seen[t] = true
+ }
+ }
+ }
+ e.totalDocs = len(docs)
+
+ windowSize := 5
+ for _, tokens := range tokenized {
+ for i, word := range tokens {
+ start := i - windowSize
+ if start < 0 {
+ start = 0
+ }
+ end := i + windowSize + 1
+ if end > len(tokens) {
+ end = len(tokens)
+ }
+ for j := start; j < end; j++ {
+ if i == j {
+ continue
+ }
+ ctx := tokens[j]
+ if e.coOccur[word] == nil {
+ e.coOccur[word] = make(map[string]float64)
+ }
+ e.coOccur[word][ctx]++
+ }
+ }
+ }
+
+ for word, ctxs := range e.coOccur {
+ totalPairs := 0.0
+ for _, count := range ctxs {
+ totalPairs += count
+ }
+ pWord := e.docFreq[word] / float64(e.totalDocs)
+ for ctx, count := range ctxs {
+ pCtx := e.docFreq[ctx] / float64(e.totalDocs)
+ pJoint := count / totalPairs
+ pmi := math.Log2(pJoint / (pWord * pCtx))
+ if pmi <= 0 {
+ delete(ctxs, ctx)
+ } else {
+ ctxs[ctx] = pmi
+ }
+ }
+ e.coOccur[word] = pruneTopK(ctxs, 50)
+ }
+
+ e.trained = true
+}
+
+func pruneTopK(m map[string]float64, k int) map[string]float64 {
+ if len(m) <= k {
+ return m
+ }
+ type kv struct {
+ k string
+ v float64
+ }
+ var sorted []kv
+ for key, val := range m {
+ sorted = append(sorted, kv{key, val})
+ }
+ sort.Slice(sorted, func(i, j int) bool {
+ return sorted[i].v > sorted[j].v
+ })
+ result := make(map[string]float64, k)
+ for i := 0; i < k; i++ {
+ result[sorted[i].k] = sorted[i].v
+ }
+ return result
+}
+
+func (e *LocalWordEmbedder) Vectorize(text string) vector.Vector {
+ e.mu.RLock()
+ useEmbedding := e.trained
+ e.mu.RUnlock()
+
+ tokens := e.tokenize(text)
+ if len(tokens) == 0 {
+ return vector.Vector{}
+ }
+
+ tf := make(map[string]float64)
+ for _, t := range tokens {
+ tf[t]++
+ }
+ maxTF := 0.0
+ for _, count := range tf {
+ if count > maxTF {
+ maxTF = count
+ }
+ }
+
+ vec := make(vector.Vector)
+
+ if useEmbedding {
+ e.mu.RLock()
+ for word, count := range tf {
+ tfidf := (count / maxTF) * idf(e.docFreq[word], e.totalDocs)
+
+ if ctxs, ok := e.coOccur[word]; ok {
+ for ctx, pmi := range ctxs {
+ vec[ctx] += tfidf * pmi
+ }
+ }
+
+ vec["__w__"+word] += tfidf
+ }
+ e.mu.RUnlock()
+ } else {
+ for word, count := range tf {
+ tfNorm := count / maxTF
+ var df float64
+ e.mu.RLock()
+ df = e.docFreq[word]
+ e.mu.RUnlock()
+ vec[word] = tfNorm * idf(df, e.totalDocs)
+ }
+ }
+
+ return vec
+}
+
+func idf(df float64, total int) float64 {
+ if df <= 0 || total <= 0 {
+ return 1.0
+ }
+ return math.Log(float64(total+1)/(df+1)+1) + 1
+}
+
+func (e *LocalWordEmbedder) Trained() bool {
+ e.mu.RLock()
+ defer e.mu.RUnlock()
+ return e.trained
+}
diff --git a/internal/memory/graph.go b/internal/memory/graph.go
index 0e5a18c..9d6829f 100644
--- a/internal/memory/graph.go
+++ b/internal/memory/graph.go
@@ -132,6 +132,9 @@ func (g *GraphDB) Commit(triples []Triple, sessionID string, turnID int) (int, i
if t.Subject == "" || t.Relation == "" || t.Object == "" {
continue
}
+ if !validEntityName(t.Subject) || !validEntityName(t.Object) {
+ continue
+ }
subjType := t.SubjectType
if subjType == "" {
@@ -161,11 +164,11 @@ func (g *GraphDB) Commit(triples []Triple, sessionID string, turnID int) (int, i
var sourceID, targetID int64
err = tx.QueryRow("SELECT id FROM entities WHERE name = ?", t.Subject).Scan(&sourceID)
if err != nil {
- return 0, 0, err
+ return 0, 0, fmt.Errorf("subject %q: %w", t.Subject, err)
}
err = tx.QueryRow("SELECT id FROM entities WHERE name = ?", t.Object).Scan(&targetID)
if err != nil {
- return 0, 0, err
+ return 0, 0, fmt.Errorf("object %q: %w", t.Object, err)
}
_, err = tx.Exec(
@@ -186,7 +189,27 @@ func (g *GraphDB) Commit(triples []Triple, sessionID string, turnID int) (int, i
return entitiesCreated, relationsCreated, nil
}
+func validEntityName(name string) bool {
+ if name == "" {
+ return false
+ }
+ r := []rune(name)
+ if len(r) < 2 || len(r) > 50 {
+ return false
+ }
+ hasLetter := false
+ for _, ch := range r {
+ if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '\u4e00' && ch <= '\u9fff') || ch == '-' || ch == '_' {
+ hasLetter = true
+ }
+ }
+ return hasLetter
+}
+
func (g *GraphDB) upsertEntity(tx *sql.Tx, name string, entityType string) (int, error) {
+ if !validEntityName(name) {
+ return 0, nil
+ }
result, err := tx.Exec(
`INSERT INTO entities (name, type) VALUES (?, ?)
ON CONFLICT(name) DO UPDATE SET
diff --git a/internal/memory/indexer.go b/internal/memory/indexer.go
index b1cf895..bfd5992 100644
--- a/internal/memory/indexer.go
+++ b/internal/memory/indexer.go
@@ -94,7 +94,7 @@ func (idx *Indexer) BuildContext(userInput string) *InjectedContext {
vectorEntities := idx.vectorSearchEntities(userInput)
// 2. 关键词搜索:已有逻辑
- keywords := extractKeywords(userInput)
+ keywords := ExtractKeywords(userInput)
if len(keywords) == 0 && len(vectorEntities) == 0 {
keywords = []string{userInput}
}
@@ -282,46 +282,6 @@ func (idx *Indexer) GetToolDefinitions() []map[string]interface{} {
}
}
-func extractKeywords(input string) []string {
- stopWords := map[string]bool{
- "的": true, "了": true, "是": true, "在": true, "有": true,
- "和": true, "就": true, "不": true, "人": true, "都": true,
- "一": true, "一个": true, "上": true, "也": true, "很": true,
- "到": true, "说": true, "要": true, "去": true, "你": true,
- "会": true, "着": true, "没有": true, "看": true, "好": true,
- "自己": true, "这": true, "他": true, "她": true, "它": true,
- "什么": true, "怎么": true, "为什么": true, "如何": true,
- }
-
- var keywords []string
- seen := make(map[string]bool)
-
- runes := []rune(input)
-
- bigram := []rune{}
- for _, r := range runes {
- bigram = append(bigram, r)
- if len(bigram) >= 2 {
- word := string(bigram)
- if !stopWords[word] && !seen[word] {
- seen[word] = true
- keywords = append(keywords, word)
- }
- bigram = bigram[1:]
- }
- }
-
- if len(keywords) == 0 && len(runes) > 0 {
- keywords = []string{string(runes)}
- }
-
- if len(keywords) > 5 {
- keywords = keywords[:5]
- }
-
- return keywords
-}
-
func buildIndexSummary(entities []Entity) string {
if len(entities) == 0 {
return ""
diff --git a/internal/memory/indexer_test.go b/internal/memory/indexer_test.go
index 3b6d845..5b5b937 100644
--- a/internal/memory/indexer_test.go
+++ b/internal/memory/indexer_test.go
@@ -186,9 +186,9 @@ func TestExtractKeywords(t *testing.T) {
{"的了的", 0}, // all stop words
}
for _, tt := range tests {
- kw := extractKeywords(tt.input)
+ kw := ExtractKeywords(tt.input)
if len(kw) < tt.min {
- t.Errorf("extractKeywords(%q) = %v, want at least %d keywords", tt.input, kw, tt.min)
+ t.Errorf("ExtractKeywords(%q) = %v, want at least %d keywords", tt.input, kw, tt.min)
}
}
}
diff --git a/internal/plugin/bridge_e2e_test.go b/internal/plugin/bridge_e2e_test.go
index 822bbc3..e2f425f 100644
--- a/internal/plugin/bridge_e2e_test.go
+++ b/internal/plugin/bridge_e2e_test.go
@@ -42,7 +42,7 @@ func TestBridgeE2E_WebPlugin(t *testing.T) {
}
sett := sdk.NewSettings("web", nil)
- psdk := sdk.New("web", nil, nil, nil, nil, nil, nil, nil, sett, regTool, regStage, regAPI)
+ psdk := sdk.New("web", sdk.SDKConfig{Settings: sett, RegTool: regTool, RegStage: regStage, RegAPI: regAPI})
plg, err := newDLLPlugin(dllPath, "web", nil)
if err != nil {
@@ -136,11 +136,11 @@ func TestBridgeE2E_SanitizerStages(t *testing.T) {
}
sett := sdk.NewSettings("sanitizer", nil)
- psdk := sdk.New("sanitizer", nil, nil, nil, nil, nil, nil, nil, sett,
- func(name string, def sdk.ToolDef, handler sdk.ToolHandler) error { return nil },
- regStage,
- func(name string) error { return nil },
- )
+ psdk := sdk.New("sanitizer", sdk.SDKConfig{Settings: sett,
+ RegTool: func(name string, def sdk.ToolDef, handler sdk.ToolHandler) error { return nil },
+ RegStage: regStage,
+ RegAPI: func(name string) error { return nil },
+ })
plg, err := newDLLPlugin(dllPath, "sanitizer", nil)
if err != nil {
diff --git a/internal/plugin/registry.go b/internal/plugin/registry.go
index 727bfba..d632c9b 100644
--- a/internal/plugin/registry.go
+++ b/internal/plugin/registry.go
@@ -60,6 +60,8 @@ type Registry struct {
instances []sdk.Plugin
factories map[string]NativeFactory
+ pluginAutoRestart map[string]bool
+
iom *agentIO.IOManager
evBus *events.Bus
memDB *memory.GraphDB
@@ -77,8 +79,9 @@ type Registry struct {
func NewRegistry() *Registry {
return &Registry{
- plugins: make(map[string]sdk.Plugin),
- factories: make(map[string]NativeFactory),
+ plugins: make(map[string]sdk.Plugin),
+ factories: make(map[string]NativeFactory),
+ pluginAutoRestart: make(map[string]bool),
}
}
@@ -102,6 +105,24 @@ func (r *Registry) RegisterNative(name string, factory NativeFactory) {
globalFactories.Store(name, factory)
}
+type channelDevice struct {
+ name string
+ desc string
+ caps agentIO.OutputCapability
+ handler sdk.ToolHandler
+}
+
+func (d *channelDevice) Name() string { return d.name }
+func (d *channelDevice) Type() agentIO.DeviceType { return agentIO.DeviceOutput }
+func (d *channelDevice) Description() string { return d.desc }
+func (d *channelDevice) Start() error { return nil }
+func (d *channelDevice) Stop() error { return nil }
+func (d *channelDevice) OutputCapabilities() agentIO.OutputCapability { return d.caps }
+func (d *channelDevice) Tools() []agentIO.ToolDef { return nil }
+func (d *channelDevice) Execute(tool string, args map[string]interface{}) (interface{}, error) {
+ return d.handler(args)
+}
+
func (r *Registry) buildSDK(name string) *sdk.PluginSDK {
sett := sdk.NewSettings(name, r.cfgReg)
@@ -120,16 +141,32 @@ func (r *Registry) buildSDK(name string) *sdk.PluginSDK {
regAPI = func(name string) error { return nil }
}
- return sdk.New(name,
- r.iom, r.evBus,
- sdk.NewGraphMemory(r.memDB),
- sdk.NewTextMemory(r.textMem),
- sdk.NewDocMemory(r.docStore),
- sdk.NewKnowledge(r.ks),
- sdk.NewLLM(r.mgr),
- sett,
- regTool, regStage, regAPI,
- )
+ regOutput := func(chName string, caps int, desc string, handler sdk.ToolHandler) error {
+ if r.iom == nil {
+ return nil
+ }
+ return r.iom.RegisterDevice(&channelDevice{
+ name: chName,
+ caps: agentIO.OutputCapability(caps),
+ desc: desc,
+ handler: handler,
+ })
+ }
+
+ return sdk.New(name, sdk.SDKConfig{
+ IOManager: r.iom,
+ EventBus: r.evBus,
+ Memory: sdk.NewGraphMemory(r.memDB),
+ TextMemory: sdk.NewTextMemory(r.textMem),
+ DocMemory: sdk.NewDocMemory(r.docStore),
+ Knowledge: sdk.NewKnowledge(r.ks),
+ LLM: sdk.NewLLM(r.mgr),
+ Settings: sett,
+ RegTool: regTool,
+ RegStage: regStage,
+ RegAPI: regAPI,
+ RegOutput: regOutput,
+ })
}
func (r *Registry) Load(dir string) error {
@@ -201,6 +238,7 @@ func (r *Registry) Load(dir string) error {
r.mu.Lock()
r.plugins[name] = p
+ r.pluginAutoRestart[name] = plgSDK.AutoRestart()
r.instances = append(r.instances, p)
r.mu.Unlock()
log.Printf("[plugin] loaded: %s", name)
@@ -263,6 +301,7 @@ func (r *Registry) loadOne(plgDir, name string) bool {
r.mu.Lock()
r.plugins[name] = plg
+ r.pluginAutoRestart[name] = plgSDK.AutoRestart()
r.instances = append(r.instances, plg)
r.mu.Unlock()
log.Printf("[plugin] loaded: %s", name)
@@ -279,6 +318,7 @@ func (r *Registry) StopAll() {
}
r.plugins = make(map[string]sdk.Plugin)
r.instances = nil
+ r.pluginAutoRestart = make(map[string]bool)
}
func (r *Registry) Reload(dir string) (string, error) {
@@ -289,6 +329,32 @@ func (r *Registry) Reload(dir string) (string, error) {
return fmt.Sprintf("loaded %d plugins", len(r.instances)), nil
}
+func (r *Registry) ReloadOne(name string) error {
+ plgDir := filepath.Join(r.plgDir, name)
+
+ r.mu.Lock()
+ if p, ok := r.plugins[name]; ok {
+ if err := p.Stop(); err != nil {
+ log.Printf("[plugin] stop %s for reload: %v", name, err)
+ }
+ delete(r.plugins, name)
+ for i, inst := range r.instances {
+ if inst.Name() == name {
+ r.instances = append(r.instances[:i], r.instances[i+1:]...)
+ break
+ }
+ }
+ }
+ r.mu.Unlock()
+
+ ok := r.loadOne(plgDir, name)
+ if !ok {
+ return fmt.Errorf("reload plugin %s failed", name)
+ }
+ log.Printf("[plugin] reloaded: %s", name)
+ return nil
+}
+
func (r *Registry) List() []string {
r.mu.RLock()
defer r.mu.RUnlock()
@@ -306,6 +372,16 @@ func (r *Registry) Get(name string) sdk.Plugin {
return r.plugins[name]
}
+func (r *Registry) AutoRestartEnabled(name string) bool {
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+ enabled, ok := r.pluginAutoRestart[name]
+ if !ok {
+ return true
+ }
+ return enabled
+}
+
func (r *Registry) PluginMetas() map[string]PluginMeta {
metas := make(map[string]PluginMeta)
globalPluginMeta.Range(func(key, val interface{}) bool {
diff --git a/internal/plugins/agentcli/plugin.go b/internal/plugins/agentcli/plugin.go
index bac8f34..144875f 100644
--- a/internal/plugins/agentcli/plugin.go
+++ b/internal/plugins/agentcli/plugin.go
@@ -177,6 +177,7 @@ func New(name string) *Plugin {
func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error {
+ s.SetAutoRestart(true)
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "default_timeout", Type: "string", DisplayName: "默认终端超时",
Description: "终端自动关闭的默认时间,例如 5m, 10m, 30m, 1h(默认 5m)",
diff --git a/internal/plugins/agentcli/plugin_test.go b/internal/plugins/agentcli/plugin_test.go
index ced6557..5ae77ff 100644
--- a/internal/plugins/agentcli/plugin_test.go
+++ b/internal/plugins/agentcli/plugin_test.go
@@ -31,8 +31,7 @@ func (tc *toolCapture) RegisterAPI(name string) error {
func setupPlugin() (*Plugin, *toolCapture, error) {
p := New("agentcli")
tc := newToolCapture()
- // Use nil for fields we don't need (iom, eventBus, etc.)
- sdk := sdk.New("agentcli", nil, nil, nil, nil, nil, nil, nil, nil, tc.RegisterTool, tc.RegisterStage, tc.RegisterAPI)
+ sdk := sdk.New("agentcli", sdk.SDKConfig{RegTool: tc.RegisterTool, RegStage: tc.RegisterStage, RegAPI: tc.RegisterAPI})
if err := p.Start(sdk); err != nil {
return nil, nil, err
}
diff --git a/internal/plugins/cli/plugin.go b/internal/plugins/cli/plugin.go
index f7186bc..7c5577b 100644
--- a/internal/plugins/cli/plugin.go
+++ b/internal/plugins/cli/plugin.go
@@ -70,6 +70,7 @@ func New(name, socketPath string) *Plugin {
func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error {
+ s.SetAutoRestart(true)
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "api_key", Type: "password", DisplayName: "CLI API 密钥",
Description: "CLI 客户端连接时需提供的认证密钥(留空则使用 WebUI 密钥)",
diff --git a/internal/plugins/cmd/plugin.go b/internal/plugins/cmd/plugin.go
index 27b5576..cfb2405 100644
--- a/internal/plugins/cmd/plugin.go
+++ b/internal/plugins/cmd/plugin.go
@@ -81,6 +81,7 @@ func New(name string) *Plugin {
func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error {
+ s.SetAutoRestart(true)
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "default_timeout", Type: "string", DisplayName: "默认命令超时",
Description: "命令执行的默认超时时间,例如 30s, 1m, 5m(默认 30s)",
diff --git a/internal/plugins/cmd/plugin_test.go b/internal/plugins/cmd/plugin_test.go
index ded45c4..33504f4 100644
--- a/internal/plugins/cmd/plugin_test.go
+++ b/internal/plugins/cmd/plugin_test.go
@@ -34,7 +34,7 @@ func (tc *toolCapture) RegisterAPI(name string) error { return nil }
func setupPlugin() (*Plugin, *toolCapture, error) {
p := New("cmd")
tc := newToolCapture()
- sdk := sdk.New("cmd", nil, nil, nil, nil, nil, nil, nil, nil, tc.RegisterTool, tc.RegisterStage, tc.RegisterAPI)
+ sdk := sdk.New("cmd", sdk.SDKConfig{RegTool: tc.RegisterTool, RegStage: tc.RegisterStage, RegAPI: tc.RegisterAPI})
if err := p.Start(sdk); err != nil {
return nil, nil, err
}
diff --git a/internal/plugins/files/plugin.go b/internal/plugins/files/plugin.go
index d98338c..3bc3029 100644
--- a/internal/plugins/files/plugin.go
+++ b/internal/plugins/files/plugin.go
@@ -34,6 +34,7 @@ func New(name string) *Plugin {
func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error {
+ s.SetAutoRestart(true)
p.sdk = s
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "dir",
diff --git a/internal/plugins/healthcheck/plugin.go b/internal/plugins/healthcheck/plugin.go
index c067d6d..e3b8e91 100644
--- a/internal/plugins/healthcheck/plugin.go
+++ b/internal/plugins/healthcheck/plugin.go
@@ -113,6 +113,7 @@ func New(name string) *Plugin {
func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error {
+ s.SetAutoRestart(true)
p.autoInterval = 30 * time.Minute
p.llmTimeout = 120 * time.Second
p.llmMaxTurns = 20
diff --git a/internal/plugins/healthcheck/plugin_test.go b/internal/plugins/healthcheck/plugin_test.go
index 8653320..e42b650 100644
--- a/internal/plugins/healthcheck/plugin_test.go
+++ b/internal/plugins/healthcheck/plugin_test.go
@@ -42,7 +42,7 @@ func setupPlugin() (*Plugin, *toolCapture, error) {
Configure(sh, iom, pr, nil, nil, nil, nil, nil)
p := New("healthcheck")
tc := newToolCapture()
- sdk := sdk.New("healthcheck", nil, nil, nil, nil, nil, nil, nil, nil, tc.RegisterTool, tc.RegisterStage, tc.RegisterAPI)
+ sdk := sdk.New("healthcheck", sdk.SDKConfig{RegTool: tc.RegisterTool, RegStage: tc.RegisterStage, RegAPI: tc.RegisterAPI})
if err := p.Start(sdk); err != nil {
return nil, nil, err
}
@@ -192,7 +192,7 @@ func TestHealthcheckWithMemory(t *testing.T) {
Configure(sh, iom, pr, memDB, nil, nil, nil, nil)
p := New("healthcheck")
tc := newToolCapture()
- sdk := sdk.New("healthcheck", nil, nil, nil, nil, nil, nil, nil, nil, tc.RegisterTool, tc.RegisterStage, tc.RegisterAPI)
+ sdk := sdk.New("healthcheck", sdk.SDKConfig{RegTool: tc.RegisterTool, RegStage: tc.RegisterStage, RegAPI: tc.RegisterAPI})
if err := p.Start(sdk); err != nil {
t.Fatal(err)
}
@@ -235,7 +235,7 @@ func TestHealthcheckWithKnowledge(t *testing.T) {
Configure(sh, iom, pr, nil, ks, nil, nil, nil)
p := New("healthcheck")
tc := newToolCapture()
- sdk := sdk.New("healthcheck", nil, nil, nil, nil, nil, nil, nil, nil, tc.RegisterTool, tc.RegisterStage, tc.RegisterAPI)
+ sdk := sdk.New("healthcheck", sdk.SDKConfig{RegTool: tc.RegisterTool, RegStage: tc.RegisterStage, RegAPI: tc.RegisterAPI})
if err := p.Start(sdk); err != nil {
t.Fatal(err)
}
@@ -289,7 +289,7 @@ func TestHealthcheckWithDocStore(t *testing.T) {
Configure(sh, iom, pr, nil, nil, ds, nil, nil)
p := New("healthcheck")
tc := newToolCapture()
- sdk := sdk.New("healthcheck", nil, nil, nil, nil, nil, nil, nil, nil, tc.RegisterTool, tc.RegisterStage, tc.RegisterAPI)
+ sdk := sdk.New("healthcheck", sdk.SDKConfig{RegTool: tc.RegisterTool, RegStage: tc.RegisterStage, RegAPI: tc.RegisterAPI})
if err := p.Start(sdk); err != nil {
t.Fatal(err)
}
diff --git a/internal/plugins/mcp/plugin.go b/internal/plugins/mcp/plugin.go
index a382779..4b62ee2 100644
--- a/internal/plugins/mcp/plugin.go
+++ b/internal/plugins/mcp/plugin.go
@@ -41,6 +41,7 @@ func New(name string) *Plugin {
func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error {
+ s.SetAutoRestart(true)
cfgs, err := p.loadConfig(s)
if err != nil {
return fmt.Errorf("load mcp config: %w", err)
diff --git a/internal/plugins/openclaw/plugin.go b/internal/plugins/openclaw/plugin.go
index a6831ac..0e1cbad 100644
--- a/internal/plugins/openclaw/plugin.go
+++ b/internal/plugins/openclaw/plugin.go
@@ -68,6 +68,7 @@ func New(name, skillsDir string) *Plugin {
func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error {
+ s.SetAutoRestart(true)
p.sdk = s
s.Settings().RegisterDef(sdk.ConfigDef{
diff --git a/internal/plugins/openclaw/sidecar_test.go b/internal/plugins/openclaw/sidecar_test.go
index f0d7969..815a8d9 100644
--- a/internal/plugins/openclaw/sidecar_test.go
+++ b/internal/plugins/openclaw/sidecar_test.go
@@ -326,13 +326,13 @@ func TestLoadOCPluginViaPluginStart(t *testing.T) {
var registeredTools []string
registeredHandlers := make(map[string]sdk.ToolHandler)
- sdk := sdk.New("openclaw", nil, nil, nil, nil, nil, nil, nil, nil,
- func(name string, def sdk.ToolDef, handler sdk.ToolHandler) error {
+ sdk := sdk.New("openclaw", sdk.SDKConfig{
+ RegTool: func(name string, def sdk.ToolDef, handler sdk.ToolHandler) error {
registeredTools = append(registeredTools, name)
registeredHandlers[name] = handler
return nil
},
- nil, nil)
+ })
if err := p.Start(sdk); err != nil {
t.Fatalf("start plugin: %v", err)
diff --git a/internal/plugins/pluginmgr/plugin.go b/internal/plugins/pluginmgr/plugin.go
index 8174799..b4fead5 100644
--- a/internal/plugins/pluginmgr/plugin.go
+++ b/internal/plugins/pluginmgr/plugin.go
@@ -63,6 +63,7 @@ func New(name string) *Plugin {
func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error {
+ s.SetAutoRestart(true)
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "http_addr",
Default: HTTPAddr,
diff --git a/internal/plugins/timer/plugin.go b/internal/plugins/timer/plugin.go
index 4b0b1c1..332dad6 100644
--- a/internal/plugins/timer/plugin.go
+++ b/internal/plugins/timer/plugin.go
@@ -40,6 +40,7 @@ func New(name string) *Plugin {
func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error {
+ s.SetAutoRestart(true)
p.maxDur = 24 * time.Hour
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "max_duration", Type: "string", DisplayName: "最大定时时长",
diff --git a/internal/plugins/webui/dashboard.html b/internal/plugins/webui/dashboard.html
index d19be2c..fa4d570 100644
--- a/internal/plugins/webui/dashboard.html
+++ b/internal/plugins/webui/dashboard.html
@@ -4,9 +4,10 @@
HomeAgent Dashboard
-
-
-
+
+
+
+