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

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

4
go.mod
View File

@ -8,6 +8,8 @@ require (
gopkg.in/yaml.v3 v3.0.1 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 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

2
go.sum
View File

@ -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/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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 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 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA=
github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8= github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=

View File

@ -2,5 +2,4 @@ go 1.25.0
use ( use (
. .
../homeagentsdk
) )

103
gui/main.js Normal file
View File

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

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

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

13
gui/package.json Normal file
View File

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

11
gui/preload.js Normal file
View File

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

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

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

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

@ -0,0 +1,68 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HomeAgent</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<!-- Connection Manager Overlay -->
<div id="conn-overlay" class="overlay">
<div class="overlay-content conn-manager">
<h2>连接管理 / Connections</h2>
<div class="conn-list" id="conn-list"></div>
<div class="conn-form" id="conn-form" style="display:none">
<h3 id="conn-form-title">添加连接 / Add Connection</h3>
<label>名称 / Name</label>
<input id="conn-name" placeholder="My HomeAgent">
<label>地址 / URL</label>
<input id="conn-url" placeholder="http://localhost:8080">
<label>API 密钥 / API Key</label>
<input id="conn-key" type="password" placeholder="sk-...">
<div class="conn-form-actions">
<button class="btn btn-ghost" onclick="cancelConnForm()">取消 / Cancel</button>
<button class="btn btn-primary" onclick="saveConnForm()">保存 / Save</button>
</div>
</div>
<button class="btn btn-primary" onclick="showConnForm()" id="conn-add-btn" style="margin-top:12px">+ 添加连接 / Add Connection</button>
</div>
</div>
<!-- Main App -->
<div id="app" style="display:none">
<!-- Top Bar -->
<nav>
<h1>HomeAgent</h1>
<a class="active" onclick="switchTab('overview')">概览</a>
<a onclick="switchTab('chat')">对话</a>
<a onclick="switchTab('plugins')">插件</a>
<a onclick="switchTab('settings')">设置</a>
<a onclick="switchTab('adapters')">适配器</a>
<a onclick="switchTab('kernel')">内核</a>
<div style="margin-left:auto;display:flex;align-items:center;gap:8px">
<span id="conn-status" class="conn-indicator" onclick="openConnManager()" title="点击管理连接">
<span class="status-dot dot-gray" id="conn-dot"></span>
<span id="conn-name-display">未连接</span>
<span style="font-size:10px;margin-left:4px;opacity:0.6"></span>
</span>
<button class="theme-btn" onclick="toggleTheme()" id="theme-btn">🌙</button>
</div>
</nav>
<div class="container" id="content">
<div id="tab-overview" class="tab-content active"></div>
<div id="tab-chat" class="tab-content"></div>
<div id="tab-plugins" class="tab-content"></div>
<div id="tab-settings" class="tab-content"></div>
<div id="tab-adapters" class="tab-content"></div>
<div id="tab-kernel" class="tab-content"></div>
</div>
</div>
<div id="toast" class="toast"></div>
<script src="app.js"></script>
</body>
</html>

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

@ -0,0 +1,203 @@
:root {
--bg-primary: #0f172a;
--bg-secondary: #1e293b;
--bg-card: #1e293b;
--bg-input: #0f172a;
--bg-hover: rgba(15,23,42,0.25);
--text-primary: #e2e8f0;
--text-secondary: #94a3b8;
--text-muted: #64748b;
--border-color: #334155;
--accent: #38bdf8;
--accent-bg: #1e3a5f;
--toast-bg: #166534;
--toast-color: #86efac;
--toast-error-bg: #7f1d1d;
--toast-error-color: #fca5a5;
--pre-color: #a5b4fc;
--pre-bg: #0f172a;
--chat-bg: #0f172a;
--msg-user-bg: #1e3a5f;
--msg-user-color: #93c5fd;
--msg-assistant-bg: #1a3a2a;
--msg-assistant-color: #86efac;
--msg-system-bg: #3b1a3a;
--msg-system-color: #f0abfc;
--kv-border: #1e293b;
}
[data-theme=light] {
--bg-primary: #f8fafc;
--bg-secondary: #ffffff;
--bg-card: #ffffff;
--bg-input: #f1f5f9;
--bg-hover: rgba(241,245,249,0.8);
--text-primary: #1e293b;
--text-secondary: #64748b;
--text-muted: #94a3b8;
--border-color: #e2e8f0;
--accent: #2563eb;
--accent-bg: #dbeafe;
--pre-color: #1e293b;
--pre-bg: #f1f5f9;
--chat-bg: #f1f5f9;
--msg-user-bg: #dbeafe;
--msg-user-color: #1e40af;
--msg-assistant-bg: #dcfce7;
--msg-assistant-color: #166534;
--msg-system-bg: #f3e8ff;
--msg-system-color: #7c3aed;
--kv-border: #e2e8f0;
}
* { margin:0; padding:0; box-sizing:border-box; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif }
body { background:var(--bg-primary); color:var(--text-primary); min-height:100vh; transition:background .2s,color .2s }
nav { background:var(--bg-secondary); padding:0 24px; display:flex; align-items:center; gap:4px; border-bottom:1px solid var(--border-color); height:48px; position:sticky; top:0; z-index:100 }
nav h1 { font-size:16px; font-weight:700; color:var(--accent); margin-right:24px; white-space:nowrap }
nav a { padding:12px 16px; color:var(--text-secondary); text-decoration:none; font-size:13px; cursor:pointer; border-bottom:2px solid transparent; transition:all .15s }
nav a:hover { color:var(--text-primary) }
nav a.active { color:var(--accent); border-bottom-color:var(--accent) }
.theme-btn { background:none; border:1px solid var(--border-color); color:var(--text-secondary); cursor:pointer; padding:4px 8px; border-radius:6px; font-size:14px; line-height:1; transition:all .15s }
.theme-btn:hover { color:var(--accent); border-color:var(--accent) }
.container { padding:20px 24px; max-width:1440px; margin:0 auto }
.card { background:var(--bg-card); border:1px solid var(--border-color); border-radius:10px; padding:20px; margin-bottom:16px; transition:background .2s,border .2s }
.card h2 { font-size:15px; font-weight:600; margin-bottom:12px; color:var(--text-primary) }
.card h3 { font-size:13px; font-weight:600; color:var(--text-secondary); margin:16px 0 8px }
.grid-2 { display:grid; grid-template-columns:1fr 1fr; gap:16px }
.grid-3 { display:grid; grid-template-columns:1fr 1fr 1fr; gap:16px }
.grid-4 { display:grid; grid-template-columns:repeat(4,1fr); gap:16px }
.stat-value { font-size:26px; font-weight:700; color:var(--accent) }
.stat-label { font-size:11px; color:var(--text-muted); margin-top:2px }
.stat-card { padding:16px 20px }
.status-dot { display:inline-block; width:8px; height:8px; border-radius:50%; margin-right:6px }
.dot-green { background:#22c55e }
.dot-yellow { background:#eab308 }
.dot-red { background:#ef4444 }
.dot-gray { background:#475569 }
table { width:100%; border-collapse:collapse; font-size:13px }
th { text-align:left; padding:8px 10px; color:var(--text-muted); font-weight:500; border-bottom:1px solid var(--border-color); font-size:11px; text-transform:uppercase; letter-spacing:.5px }
td { padding:8px 10px; border-bottom:1px solid var(--kv-border) }
tr:hover td { background:var(--bg-hover) }
.badge { display:inline-block; padding:2px 8px; border-radius:4px; font-size:11px; font-weight:500 }
.badge-green { background:#166534; color:#86efac }
.badge-red { background:#7f1d1d; color:#fca5a5 }
.badge-yellow { background:#713f12; color:#fde68a }
.badge-blue { background:#1e3a5f; color:#93c5fd }
.btn { padding:6px 14px; border-radius:6px; border:none; font-size:12px; cursor:pointer; font-weight:500; transition:all .15s }
.btn-primary { background:var(--accent); color:#fff }
.btn-primary:hover { filter:brightness(1.1) }
.btn-primary:disabled { opacity:.5; cursor:not-allowed }
.btn-danger { background:#dc2626; color:#fff }
.btn-danger:hover { background:#b91c1c }
.btn-sm { padding:4px 10px; font-size:11px }
.btn-ghost { background:transparent; border:1px solid var(--border-color); color:var(--text-secondary); cursor:pointer; padding:6px 14px; border-radius:6px; font-size:12px }
.btn-ghost:hover { background:var(--bg-hover); color:var(--text-primary) }
.tab-content { display:none }
.tab-content.active { display:block }
input,textarea,select { background:var(--bg-input); border:1px solid var(--border-color); border-radius:6px; padding:8px 12px; color:var(--text-primary); font-size:13px; width:100%; margin-bottom:10px; outline:none; transition:border .15s }
input:focus,textarea:focus,select:focus { border-color:var(--accent) }
textarea { resize:vertical; min-height:80px; font-family:monospace; font-size:12px }
label { display:block; font-size:11px; color:var(--text-secondary); margin-bottom:3px; font-weight:500 }
pre { background:var(--pre-bg); border-radius:6px; padding:12px; font-size:12px; overflow-x:auto; color:var(--pre-color); font-family:monospace; max-height:400px; overflow-y:auto }
code { font-family:monospace; font-size:12px; color:var(--pre-color) }
.settings-layout { display:flex; gap:20px; min-height:60vh }
.settings-sidebar { width:200px; flex-shrink:0; background:var(--bg-card); border:1px solid var(--border-color); border-radius:10px; padding:8px 0; overflow-y:auto; max-height:70vh }
.settings-sidebar a { display:block; padding:9px 16px; color:var(--text-secondary); font-size:13px; cursor:pointer; border-left:3px solid transparent; transition:all .1s }
.settings-sidebar a:hover { background:var(--bg-primary); color:var(--text-primary) }
.settings-sidebar a.active { background:var(--bg-primary); color:var(--accent); border-left-color:var(--accent) }
.settings-content { flex:1; min-width:0 }
.settings-key { font-family:monospace; font-size:11px; color:var(--text-muted); margin-bottom:2px }
.reasoning { border-left:2px solid #888; padding-left:12px; margin:8px 0; font-size:12px; color:#999 }
.reasoning-title { cursor:pointer; font-size:11px; color:#666; font-weight:600; user-select:none; margin-bottom:4px }
.reasoning-body { color:#999; line-height:1.5 }
.msg-content .text h1,.msg-content .text h2,.msg-content .text h3 { font-size:1em; margin:8px 0 4px }
.msg-content .text p { margin:4px 0; line-height:1.5 }
.msg-content .text ul,.msg-content .text ol { padding-left:20px; margin:4px 0 }
.msg-content .text li { margin:2px 0 }
.msg-content .text code { background:var(--pre-bg); padding:1px 4px; border-radius:3px; font-size:11px }
.msg-content .text pre { background:var(--pre-bg); border-radius:6px; padding:10px; margin:8px 0; overflow-x:auto; font-size:11px; max-height:300px }
.msg-content .text pre code { background:none; padding:0 }
.msg-content .text blockquote { border-left:3px solid var(--border-color); padding-left:10px; margin:8px 0; color:var(--text-secondary) }
.msg-content .text table { border-collapse:collapse; margin:8px 0; font-size:12px; width:100% }
.msg-content .text th,.msg-content .text td { border:1px solid var(--border-color); padding:4px 8px; text-align:left }
.toast { position:fixed; bottom:20px; right:20px; background:var(--toast-bg); color:var(--toast-color); padding:10px 20px; border-radius:8px; font-size:13px; display:none; z-index:9999; box-shadow:0 4px 12px rgba(0,0,0,.3) }
.toast.error { background:var(--toast-error-bg); color:var(--toast-error-color) }
.empty-state { text-align:center; padding:40px 20px; color:var(--text-muted) }
.chat-layout { display:flex; gap:16px; height:calc(100vh - 100px); min-height:60vh; overflow:hidden }
.chat-main { flex:2; min-width:0; display:flex; flex-direction:column }
.chat-main .card { flex:1; display:flex; flex-direction:column; margin-bottom:0 }
.chat-messages { flex:1; overflow-y:auto; padding:12px; border:1px solid var(--border-color); border-radius:8px; background:var(--chat-bg); display:flex; flex-direction:column; gap:4px }
.msg { display:flex; gap:8px; margin-bottom:2px; align-items:flex-start; max-width:85% }
.msg-user { flex-direction:row-reverse; align-self:flex-end }
.msg-assistant { align-self:flex-start }
.msg-system { align-self:center; max-width:90% }
.msg-avatar { width:28px; height:28px; border-radius:6px; display:flex; align-items:center; justify-content:center; font-size:12px; flex-shrink:0 }
.msg-user .msg-avatar { background:var(--msg-user-bg); color:var(--msg-user-color) }
.msg-assistant .msg-avatar { background:var(--msg-assistant-bg); color:var(--msg-assistant-color) }
.msg-system .msg-avatar { background:var(--msg-system-bg); color:var(--msg-system-color) }
.msg-bubble { padding:8px 12px; border-radius:10px; font-size:13px; line-height:1.5; word-break:break-word }
.msg-user .msg-bubble { background:var(--msg-user-bg); color:var(--msg-user-color); border-bottom-right-radius:4px }
.msg-assistant .msg-bubble { background:var(--msg-assistant-bg); color:var(--msg-assistant-color); border-bottom-left-radius:4px }
.msg-system .msg-bubble { background:var(--msg-system-bg); color:var(--msg-system-color); text-align:center; font-size:12px }
.msg-bubble .text { white-space:pre-wrap }
.msg-bubble .text p { margin:4px 0 }
.msg-bubble .text pre { background:var(--pre-bg); border-radius:6px; padding:8px; margin:4px 0; overflow-x:auto; font-size:11px; max-height:200px }
.msg-bubble .text code { background:var(--pre-bg); padding:1px 4px; border-radius:3px; font-size:11px }
.msg-bubble .text pre code { background:none; padding:0 }
.msg-bubble .reasoning { border-left:2px solid rgba(255,255,255,0.2); padding-left:8px; margin:6px 0; font-size:11px; opacity:0.7 }
.msg-bubble .reasoning-title { cursor:pointer; font-size:10px; font-weight:600; user-select:none; margin-bottom:2px }
.msg-bubble .tool-call { background:rgba(0,0,0,0.15); border-radius:6px; padding:6px 8px; margin:4px 0; font-size:11px; border-left:2px solid var(--accent) }
.msg-bubble .tool-call .tc-name { font-weight:600; color:var(--accent) }
.msg-bubble .tool-call .tc-args { font-family:monospace; font-size:10px; opacity:0.7; white-space:pre-wrap; word-break:break-all; margin-top:2px }
.msg-bubble .tool-call .tc-result { font-family:monospace; font-size:10px; opacity:0.6; white-space:pre-wrap; word-break:break-all; margin-top:2px; max-height:80px; overflow-y:auto }
.chat-input-row { display:flex; gap:8px; flex-shrink:0; padding-top:10px }
.chat-input-row input { flex:1; margin-bottom:0 }
.chat-input-row button { flex-shrink:0; margin-bottom:0 }
.chat-sidebar { flex:1; min-width:240px; max-width:340px; overflow-y:auto; display:flex; flex-direction:column; gap:12px }
.chat-sidebar .card { margin-bottom:0 }
.loading { display:inline-block; width:16px; height:16px; border:2px solid var(--border-color); border-radius:50%; border-top-color:var(--accent); animation:spin .6s linear infinite }
@keyframes spin { to { transform:rotate(360deg) } }
.kv-row { display:flex; padding:6px 0; border-bottom:1px solid var(--kv-border); font-size:13px }
.kv-row .key { color:var(--text-muted); width:180px; flex-shrink:0 }
.kv-row .val { color:var(--text-primary); word-break:break-all }
.loading-spinner { width:32px; height:32px; border:3px solid rgba(68,136,255,0.15); border-top:3px solid #4488ff; border-radius:50%; animation:spin 0.8s linear infinite; margin:20px auto }
#starmap-container { position:relative; overflow:hidden }
#starmap-container canvas { display:block }
#starmap-placeholder { display:flex; align-items:center; justify-content:center; height:100%; width:100% }
.sidebar-subnav { display:flex; gap:0; border-bottom:1px solid var(--border-color); margin-bottom:10px }
.sidebar-subnav span { padding:6px 12px; font-size:12px; cursor:pointer; color:var(--text-muted); border-bottom:2px solid transparent; transition:all .15s }
.sidebar-subnav span:hover { color:var(--text-primary) }
.sidebar-subnav span.active { color:var(--accent); border-bottom-color:var(--accent) }
/* Connection Manager */
.overlay { position:fixed; inset:0; background:rgba(0,0,0,0.6); display:none; align-items:center; justify-content:center; z-index:1000 }
.overlay-content { background:var(--bg-card); border:1px solid var(--border-color); border-radius:12px; padding:28px; width:520px; max-height:80vh; overflow-y:auto }
.overlay-content h2 { font-size:18px; margin-bottom:16px; color:var(--text-primary) }
.conn-item { display:flex; align-items:center; gap:12px; padding:12px 16px; border:1px solid var(--border-color); border-radius:8px; margin-bottom:8px; cursor:pointer; transition:all .15s }
.conn-item:hover { background:var(--bg-hover); border-color:var(--accent) }
.conn-item.active { border-color:var(--accent); background:var(--accent-bg) }
.conn-item .conn-info { flex:1; min-width:0 }
.conn-item .conn-name { font-size:14px; font-weight:600; color:var(--text-primary) }
.conn-item .conn-url { font-size:11px; color:var(--text-muted); margin-top:2px }
.conn-item .conn-actions { display:flex; gap:4px; flex-shrink:0 }
.conn-form h3 { font-size:15px; margin-bottom:12px }
.conn-form-actions { display:flex; gap:8px; justify-content:flex-end; margin-top:12px }
.conn-indicator { display:flex; align-items:center; gap:4px; cursor:pointer; padding:4px 10px; border-radius:6px; font-size:12px; color:var(--text-secondary); transition:all .15s; user-select:none }
.conn-indicator:hover { background:var(--bg-hover); color:var(--text-primary) }
@media(max-width:768px) {
nav { padding:0 8px; gap:2px }
nav h1 { font-size:13px; margin-right:8px }
nav a { padding:8px 6px; font-size:11px }
.container { padding:12px }
.card { padding:12px }
.grid-2,.grid-3,.grid-4 { grid-template-columns:1fr }
.stat-value { font-size:20px }
.settings-layout { flex-direction:column }
.settings-sidebar { width:100%; max-height:200px; display:flex; flex-wrap:wrap; padding:4px }
.settings-sidebar a { display:inline-block; padding:6px 12px; border-left:none; border-bottom:2px solid transparent }
.settings-sidebar a.active { border-left:none; border-bottom-color:var(--accent) }
.kv-row { flex-direction:column; gap:2px }
.kv-row .key { width:auto }
.chat-layout { flex-direction:column; height:auto }
.chat-sidebar { max-width:none }
.msg { max-width:95% }
}

View File

@ -6,6 +6,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"log" "log"
"runtime/debug"
"sort" "sort"
"strings" "strings"
"sync" "sync"
@ -75,8 +76,9 @@ type Agent struct {
currentOutputChannel string currentOutputChannel string
// 阶段管道:插件消息流编辑 // 阶段管道:插件消息流编辑
stageHost *StageHost stageHost *StageHost
eventBus *events.Bus eventBus *events.Bus
pluginHealth *pluginHealthTracker
// 自循环输入通道:核心内部任务(记忆消歧、系统维护),不经过 IO 层 // 自循环输入通道:核心内部任务(记忆消歧、系统维护),不经过 IO 层
selfInputCh chan string selfInputCh chan string
@ -190,6 +192,7 @@ func New(cfg AgentConfig) *Agent {
selfInputCh: make(chan string, 64), selfInputCh: make(chan string, 64),
childResults: make(map[string]string), childResults: make(map[string]string),
interceptCh: make(chan *agentIO.InputEvent, 64), interceptCh: make(chan *agentIO.InputEvent, 64),
pluginHealth: newPluginHealthTracker(),
thinkingEnabled: cfg.ThinkingEnabled, thinkingEnabled: cfg.ThinkingEnabled,
inputCfg: cfg.InputProcessing, inputCfg: cfg.InputProcessing,
noMergeMarkers: make(map[string]int), noMergeMarkers: make(map[string]int),
@ -227,6 +230,13 @@ func (a *Agent) injectSelf(task string) {
} }
func (a *Agent) eventLoop() { 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 { for {
select { select {
case evt := <-a.io.InputChan(): case evt := <-a.io.InputChan():
@ -244,6 +254,13 @@ func (a *Agent) eventLoop() {
// a) 通过 cancelLLM + interceptCh 直接打断进行中的 LLM 请求 // a) 通过 cancelLLM + interceptCh 直接打断进行中的 LLM 请求
// b) 通过 a.io.InjectInput() → InputChan → eventLoop代理空闲时触发新处理循环 // b) 通过 a.io.InjectInput() → InputChan → eventLoop代理空闲时触发新处理循环
func (a *Agent) interceptLoop() { 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 { for {
select { select {
case evt := <-a.io.InputInterruptChan(): 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.personality != nil && a.personality.Content != "",
a.docStoreSize()) a.docStoreSize())
// === Stage: pre_action — 上下文就绪,即将调用 LLM ===
if a.runStage(sdk.StagePreAction, stageCtx) { if a.runStage(sdk.StagePreAction, stageCtx) {
return *stageCtx.Response, toolsUsed, nil 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.Content = stageCtx.LLMText
resp.ToolCalls = convertBackToolCalls(stageCtx.ToolCalls) 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 { if len(resp.ToolCalls) == 0 {
return resp.Content, toolsUsed, nil 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) pluginName := a.resolveToolPlugin(tc.Name)
log.Printf("[agent] executing tool: %s (plugin=%s, id=%s)", tc.Name, pluginName, tc.ID) 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} sdkTC := sdk.ToolCall{ID: tc.ID, Name: tc.Name, Plugin: pluginName, Arguments: tc.Arguments}
stageCtx.ToolCalls = []sdk.ToolCall{sdkTC} stageCtx.ToolCalls = []sdk.ToolCall{sdkTC}
stageCtx.ToolResults = nil stageCtx.ToolResults = nil
@ -773,6 +805,14 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
} }
tc.Arguments = stageCtx.ToolCalls[0].Arguments 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) result := a.executeToolCall(tc)
log.Printf("[agent] tool %s result: %s", tc.Name, truncateStr(result, 100)) 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 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 { switch {
case strings.HasPrefix(tc.Name, "memory_"): case strings.HasPrefix(tc.Name, "memory_"):
return a.executeMemoryTool(tc) return a.executeMemoryTool(tc)
@ -982,7 +1036,9 @@ func (a *Agent) executeToolCall(tc agentAPI.ToolCall) string {
return a.executeKnowledgeTool(tc) return a.executeKnowledgeTool(tc)
case strings.HasPrefix(tc.Name, "doc_"): case strings.HasPrefix(tc.Name, "doc_"):
return a.executeDocTool(tc) 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) return a.executeOutputSendTool(tc)
case tc.Name == "output_list_channels": case tc.Name == "output_list_channels":
return a.executeOutputListChannels() 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 a.skills != nil {
if sp := a.skills.GetInjectedPrompt(); sp != "" { if sp := a.skills.GetInjectedPrompt(); sp != "" {
prompt += "\n\n" + 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() channels := a.io.ListChannels()
chanNames := make([]interface{}, 0, len(channels)) for _, ch := range channels {
chanDesc := "输出通道名称: " if ch.Type != agentIO.DeviceOutput && ch.Type != agentIO.DeviceIO {
for i, ch := range channels { continue
if ch.Type == agentIO.DeviceOutput || ch.Type == agentIO.DeviceIO {
chanNames = append(chanNames, ch.Name)
if i > 0 {
chanDesc += ", "
}
chanDesc += ch.Name
} }
} capStr := a.io.GetChannelCapabilities(ch.Name).String()
if len(chanNames) == 0 { desc := ch.Description
chanNames = []interface{}{"default"} if desc == "" {
chanDesc = "输出通道名称: default" 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{}{ tools = append(tools, map[string]interface{}{
"type": "function", "type": "function",
"function": map[string]interface{}{ "function": map[string]interface{}{
"name": "output_list_channels", "name": "output_list_channels",
"description": "列出所有可用输出通道及其能力(如 text/file/image/audio可调用工具。", "description": "列出所有可用输出通道及其能力(如 text/file/image/audio对应的输出门工具名称。",
"parameters": map[string]interface{}{ "parameters": map[string]interface{}{
"type": "object", "type": "object",
"properties": map[string]interface{}{}, "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 { if a.pendingMedia != nil {
@ -2075,6 +2147,13 @@ func (a *Agent) enqueueConsolidationTask(task ConsolidationTask) {
// distillLoop — 定期心跳:上下文→文档 + 图→文档 + 图重整 // distillLoop — 定期心跳:上下文→文档 + 图→文档 + 图重整
func (a *Agent) 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 { if a.docStore == nil && a.memory == nil {
return return
} }
@ -2088,6 +2167,7 @@ func (a *Agent) distillLoop() {
a.distillContext() a.distillContext()
a.syncGraphToDocs() a.syncGraphToDocs()
a.reorgGraph() a.reorgGraph()
a.autoReloadPlugins()
case <-a.ctx.Done(): case <-a.ctx.Done():
return return
} }
@ -2461,24 +2541,90 @@ func (a *Agent) processConsolidation(input string) {
// executeOutputSendTool — AI 通过指定通道发送消息(校验通道能力) // executeOutputSendTool — AI 通过指定通道发送消息(校验通道能力)
func (a *Agent) executeOutputSendTool(tc agentAPI.ToolCall) string { 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) content, _ := tc.Arguments["content"].(string)
if channel == "" || content == "" { if channel == "" || content == "" {
return "channelcontent 不能为空" 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) caps := a.io.GetChannelCapabilities(channel)
if caps == 0 { if caps == 0 {
return fmt.Sprintf("通道 [%s] 不存在或不可用。可用通道请用 output_list_channels 查看", channel) return fmt.Sprintf("通道 [%s] 不存在或不可用。可用输出工具列表见 output_list_channels", channel)
} }
if !caps.Supports(agentIO.CapText) { if !caps.Supports(agentIO.CapText) {
return fmt.Sprintf("通道 [%s] 不支持文本输出(能力: %s", channel, caps.String()) 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) a.io.EmitTextTo("agent_io", channel, content)
return fmt.Sprintf("已通过 [%s] 通道发送", channel) 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 — 列出所有可用通道及其能力 // executeOutputListChannels — 列出所有可用通道及其能力
func (a *Agent) executeOutputListChannels() string { func (a *Agent) executeOutputListChannels() string {
channels := a.io.ListChannels() channels := a.io.ListChannels()
@ -2520,6 +2666,28 @@ func (a *Agent) executePluginReload() string {
return msg 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 异步执行独立任务 // executeSpawnChild 创建子 Agent 异步执行独立任务
// 不阻塞主 Agent子任务完成后通过 selfInputCh 通知主 Agent 查看结果 // 不阻塞主 Agent子任务完成后通过 selfInputCh 通知主 Agent 查看结果
func (a *Agent) executeSpawnChild(tc agentAPI.ToolCall) string { func (a *Agent) executeSpawnChild(tc agentAPI.ToolCall) string {
@ -2560,7 +2728,6 @@ func (a *Agent) runChildTask(taskID, task string) {
// 子 Agent 可调用核心以外的全部工具(记忆/知识/文档/社交),但不能调用输出工具 // 子 Agent 可调用核心以外的全部工具(记忆/知识/文档/社交),但不能调用输出工具
allTools := a.buildToolDefs() allTools := a.buildToolDefs()
childTools := make([]interface{}, 0, len(allTools)) 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 { for _, t := range allTools {
toolMap, ok := t.(map[string]interface{}) toolMap, ok := t.(map[string]interface{})
if !ok { if !ok {
@ -2571,9 +2738,10 @@ func (a *Agent) runChildTask(taskID, task string) {
continue continue
} }
name, _ := fn["name"].(string) name, _ := fn["name"].(string)
if !outputTools[name] { if strings.HasPrefix(name, "output_send__") || name == "output_list_channels" || name == "spawn_child" || name == "plgreload" {
childTools = append(childTools, t) continue
} }
childTools = append(childTools, t)
} }
var finalResult string var finalResult string
@ -2604,7 +2772,7 @@ func (a *Agent) runChildTask(taskID, task string) {
for _, ct := range resp.ToolCalls { for _, ct := range resp.ToolCalls {
var result string var result string
switch { 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) result = fmt.Sprintf("子 Agent 不允许调用输出工具: %s", ct.Name)
case ct.Name == "spawn_child" || ct.Name == "plgreload": case ct.Name == "spawn_child" || ct.Name == "plgreload":
result = fmt.Sprintf("子 Agent 不允许调用系统工具: %s", ct.Name) result = fmt.Sprintf("子 Agent 不允许调用系统工具: %s", ct.Name)
@ -2796,7 +2964,14 @@ func (a *Agent) runStage(stage sdk.Stage, ctx *sdk.StageContext) bool {
return false return false
} }
ctx.Phase = stage 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 return ctx.Response != nil
} }

View File

@ -1,13 +1,13 @@
package core package core
import ( import (
"strings"
"testing" "testing"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api" agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io" agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
) )
// mockOutputDevice implements agentIO.Device for testing output tools
type mockOutputDevice struct { type mockOutputDevice struct {
name string name string
caps agentIO.OutputCapability caps agentIO.OutputCapability
@ -61,24 +61,11 @@ func TestExecuteOutputSendTool(t *testing.T) {
}) })
a := &Agent{io: io} a := &Agent{io: io}
tc := agentAPI.ToolCall{Name: "output_send", Arguments: map[string]interface{}{ tc := agentAPI.ToolCall{Name: "output_send__screen", Arguments: map[string]interface{}{
"channel": "screen", "content": `{"content":"hello world"}`,
"content": "hello world",
}} }}
result := a.executeOutputSendTool(tc) result := a.executeOutputSendTool(tc)
if result != "已通过 [screen] 通道发送" { if !strings.Contains(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 不能为空" {
t.Errorf("unexpected result: %s", result) t.Errorf("unexpected result: %s", result)
} }
} }
@ -86,25 +73,22 @@ func TestExecuteOutputSendToolMissingChannel(t *testing.T) {
func TestExecuteOutputSendToolEmptyContent(t *testing.T) { func TestExecuteOutputSendToolEmptyContent(t *testing.T) {
io := agentIO.NewIOManager() io := agentIO.NewIOManager()
a := &Agent{io: io} a := &Agent{io: io}
tc := agentAPI.ToolCall{Name: "output_send", Arguments: map[string]interface{}{ tc := agentAPI.ToolCall{Name: "output_send__screen", Arguments: map[string]interface{}{}}
"channel": "screen",
}}
result := a.executeOutputSendTool(tc) result := a.executeOutputSendTool(tc)
if result != "channel 和 content 不能为空" { if result == "" || strings.Contains(result, "已通过") {
t.Errorf("unexpected result: %s", result) t.Errorf("expected error for empty content, got: %s", result)
} }
} }
func TestExecuteOutputSendToolChannelNotExist(t *testing.T) { func TestExecuteOutputSendToolChannelNotExist(t *testing.T) {
io := agentIO.NewIOManager() io := agentIO.NewIOManager()
a := &Agent{io: io} a := &Agent{io: io}
tc := agentAPI.ToolCall{Name: "output_send", Arguments: map[string]interface{}{ tc := agentAPI.ToolCall{Name: "output_send__nonexistent", Arguments: map[string]interface{}{
"channel": "nonexistent",
"content": "hello", "content": "hello",
}} }}
result := a.executeOutputSendTool(tc) result := a.executeOutputSendTool(tc)
if result != "通道 [nonexistent] 不存在或不可用。可用通道请用 output_list_channels 查看" { if !strings.Contains(result, "不存在") && !strings.Contains(result, "不可用") {
t.Errorf("unexpected result: %s", result) t.Errorf("expected error for nonexistent channel, got: %s", result)
} }
} }
@ -116,23 +100,26 @@ func TestExecuteOutputSendToolNoTextCap(t *testing.T) {
}) })
a := &Agent{io: io} a := &Agent{io: io}
tc := agentAPI.ToolCall{Name: "output_send", Arguments: map[string]interface{}{ tc := agentAPI.ToolCall{Name: "output_send__camera", Arguments: map[string]interface{}{
"channel": "camera",
"content": "hello", "content": "hello",
}} }}
result := a.executeOutputSendTool(tc) result := a.executeOutputSendTool(tc)
if result == "已通过 [camera] 通道发送" { if strings.Contains(result, "已通过") {
t.Errorf("should reject channel without text capability") t.Errorf("should reject channel without text capability")
} }
} }
func TestBuildToolDefsOutputToolsAlwaysPresent(t *testing.T) { func TestBuildToolDefsOutputToolsWithDevice(t *testing.T) {
io := agentIO.NewIOManager() io := agentIO.NewIOManager()
io.RegisterDevice(&mockOutputDevice{
name: "screen",
caps: agentIO.CapText,
})
a := &Agent{io: io, knowledge: nil, docStore: nil, pluginReg: nil} a := &Agent{io: io, knowledge: nil, docStore: nil, pluginReg: nil}
tools := a.buildToolDefs() tools := a.buildToolDefs()
foundSend := false foundSend := false
foundList := false foundHelp := false
for _, td := range tools { for _, td := range tools {
m, ok := td.(map[string]interface{}) m, ok := td.(map[string]interface{})
if !ok { if !ok {
@ -144,17 +131,17 @@ func TestBuildToolDefsOutputToolsAlwaysPresent(t *testing.T) {
} }
name, _ := fn["name"].(string) name, _ := fn["name"].(string)
switch name { switch name {
case "output_send": case "output_send__screen":
foundSend = true foundSend = true
case "output_list_channels": case "output_send__screen_help":
foundList = true foundHelp = true
} }
} }
if !foundSend { 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 { if !foundHelp {
t.Error("output_list_channels should always be in tools") 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() io := agentIO.NewIOManager()
a := &Agent{io: io} a := &Agent{io: io}
tools := a.buildToolDefs() tools := a.buildToolDefs()
// should have at least output_send, output_list_channels if len(tools) < 1 {
if len(tools) < 2 { t.Errorf("expected at least 1 tool, got %d", len(tools))
t.Errorf("expected at least 2 tools, got %d", len(tools))
} }
} }

View File

@ -10,6 +10,7 @@ import (
"sync" "sync"
"time" "time"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
) )
@ -28,18 +29,18 @@ const contextFlushInterval = 5 * time.Second
// RelevanceContext — 基于相关性的上下文管理,非固定阈值 // RelevanceContext — 基于相关性的上下文管理,非固定阈值
type RelevanceContext struct { type RelevanceContext struct {
mu sync.Mutex mu sync.Mutex
events []*ContextEvent events []*ContextEvent
veczer *vector.TFIDFVectorizer embedder *memory.LocalWordEmbedder
trained bool trained bool
savePath string // 持久化路径,空则不持久化 savePath string
saveTimer *time.Timer saveTimer *time.Timer
dirty bool dirty bool
} }
func NewRelevanceContext(savePath string) *RelevanceContext { func NewRelevanceContext(savePath string) *RelevanceContext {
rc := &RelevanceContext{ rc := &RelevanceContext{
veczer: vector.NewTFIDFVectorizer(2), embedder: memory.NewLocalWordEmbedder(),
savePath: savePath, savePath: savePath,
} }
if savePath != "" { if savePath != "" {
@ -59,7 +60,7 @@ func (c *RelevanceContext) load() {
return return
} }
for _, evt := range events { 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 c.events = events
} }
@ -83,10 +84,9 @@ func (c *RelevanceContext) Append(evt ContextEvent) {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() 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.events = append(c.events, &evt)
// 增量训练向量化器
c.trained = false c.trained = false
c.save() c.save()
@ -146,10 +146,9 @@ func (c *RelevanceContext) Prune(currentInput string, topK int, docStore *docume
return 0 return 0
} }
// 确保向量化器已训练
c.ensureTrained() c.ensureTrained()
queryVec := c.veczer.Vectorize(currentInput) queryVec := c.embedder.Vectorize(currentInput)
// 计算每条候选上下文与当前输入的相关性 // 计算每条候选上下文与当前输入的相关性
type scored struct { type scored struct {
@ -263,10 +262,9 @@ func (c *RelevanceContext) ensureTrained() {
for i, evt := range c.events { for i, evt := range c.events {
texts[i] = evt.Input + " " + evt.Response texts[i] = evt.Input + " " + evt.Response
} }
c.veczer.Train(texts) c.embedder.Train(texts)
// 重算所有事件向量,与新的向量化器特征空间对齐
for _, evt := range c.events { 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 c.trained = true
} }

View File

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

View File

@ -3,6 +3,7 @@ package core
import ( import (
"fmt" "fmt"
"log" "log"
"runtime/debug"
"sync" "sync"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
@ -53,7 +54,13 @@ func (h *StageHost) GetToolDefs() []sdk.ToolDef {
return defs 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() h.mu.RLock()
handler, ok := h.tools[name] handler, ok := h.tools[name]
h.mu.RUnlock() h.mu.RUnlock()
@ -72,6 +79,22 @@ func (h *StageHost) ToolPlugin(name string) string {
return h.toolPlugins[name] 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 { func inferToolPlugin(name string) string {
for i := 0; i < len(name); i++ { for i := 0; i < len(name); i++ {
if name[i] == '_' { if name[i] == '_' {
@ -100,6 +123,11 @@ func (h *StageHost) RunStage(stage sdk.Stage, ctx *sdk.StageContext) {
wg.Add(1) wg.Add(1)
go func(fn sdk.StageHandler) { go func(fn sdk.StageHandler) {
defer wg.Done() defer wg.Done()
defer func() {
if r := recover(); r != nil {
log.Printf("[stage] handler panic: %v", r)
}
}()
if err := fn(ctx); err != nil { if err := fn(ctx); err != nil {
errCh <- err errCh <- err
} }

View File

@ -3,6 +3,7 @@ package io
import ( import (
"fmt" "fmt"
"log" "log"
"runtime/debug"
"sync" "sync"
"time" "time"
) )
@ -143,6 +144,12 @@ func (m *IOManager) RegisterDevice(dev Device) error {
return nil 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 { func (m *IOManager) StartAll() error {
m.mu.RLock() m.mu.RLock()
devices := make([]Device, 0, len(m.devices)) devices := make([]Device, 0, len(m.devices))
@ -336,7 +343,7 @@ func (m *IOManager) GetAllTools() []ToolDef {
return tools 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() m.mu.RLock()
type nameDevice struct { type nameDevice struct {
name string name string
@ -356,6 +363,12 @@ func (m *IOManager) ExecuteTool(name string, args map[string]interface{}) (inter
if len(candidates) == 0 { if len(candidates) == 0 {
return nil, fmt.Errorf("tool %s not found", name) 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) return candidates[0].dev.Execute(name, args)
} }

View File

@ -350,8 +350,9 @@ func (r *ConfigRegistry) seedDBValues(dataDir string) {
set("core.agent.workdir", "") set("core.agent.workdir", "")
set("core.agent.system_prompt", `你是 HomeAgent一个持续运行的个人管家。 set("core.agent.system_prompt", `你是 HomeAgent一个持续运行的个人管家。
你的回复默认发送到用户的输入来源,无需额外工具。 你的回复默认发送到用户的输入来源,无需额外工具。
如需异步发送消息到其他通道,使用 output_send 输出回复请使用 output_send__{通道名} 工具content 为 JSON 字符串。用 output_list_channels 查看可用通道
使用 output_list_channels 查看可用通道 使用 output_send__{通道名}_help 查看每个通道的 JSON 格式说明
输出通道可多次调用,长消息应当分多次发出而不是一口气发完。
当用户上传图片或音频时,系统会自动附着媒体内容。如果模型不支持直接处理多媒体,请调用对应的媒体处理工具。 当用户上传图片或音频时,系统会自动附着媒体内容。如果模型不支持直接处理多媒体,请调用对应的媒体处理工具。

View File

@ -2,18 +2,21 @@ package events
import ( import (
"fmt" "fmt"
"log"
"sync" "sync"
) )
type EventType string type EventType string
const ( const (
EventRawInput EventType = "raw_input" EventRawInput EventType = "raw_input"
EventAgentOutput EventType = "agent_output" EventAgentOutput EventType = "agent_output"
EventToolCall EventType = "tool_call" EventAgentLLMChain EventType = "agent_llm_chain"
EventReasoning EventType = "reasoning" EventToolCall EventType = "tool_call"
EventSystem EventType = "system" EventReasoning EventType = "reasoning"
EventAll EventType = "*" EventStage EventType = "stage"
EventSystem EventType = "system"
EventAll EventType = "*"
) )
type Event struct { type Event struct {
@ -45,13 +48,22 @@ func (b *Bus) Publish(evt *Event) {
b.mu.RUnlock() b.mu.RUnlock()
for _, h := range allHandlers { for _, h := range allHandlers {
h(evt) b.safeCall(h, evt)
} }
for _, h := range typeHandlers { 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() { func (b *Bus) Subscribe(eventType EventType, handler Handler) func() {
b.mu.Lock() b.mu.Lock()
b.subs[eventType] = append(b.subs[eventType], handler) b.subs[eventType] = append(b.subs[eventType], handler)

View File

@ -11,6 +11,7 @@ import (
"sync" "sync"
"time" "time"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
) )
@ -165,7 +166,7 @@ func (s *Store) Add(name, content string) error {
Content: content, Content: content,
Path: path, Path: path,
Category: sanitize(category), Category: sanitize(category),
Tags: extractKeywords(name + " " + content), Tags: memory.ExtractKeywords(name + " " + content),
UpdatedAt: now, UpdatedAt: now,
} }
s.items[id] = k s.items[id] = k
@ -381,7 +382,7 @@ func (s *Store) scanDir(category, dirName string) {
Content: content, Content: content,
Path: contentPath, Path: contentPath,
Category: category, Category: category,
Tags: extractKeywords(dirName + " " + content), Tags: memory.ExtractKeywords(dirName + " " + content),
UpdatedAt: now, UpdatedAt: now,
} }
s.items[name] = k s.items[name] = k
@ -411,33 +412,4 @@ func sanitize(name string) string {
return name 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
}

118
internal/memory/cut.go Normal file
View File

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

View File

@ -11,6 +11,7 @@ import (
"sync" "sync"
"time" "time"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
) )
@ -388,7 +389,7 @@ func summarizeEntries(entries []ContextEntry) string {
var topics []string var topics []string
for _, e := range entries { for _, e := range entries {
sources[e.Source]++ sources[e.Source]++
words := extractKeywords(e.Content) words := memory.ExtractKeywords(e.Content)
topics = append(topics, words...) topics = append(topics, words...)
} }
@ -420,7 +421,7 @@ func summarizeEntries(entries []ContextEntry) string {
func extractTags(entries []ContextEntry) []string { func extractTags(entries []ContextEntry) []string {
tagSet := make(map[string]bool) tagSet := make(map[string]bool)
for _, e := range entries { for _, e := range entries {
for _, kw := range extractKeywords(e.Content) { for _, kw := range memory.ExtractKeywords(e.Content) {
tagSet[kw] = true tagSet[kw] = true
} }
} }
@ -439,7 +440,7 @@ func extractEntities(entries []ContextEntry) []string {
var entities []string var entities []string
seen := make(map[string]bool) seen := make(map[string]bool)
for _, e := range entries { for _, e := range entries {
for _, kw := range extractKeywords(e.Content) { for _, kw := range memory.ExtractKeywords(e.Content) {
if len(kw) >= 2 && !seen[kw] { if len(kw) >= 2 && !seen[kw] {
seen[kw] = true seen[kw] = true
entities = append(entities, kw) entities = append(entities, kw)
@ -452,32 +453,6 @@ func extractEntities(entries []ContextEntry) []string {
return entities 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 { func truncate(s string, max int) string {
runes := []rune(s) runes := []rune(s)
if len(runes) > max { if len(runes) > max {

View File

@ -4,6 +4,8 @@ import (
"os" "os"
"testing" "testing"
"time" "time"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
) )
func TestInsertAndQuery(t *testing.T) { func TestInsertAndQuery(t *testing.T) {
@ -186,7 +188,7 @@ func TestSummarizeEntries(t *testing.T) {
} }
func TestExtractKeywords(t *testing.T) { func TestExtractKeywords(t *testing.T) {
kws := extractKeywords("今天天气很好") kws := memory.ExtractKeywords("今天天气很好")
if len(kws) == 0 { if len(kws) == 0 {
t.Error("should extract keywords from Chinese text") t.Error("should extract keywords from Chinese text")
} }

221
internal/memory/embedder.go Normal file
View File

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

View File

@ -132,6 +132,9 @@ func (g *GraphDB) Commit(triples []Triple, sessionID string, turnID int) (int, i
if t.Subject == "" || t.Relation == "" || t.Object == "" { if t.Subject == "" || t.Relation == "" || t.Object == "" {
continue continue
} }
if !validEntityName(t.Subject) || !validEntityName(t.Object) {
continue
}
subjType := t.SubjectType subjType := t.SubjectType
if subjType == "" { if subjType == "" {
@ -161,11 +164,11 @@ func (g *GraphDB) Commit(triples []Triple, sessionID string, turnID int) (int, i
var sourceID, targetID int64 var sourceID, targetID int64
err = tx.QueryRow("SELECT id FROM entities WHERE name = ?", t.Subject).Scan(&sourceID) err = tx.QueryRow("SELECT id FROM entities WHERE name = ?", t.Subject).Scan(&sourceID)
if err != nil { 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) err = tx.QueryRow("SELECT id FROM entities WHERE name = ?", t.Object).Scan(&targetID)
if err != nil { if err != nil {
return 0, 0, err return 0, 0, fmt.Errorf("object %q: %w", t.Object, err)
} }
_, err = tx.Exec( _, err = tx.Exec(
@ -186,7 +189,27 @@ func (g *GraphDB) Commit(triples []Triple, sessionID string, turnID int) (int, i
return entitiesCreated, relationsCreated, nil 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) { func (g *GraphDB) upsertEntity(tx *sql.Tx, name string, entityType string) (int, error) {
if !validEntityName(name) {
return 0, nil
}
result, err := tx.Exec( result, err := tx.Exec(
`INSERT INTO entities (name, type) VALUES (?, ?) `INSERT INTO entities (name, type) VALUES (?, ?)
ON CONFLICT(name) DO UPDATE SET ON CONFLICT(name) DO UPDATE SET

View File

@ -94,7 +94,7 @@ func (idx *Indexer) BuildContext(userInput string) *InjectedContext {
vectorEntities := idx.vectorSearchEntities(userInput) vectorEntities := idx.vectorSearchEntities(userInput)
// 2. 关键词搜索:已有逻辑 // 2. 关键词搜索:已有逻辑
keywords := extractKeywords(userInput) keywords := ExtractKeywords(userInput)
if len(keywords) == 0 && len(vectorEntities) == 0 { if len(keywords) == 0 && len(vectorEntities) == 0 {
keywords = []string{userInput} 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 { func buildIndexSummary(entities []Entity) string {
if len(entities) == 0 { if len(entities) == 0 {
return "" return ""

View File

@ -186,9 +186,9 @@ func TestExtractKeywords(t *testing.T) {
{"的了的", 0}, // all stop words {"的了的", 0}, // all stop words
} }
for _, tt := range tests { for _, tt := range tests {
kw := extractKeywords(tt.input) kw := ExtractKeywords(tt.input)
if len(kw) < tt.min { 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)
} }
} }
} }

View File

@ -42,7 +42,7 @@ func TestBridgeE2E_WebPlugin(t *testing.T) {
} }
sett := sdk.NewSettings("web", nil) 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) plg, err := newDLLPlugin(dllPath, "web", nil)
if err != nil { if err != nil {
@ -136,11 +136,11 @@ func TestBridgeE2E_SanitizerStages(t *testing.T) {
} }
sett := sdk.NewSettings("sanitizer", nil) sett := sdk.NewSettings("sanitizer", nil)
psdk := sdk.New("sanitizer", nil, nil, nil, nil, nil, nil, nil, sett, psdk := sdk.New("sanitizer", sdk.SDKConfig{Settings: sett,
func(name string, def sdk.ToolDef, handler sdk.ToolHandler) error { return nil }, RegTool: func(name string, def sdk.ToolDef, handler sdk.ToolHandler) error { return nil },
regStage, RegStage: regStage,
func(name string) error { return nil }, RegAPI: func(name string) error { return nil },
) })
plg, err := newDLLPlugin(dllPath, "sanitizer", nil) plg, err := newDLLPlugin(dllPath, "sanitizer", nil)
if err != nil { if err != nil {

View File

@ -60,6 +60,8 @@ type Registry struct {
instances []sdk.Plugin instances []sdk.Plugin
factories map[string]NativeFactory factories map[string]NativeFactory
pluginAutoRestart map[string]bool
iom *agentIO.IOManager iom *agentIO.IOManager
evBus *events.Bus evBus *events.Bus
memDB *memory.GraphDB memDB *memory.GraphDB
@ -77,8 +79,9 @@ type Registry struct {
func NewRegistry() *Registry { func NewRegistry() *Registry {
return &Registry{ return &Registry{
plugins: make(map[string]sdk.Plugin), plugins: make(map[string]sdk.Plugin),
factories: make(map[string]NativeFactory), 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) 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 { func (r *Registry) buildSDK(name string) *sdk.PluginSDK {
sett := sdk.NewSettings(name, r.cfgReg) 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 } regAPI = func(name string) error { return nil }
} }
return sdk.New(name, regOutput := func(chName string, caps int, desc string, handler sdk.ToolHandler) error {
r.iom, r.evBus, if r.iom == nil {
sdk.NewGraphMemory(r.memDB), return nil
sdk.NewTextMemory(r.textMem), }
sdk.NewDocMemory(r.docStore), return r.iom.RegisterDevice(&channelDevice{
sdk.NewKnowledge(r.ks), name: chName,
sdk.NewLLM(r.mgr), caps: agentIO.OutputCapability(caps),
sett, desc: desc,
regTool, regStage, regAPI, 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 { func (r *Registry) Load(dir string) error {
@ -201,6 +238,7 @@ func (r *Registry) Load(dir string) error {
r.mu.Lock() r.mu.Lock()
r.plugins[name] = p r.plugins[name] = p
r.pluginAutoRestart[name] = plgSDK.AutoRestart()
r.instances = append(r.instances, p) r.instances = append(r.instances, p)
r.mu.Unlock() r.mu.Unlock()
log.Printf("[plugin] loaded: %s", name) log.Printf("[plugin] loaded: %s", name)
@ -263,6 +301,7 @@ func (r *Registry) loadOne(plgDir, name string) bool {
r.mu.Lock() r.mu.Lock()
r.plugins[name] = plg r.plugins[name] = plg
r.pluginAutoRestart[name] = plgSDK.AutoRestart()
r.instances = append(r.instances, plg) r.instances = append(r.instances, plg)
r.mu.Unlock() r.mu.Unlock()
log.Printf("[plugin] loaded: %s", name) log.Printf("[plugin] loaded: %s", name)
@ -279,6 +318,7 @@ func (r *Registry) StopAll() {
} }
r.plugins = make(map[string]sdk.Plugin) r.plugins = make(map[string]sdk.Plugin)
r.instances = nil r.instances = nil
r.pluginAutoRestart = make(map[string]bool)
} }
func (r *Registry) Reload(dir string) (string, error) { 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 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 { func (r *Registry) List() []string {
r.mu.RLock() r.mu.RLock()
defer r.mu.RUnlock() defer r.mu.RUnlock()
@ -306,6 +372,16 @@ func (r *Registry) Get(name string) sdk.Plugin {
return r.plugins[name] 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 { func (r *Registry) PluginMetas() map[string]PluginMeta {
metas := make(map[string]PluginMeta) metas := make(map[string]PluginMeta)
globalPluginMeta.Range(func(key, val interface{}) bool { globalPluginMeta.Range(func(key, val interface{}) bool {

View File

@ -177,6 +177,7 @@ func New(name string) *Plugin {
func (p *Plugin) Name() string { return p.name } func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error { func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.SetAutoRestart(true)
s.Settings().RegisterDef(sdk.ConfigDef{ s.Settings().RegisterDef(sdk.ConfigDef{
Key: "default_timeout", Type: "string", DisplayName: "默认终端超时", Key: "default_timeout", Type: "string", DisplayName: "默认终端超时",
Description: "终端自动关闭的默认时间,例如 5m, 10m, 30m, 1h默认 5m", Description: "终端自动关闭的默认时间,例如 5m, 10m, 30m, 1h默认 5m",

View File

@ -31,8 +31,7 @@ func (tc *toolCapture) RegisterAPI(name string) error {
func setupPlugin() (*Plugin, *toolCapture, error) { func setupPlugin() (*Plugin, *toolCapture, error) {
p := New("agentcli") p := New("agentcli")
tc := newToolCapture() tc := newToolCapture()
// Use nil for fields we don't need (iom, eventBus, etc.) sdk := sdk.New("agentcli", sdk.SDKConfig{RegTool: tc.RegisterTool, RegStage: tc.RegisterStage, RegAPI: tc.RegisterAPI})
sdk := sdk.New("agentcli", nil, nil, nil, nil, nil, nil, nil, nil, tc.RegisterTool, tc.RegisterStage, tc.RegisterAPI)
if err := p.Start(sdk); err != nil { if err := p.Start(sdk); err != nil {
return nil, nil, err return nil, nil, err
} }

View File

@ -70,6 +70,7 @@ func New(name, socketPath string) *Plugin {
func (p *Plugin) Name() string { return p.name } func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error { func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.SetAutoRestart(true)
s.Settings().RegisterDef(sdk.ConfigDef{ s.Settings().RegisterDef(sdk.ConfigDef{
Key: "api_key", Type: "password", DisplayName: "CLI API 密钥", Key: "api_key", Type: "password", DisplayName: "CLI API 密钥",
Description: "CLI 客户端连接时需提供的认证密钥(留空则使用 WebUI 密钥)", Description: "CLI 客户端连接时需提供的认证密钥(留空则使用 WebUI 密钥)",

View File

@ -81,6 +81,7 @@ func New(name string) *Plugin {
func (p *Plugin) Name() string { return p.name } func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error { func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.SetAutoRestart(true)
s.Settings().RegisterDef(sdk.ConfigDef{ s.Settings().RegisterDef(sdk.ConfigDef{
Key: "default_timeout", Type: "string", DisplayName: "默认命令超时", Key: "default_timeout", Type: "string", DisplayName: "默认命令超时",
Description: "命令执行的默认超时时间,例如 30s, 1m, 5m默认 30s", Description: "命令执行的默认超时时间,例如 30s, 1m, 5m默认 30s",

View File

@ -34,7 +34,7 @@ func (tc *toolCapture) RegisterAPI(name string) error { return nil }
func setupPlugin() (*Plugin, *toolCapture, error) { func setupPlugin() (*Plugin, *toolCapture, error) {
p := New("cmd") p := New("cmd")
tc := newToolCapture() 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 { if err := p.Start(sdk); err != nil {
return nil, nil, err return nil, nil, err
} }

View File

@ -34,6 +34,7 @@ func New(name string) *Plugin {
func (p *Plugin) Name() string { return p.name } func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error { func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.SetAutoRestart(true)
p.sdk = s p.sdk = s
s.Settings().RegisterDef(sdk.ConfigDef{ s.Settings().RegisterDef(sdk.ConfigDef{
Key: "dir", Key: "dir",

View File

@ -113,6 +113,7 @@ func New(name string) *Plugin {
func (p *Plugin) Name() string { return p.name } func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error { func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.SetAutoRestart(true)
p.autoInterval = 30 * time.Minute p.autoInterval = 30 * time.Minute
p.llmTimeout = 120 * time.Second p.llmTimeout = 120 * time.Second
p.llmMaxTurns = 20 p.llmMaxTurns = 20

View File

@ -42,7 +42,7 @@ func setupPlugin() (*Plugin, *toolCapture, error) {
Configure(sh, iom, pr, nil, nil, nil, nil, nil) Configure(sh, iom, pr, nil, nil, nil, nil, nil)
p := New("healthcheck") p := New("healthcheck")
tc := newToolCapture() 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 { if err := p.Start(sdk); err != nil {
return nil, nil, err return nil, nil, err
} }
@ -192,7 +192,7 @@ func TestHealthcheckWithMemory(t *testing.T) {
Configure(sh, iom, pr, memDB, nil, nil, nil, nil) Configure(sh, iom, pr, memDB, nil, nil, nil, nil)
p := New("healthcheck") p := New("healthcheck")
tc := newToolCapture() 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 { if err := p.Start(sdk); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -235,7 +235,7 @@ func TestHealthcheckWithKnowledge(t *testing.T) {
Configure(sh, iom, pr, nil, ks, nil, nil, nil) Configure(sh, iom, pr, nil, ks, nil, nil, nil)
p := New("healthcheck") p := New("healthcheck")
tc := newToolCapture() 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 { if err := p.Start(sdk); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -289,7 +289,7 @@ func TestHealthcheckWithDocStore(t *testing.T) {
Configure(sh, iom, pr, nil, nil, ds, nil, nil) Configure(sh, iom, pr, nil, nil, ds, nil, nil)
p := New("healthcheck") p := New("healthcheck")
tc := newToolCapture() 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 { if err := p.Start(sdk); err != nil {
t.Fatal(err) t.Fatal(err)
} }

View File

@ -41,6 +41,7 @@ func New(name string) *Plugin {
func (p *Plugin) Name() string { return p.name } func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error { func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.SetAutoRestart(true)
cfgs, err := p.loadConfig(s) cfgs, err := p.loadConfig(s)
if err != nil { if err != nil {
return fmt.Errorf("load mcp config: %w", err) return fmt.Errorf("load mcp config: %w", err)

View File

@ -68,6 +68,7 @@ func New(name, skillsDir string) *Plugin {
func (p *Plugin) Name() string { return p.name } func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error { func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.SetAutoRestart(true)
p.sdk = s p.sdk = s
s.Settings().RegisterDef(sdk.ConfigDef{ s.Settings().RegisterDef(sdk.ConfigDef{

View File

@ -326,13 +326,13 @@ func TestLoadOCPluginViaPluginStart(t *testing.T) {
var registeredTools []string var registeredTools []string
registeredHandlers := make(map[string]sdk.ToolHandler) registeredHandlers := make(map[string]sdk.ToolHandler)
sdk := sdk.New("openclaw", nil, nil, nil, nil, nil, nil, nil, nil, sdk := sdk.New("openclaw", sdk.SDKConfig{
func(name string, def sdk.ToolDef, handler sdk.ToolHandler) error { RegTool: func(name string, def sdk.ToolDef, handler sdk.ToolHandler) error {
registeredTools = append(registeredTools, name) registeredTools = append(registeredTools, name)
registeredHandlers[name] = handler registeredHandlers[name] = handler
return nil return nil
}, },
nil, nil) })
if err := p.Start(sdk); err != nil { if err := p.Start(sdk); err != nil {
t.Fatalf("start plugin: %v", err) t.Fatalf("start plugin: %v", err)

View File

@ -63,6 +63,7 @@ func New(name string) *Plugin {
func (p *Plugin) Name() string { return p.name } func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error { func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.SetAutoRestart(true)
s.Settings().RegisterDef(sdk.ConfigDef{ s.Settings().RegisterDef(sdk.ConfigDef{
Key: "http_addr", Key: "http_addr",
Default: HTTPAddr, Default: HTTPAddr,

View File

@ -40,6 +40,7 @@ func New(name string) *Plugin {
func (p *Plugin) Name() string { return p.name } func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error { func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.SetAutoRestart(true)
p.maxDur = 24 * time.Hour p.maxDur = 24 * time.Hour
s.Settings().RegisterDef(sdk.ConfigDef{ s.Settings().RegisterDef(sdk.ConfigDef{
Key: "max_duration", Type: "string", DisplayName: "最大定时时长", Key: "max_duration", Type: "string", DisplayName: "最大定时时长",

View File

@ -4,9 +4,10 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HomeAgent Dashboard</title> <title>HomeAgent Dashboard</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js" onerror="window._THREE_FAILED=true"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script> <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js" onerror="window._THREE_FAILED=true"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/4.3.0/marked.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/marked/4.3.0/marked.min.js" onerror="console.warn('marked CDN failed')"></script>
<script>setTimeout(function(){if(!window.THREE)window._THREE_FAILED=true},8000)</script>
<style> <style>
:root { :root {
--bg-primary: #0f172a; --bg-primary: #0f172a;
@ -152,9 +153,9 @@ code { font-family:monospace; font-size:12px; color:var(--pre-color) }
.empty-state { text-align:center; padding:40px 20px; color:var(--text-muted) } .empty-state { text-align:center; padding:40px 20px; color:var(--text-muted) }
.empty-state p { font-size:14px; margin-bottom:8px } .empty-state p { font-size:14px; margin-bottom:8px }
.empty-state .icon { font-size:36px; margin-bottom:12px; opacity:.5 } .empty-state .icon { font-size:36px; margin-bottom:12px; opacity:.5 }
.chat-layout { display:flex; gap:16px; height:calc(100vh - 100px); min-height:60vh } .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 { flex:2; min-width:0; min-height:0; display:flex; flex-direction:column }
.chat-main .card { flex:1; display:flex; flex-direction:column; margin-bottom:0 } .chat-main .card { flex:1; display:flex; flex-direction:column; margin-bottom:0; min-height:0 }
.chat-main .card h2 { flex-shrink:0 } .chat-main .card h2 { flex-shrink:0 }
.chat-messages { flex:1; overflow-y:auto; padding:12px; border:1px solid var(--border-color); border-radius:8px; background:var(--chat-bg); margin-bottom:0; display:flex; flex-direction:column; gap:4px; min-height:0 } .chat-messages { flex:1; overflow-y:auto; padding:12px; border:1px solid var(--border-color); border-radius:8px; background:var(--chat-bg); margin-bottom:0; display:flex; flex-direction:column; gap:4px; min-height:0 }
.msg { display:flex; gap:8px; margin-bottom:2px; align-items:flex-start; max-width:85% } .msg { display:flex; gap:8px; margin-bottom:2px; align-items:flex-start; max-width:85% }
@ -229,6 +230,20 @@ code { font-family:monospace; font-size:12px; color:var(--pre-color) }
#sm-container-chat { height:260px; background:var(--bg-input); border-radius:6px; border:1px solid var(--border-color); overflow:hidden; position:relative } #sm-container-chat { height:260px; background:var(--bg-input); border-radius:6px; border:1px solid var(--border-color); overflow:hidden; position:relative }
#sm-container-chat canvas { display:block } #sm-container-chat canvas { display:block }
.toggle-row { margin-top:8px; display:flex; align-items:center; gap:12px }
.toggle-row .label-text { color:#8888aa; font-size:12px }
.toggle-switch { position:relative; width:36px; height:20px; cursor:pointer; flex-shrink:0 }
.toggle-track { position:absolute; inset:0; background:rgba(60,60,80,0.8); border-radius:10px; transition:all 0.3s; border:1px solid rgba(100,100,255,0.2) }
.toggle-track.on { background:rgba(68,136,255,0.5); border-color:#4488ff }
.toggle-knob { position:absolute; width:16px; height:16px; left:2px; top:2px; background:#6666aa; border-radius:50%; transition:all 0.3s }
.toggle-knob.on { left:18px; background:#4488ff }
.toggle-btn { display:flex; align-items:center; gap:4px; padding:2px 8px; border-radius:4px; border:1px solid rgba(100,100,255,0.15); background:transparent; color:#8888aa; font-size:12px; font-family:inherit; cursor:pointer; transition:all 0.2s }
.toggle-btn:hover { background:rgba(68,136,255,0.15); color:#fff }
.toggle-btn.on { background:rgba(68,136,255,0.3); color:#4488ff; border-color:#4488ff }
.label-text { color:#8888aa; font-size:12px }
.loading-spinner { width:32px; height:32px; border:3px solid rgba(68,136,255,0.15); border-top:3px solid #4488ff; border-radius:50%; animation:spin 0.8s linear infinite }
@keyframes spin { to { transform:rotate(360deg) } }
.sidebar-subnav { display:flex; gap:0; border-bottom:1px solid var(--border-color); margin-bottom:10px } .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 { 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:hover { color:var(--text-primary) }
@ -298,6 +313,7 @@ let state = {
selectedSection: 'core', selectedSection: 'core',
messages: [], messages: [],
chatLoading: false, chatLoading: false,
chatStage: '',
healthResult: null, healthResult: null,
starmapInit: false, starmapInit: false,
starmapLoading: false, starmapLoading: false,
@ -412,7 +428,7 @@ function switchTab(n) {
// ===== Tab Render Dispatch ===== // ===== Tab Render Dispatch =====
async function renderAll() { async function renderAll() {
try { var s = await api('/status'); state.status = s } catch(e) {} try { var s = await api('/status'); state.status = s; state.startedAt = s.startedAt ? new Date(s.startedAt).getTime() : null } catch(e) {}
try { state.kernel = await api('/kernel') } catch(e) {} try { state.kernel = await api('/kernel') } catch(e) {}
try { try {
var s = await api('/settings'); var s = await api('/settings');
@ -434,6 +450,30 @@ async function renderAll() {
applyI18n(); applyI18n();
} }
function fmtUptime(ms) {
var s = Math.floor(ms / 1000);
if (s < 60) return s + 's';
var m = Math.floor(s / 60); s = s % 60;
if (m < 60) return m + 'm ' + s + 's';
var h = Math.floor(m / 60); m = m % 60;
return h + 'h ' + m + 'm ' + s + 's';
}
var uptimeTick = null;
function startUptimeTicker() {
if (uptimeTick) clearInterval(uptimeTick);
uptimeTick = setInterval(function() {
var el = document.querySelector('#uptime-val');
if (el && state.startedAt) {
var now = Date.now();
el.textContent = fmtUptime(now - state.startedAt);
} else if (!state.startedAt) {
var el2 = document.querySelector('#uptime-val');
if (el2) el2.textContent = '-';
}
}, 1000);
}
// ===== Overview ===== // ===== Overview =====
function statCard(l, v) { function statCard(l, v) {
return '<div class="card stat-card"><div class="stat-value">' + v + '</div><div class="stat-label">' + l + '</div></div>'; return '<div class="card stat-card"><div class="stat-value">' + v + '</div><div class="stat-label">' + l + '</div></div>';
@ -444,7 +484,7 @@ function renderOverview() {
var k = state.kernel; var k = state.kernel;
var html = '<div class="grid-4">' var html = '<div class="grid-4">'
+ statCard(__('运行状态','Status'), s.status || 'unknown', 'running') + statCard(__('运行状态','Status'), s.status || 'unknown', 'running')
+ statCard(__('运行时间','Uptime'), s.uptime || '-', 'uptime') + statCard(__('运行时间','Uptime'), '<span id="uptime-val">' + (state.startedAt ? fmtUptime(Date.now() - state.startedAt) : '-') + '</span>', 'uptime')
+ statCard(__('插件','Plugins'), (k?.plugins || []).length || 0, 'plugin') + statCard(__('插件','Plugins'), (k?.plugins || []).length || 0, 'plugin')
+ statCard(__('版本','Version'), s.version || '0.1.0', 'version') + statCard(__('版本','Version'), s.version || '0.1.0', 'version')
+ '</div>'; + '</div>';
@ -471,59 +511,15 @@ function renderOverview() {
} }
// ===== Chat ===== // ===== Chat =====
function renderChat() { var _chatLayoutBuilt = false;
function buildChatLayout() {
var cont = document.getElementById('tab-chat'); var cont = document.getElementById('tab-chat');
var msgs = state.messages; var k = state.kernel || {};
var k = state.kernel;
var html = '<div class="chat-layout"><div class="chat-main">'; var html = '<div class="chat-layout"><div class="chat-main">';
html += '<div class="card"><h2>' + __('对话','Chat') + '</h2><div class="chat-messages" id="chat-msgs">'; html += '<div class="card"><h2>' + __('对话','Chat') + ' <span id="chat-stage" class="badge" style="font-size:10px;font-weight:400;display:' + (state.chatLoading ? 'inline' : 'none') + '">' + escHtml(state.chatStage || '') + '</span></h2><div class="chat-messages" id="chat-msgs">';
if (msgs.length === 0) { if (state.messages.length === 0) {
html += '<div class="empty-state" style="flex:1;display:flex;align-items:center;justify-content:center"><p>' + __('开始对话以测试 Agent 回复','Start a conversation to test Agent replies') + '</p></div>'; html += '<div class="empty-state" style="flex:1;display:flex;align-items:center;justify-content:center"><p>' + __('开始对话以测试 Agent 回复','Start a conversation to test Agent replies') + '</p></div>';
} else {
msgs.forEach(function(m, i) {
var role = m.role || 'user';
var c = m.content || '';
// Format content based on role
if (role === 'assistant') {
if (typeof marked !== 'undefined') { c = marked.parse(c) } else { c = '<pre>' + escHtml(c) + '</pre>' }
} else if (role === 'system') {
c = escHtml(c);
} else {
c = escHtml(c);
}
// Reasoning section
var rc = '';
if (m.reasoning_content) {
var rcBody = (typeof marked !== 'undefined' ? marked.parse(m.reasoning_content) : escHtml(m.reasoning_content));
rc = '<div class="reasoning">'
+ '<div class="reasoning-title" onclick="var n=this.nextElementSibling;n.style.display=n.style.display===\'none\'?\'block\':\'none\';this.textContent=this.textContent===\'' + __('收起思考','Collapse') + '\'?\'' + __('展开思考','Expand') + '\':\'' + __('收起思考','Collapse') + '\'">' + __('收起思考','Collapse') + '</div>'
+ '<div class="reasoning-body" style="display:none">' + rcBody + '</div></div>';
}
// Tool calls section
var tcs = '';
if (m.tool_calls && m.tool_calls.length > 0) {
m.tool_calls.forEach(function(tc) {
var argsStr = typeof tc.args === 'object' ? JSON.stringify(tc.args, null, 1) : (tc.args || '');
var resultStr = tc.result ? (typeof tc.result === 'object' ? JSON.stringify(tc.result, null, 1).substring(0, 200) : String(tc.result).substring(0, 200)) : '';
var statusIcon = tc.status === 'denied' ? '⛔' : '🔧';
tcs += '<div class="tool-call">'
+ '<div><span class="tc-name">' + statusIcon + ' ' + escHtml(tc.tool || tc.name || '') + '</span></div>'
+ (argsStr && argsStr !== '{}' ? '<div class="tc-args">' + escHtml(argsStr) + '</div>' : '')
+ (resultStr ? '<div class="tc-result">→ ' + escHtml(resultStr) + '</div>' : '')
+ '</div>';
});
}
// Build bubble content
var body = rc + tcs + '<div class="text">' + c + '</div>';
if (role === 'system') {
html += '<div class="msg msg-system"><div class="msg-bubble">' + body + '</div></div>';
} else {
html += '<div class="msg msg-' + role + '">'
+ '<div class="msg-avatar">' + (role === 'user' ? 'U' : 'A') + '</div>'
+ '<div class="msg-content"><div class="msg-bubble">' + body + '</div></div>'
+ '</div>';
}
});
} }
html += '</div>' html += '</div>'
+ '<div class="chat-input-row">' + '<div class="chat-input-row">'
@ -532,7 +528,7 @@ function renderChat() {
+ '</div></div>'; + '</div></div>';
html += '</div><div class="chat-sidebar">' html += '</div><div class="chat-sidebar">'
+ '<div class="card" style="padding:12px"><h2 style="font-size:13px;margin-bottom:8px">' + __('星图','Star Map') + '</h2>' + '<div class="card" style="padding:12px"><h2 style="font-size:13px;margin-bottom:8px">' + __('星图','Star Map') + '</h2>'
+ '<div id="sm-container-chat" style="height:160px"><p style="color:var(--text-muted);padding:12px;text-align:center;font-size:11px">' + __('加载中..','Loading..') + '</p></div></div>' + '<div id="sm-container-chat" style="height:160px;display:flex;align-items:center;justify-content:center"><div class="loading-spinner"></div></div></div>'
+ '<div class="card" style="padding:12px"><h2 style="font-size:13px;margin-bottom:8px">' + __('终端','Terminal') + ' <span id="term-count-badge" class="badge badge-blue">0</span></h2>' + '<div class="card" style="padding:12px"><h2 style="font-size:13px;margin-bottom:8px">' + __('终端','Terminal') + ' <span id="term-count-badge" class="badge badge-blue">0</span></h2>'
+ '<div id="term-list" style="max-height:160px;overflow-y:auto;font-size:11px"></div></div>' + '<div id="term-list" style="max-height:160px;overflow-y:auto;font-size:11px"></div></div>'
+ '<div class="card" style="padding:12px"><h2 style="font-size:13px;margin-bottom:8px">' + __('命令历史','Command History') + ' <span id="cmd-count-badge" class="badge badge-blue">0</span></h2>' + '<div class="card" style="padding:12px"><h2 style="font-size:13px;margin-bottom:8px">' + __('命令历史','Command History') + ' <span id="cmd-count-badge" class="badge badge-blue">0</span></h2>'
@ -569,24 +565,86 @@ function renderChat() {
+ '<button class="btn btn-primary btn-sm" onclick="createKnowledgeChat()">' + __('创建','Create') + '</button>' + '<button class="btn btn-primary btn-sm" onclick="createKnowledgeChat()">' + __('创建','Create') + '</button>'
+ '</div></div></div></div></div>'; + '</div></div></div></div></div>';
cont.innerHTML = html; cont.innerHTML = html;
var el = document.getElementById('chat-msgs'); _chatLayoutBuilt = true;
if (el) el.scrollTop = el.scrollHeight;
renderChatStarmap();
renderTerminals();
renderCmdHistory();
} }
function rerenderChat() { renderChat(); renderChatStarmap() } function renderChat() {
if (!_chatLayoutBuilt) { buildChatLayout(); renderChatStarmap(); renderTerminals(); renderCmdHistory() }
var msgsEl = document.getElementById('chat-msgs');
if (!msgsEl) return;
var msgs = state.messages;
var html = '';
if (msgs.length === 0) {
html = '<div class="empty-state" style="flex:1;display:flex;align-items:center;justify-content:center"><p>' + __('开始对话以测试 Agent 回复','Start a conversation to test Agent replies') + '</p></div>';
} else {
msgs.forEach(function(m, i) {
var role = m.role || 'user';
var c = m.content || '';
if (role === 'assistant') {
if (typeof marked !== 'undefined') { c = marked.parse(c) } else { c = '<pre>' + escHtml(c) + '</pre>' }
} else if (role === 'system') {
c = escHtml(c);
} else {
c = escHtml(c);
}
var rc = '';
if (m.reasoning_content) {
var rcBody = (typeof marked !== 'undefined' ? marked.parse(m.reasoning_content) : escHtml(m.reasoning_content));
rc = '<div class="reasoning">'
+ '<div class="reasoning-title" onclick="var n=this.nextElementSibling;n.style.display=n.style.display===\'none\'?\'block\':\'none\';this.textContent=this.textContent===\'' + __('收起思考','Collapse') + '\'?\'' + __('展开思考','Expand') + '\':\'' + __('收起思考','Collapse') + '\'">' + __('收起思考','Collapse') + '</div>'
+ '<div class="reasoning-body" style="display:none">' + rcBody + '</div></div>';
}
var tcs = '';
if (m.tool_calls && m.tool_calls.length > 0) {
m.tool_calls.forEach(function(tc) {
var argsStr = typeof tc.args === 'object' ? JSON.stringify(tc.args, null, 1) : (tc.args || '');
var resultStr = tc.result ? (typeof tc.result === 'object' ? JSON.stringify(tc.result, null, 1).substring(0, 200) : String(tc.result).substring(0, 200)) : '';
var statusIcon = tc.status === 'denied' ? '⛔' : '🔧';
tcs += '<div class="tool-call">'
+ '<div><span class="tc-name">' + statusIcon + ' ' + escHtml(tc.tool || tc.name || '') + '</span></div>'
+ (argsStr && argsStr !== '{}' ? '<div class="tc-args">' + escHtml(argsStr) + '</div>' : '')
+ (resultStr ? '<div class="tc-result">→ ' + escHtml(resultStr) + '</div>' : '')
+ '</div>';
});
}
var body = rc + tcs + '<div class="text">' + c + '</div>';
if (role === 'system') {
html += '<div class="msg msg-system"><div class="msg-bubble">' + body + '</div></div>';
} else {
html += '<div class="msg msg-' + role + '">'
+ '<div class="msg-avatar">' + (role === 'user' ? 'U' : 'A') + '</div>'
+ '<div class="msg-content"><div class="msg-bubble">' + body + '</div></div>'
+ '</div>';
}
});
}
msgsEl.innerHTML = html;
msgsEl.scrollTop = msgsEl.scrollHeight;
updateChatBadge();
}
function updateChatBadge() {
var badge = document.getElementById('chat-stage');
if (!badge) return;
badge.textContent = state.chatStage || '';
badge.style.display = state.chatLoading ? 'inline' : 'none';
}
function rerenderChat() { renderChat(); renderChatStarmap(); renderTerminals(); renderCmdHistory() }
function renderChatStarmap() { function renderChatStarmap() {
var cont = document.getElementById('sm-container-chat'); var cont = document.getElementById('sm-container-chat');
if (!cont) return; if (!cont) return;
if (window._THREE_FAILED || (!window.THREE && window._THREE_FAILED !== undefined)) {
cont.innerHTML = '<p style="color:var(--text-muted);padding:20px;text-align:center;font-size:11px">' + __('3D 星图不可用CDN 加载失败)','Star map unavailable (CDN load failed)') + '</p>';
state.starmapInit = true;
state.starmapLoading = false;
return;
}
if (!window.THREE) { if (!window.THREE) {
if (!state.starmapLoading) { cont.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100%;padding:20px"><div class="loading-spinner"></div></div>';
cont.innerHTML = '<p style="color:var(--text-muted);padding:20px;text-align:center;font-size:12px">' state.starmapInit = false;
+ __('Three.js 未加载','Three.js not loaded') + '</p>'; state.starmapLoading = false;
state.starmapInit = true;
}
return; return;
} }
if (cont.querySelector('canvas')) { if (cont.querySelector('canvas')) {
@ -600,6 +658,10 @@ function renderChatStarmap() {
if (rect.width > 0) starmapRen.setSize(rect.width, Math.max(rect.height, 250)); if (rect.width > 0) starmapRen.setSize(rect.width, Math.max(rect.height, 250));
cont.appendChild(starmapRen.domElement); cont.appendChild(starmapRen.domElement);
starmapRen.domElement.style.display = 'block'; starmapRen.domElement.style.display = 'block';
} else {
// starmapRen was destroyed (e.g. re-render cycle), restart
state.starmapInit = false;
state.starmapLoading = false;
} }
return; return;
} }
@ -635,8 +697,7 @@ async function loadChatStarmapData() {
} }
function getStarmapBg() { function getStarmapBg() {
var isLight = document.documentElement.getAttribute('data-theme') === 'light'; return 0x0a0a1a;
return isLight ? 0xf0f4f8 : 0x0a0a1a;
} }
function initChatStarmap() { function initChatStarmap() {
@ -651,15 +712,14 @@ function initChatStarmap() {
starmapRen.domElement.style.display = 'block'; starmapRen.domElement.style.display = 'block';
return; return;
} }
var bg = getStarmapBg();
starmapScene = new THREE.Scene(); starmapScene = new THREE.Scene();
starmapScene.fog = new THREE.FogExp2(bg, 0.02); starmapScene.fog = new THREE.FogExp2(0x0a0a1a, 0.015);
starmapCam = new THREE.PerspectiveCamera(60, w / h, 0.1, 2000); starmapCam = new THREE.PerspectiveCamera(60, w / h, 0.1, 2000);
starmapCam.position.set(0, 20, 40); starmapCam.position.set(0, 20, 40);
starmapRen = new THREE.WebGLRenderer({ antialias: true, alpha: true }); starmapRen = new THREE.WebGLRenderer({ antialias: true, alpha: true });
starmapRen.setSize(w, h); starmapRen.setSize(w, h);
starmapRen.setPixelRatio(Math.min(window.devicePixelRatio, 2)); starmapRen.setPixelRatio(Math.min(window.devicePixelRatio, 2));
starmapRen.setClearColor(bg, 1); starmapRen.setClearColor(0x0a0a1a, 1);
cont.innerHTML = ''; cont.innerHTML = '';
cont.appendChild(starmapRen.domElement); cont.appendChild(starmapRen.domElement);
starmapCtrl = new THREE.OrbitControls(starmapCam, starmapRen.domElement); starmapCtrl = new THREE.OrbitControls(starmapCam, starmapRen.domElement);
@ -673,6 +733,7 @@ function initChatStarmap() {
dl.position.set(50, 100, 50); dl.position.set(50, 100, 50);
starmapScene.add(dl); starmapScene.add(dl);
createStarField(); createStarField();
createNebula();
buildChatStarmapGraph(); buildChatStarmapGraph();
starmapRen.domElement.addEventListener('mousemove', onStarmapMove); starmapRen.domElement.addEventListener('mousemove', onStarmapMove);
starmapRen.domElement.addEventListener('click', onStarmapClick); starmapRen.domElement.addEventListener('click', onStarmapClick);
@ -687,111 +748,154 @@ function buildChatStarmapGraph() {
starmapNodeMeshes = []; starmapNodeMeshes = [];
starmapEdgeLines = []; starmapEdgeLines = [];
if (starmapNodes.length === 0) return; if (starmapNodes.length === 0) return;
var nMap = {}; // Calculate node degrees for leaf node detection
starmapNodes.forEach(function(n) { nMap[n.id] = n }); var nodeDegs = {};
var sorted = [...starmapNodes].sort(function(a, b) { starmapNodes.forEach(function(n) { nodeDegs[n.id] = 0 });
starmapEdges.forEach(function(e) {
nodeDegs[e.source_id] = (nodeDegs[e.source_id] || 0) + 1;
nodeDegs[e.target_id] = (nodeDegs[e.target_id] || 0) + 1;
});
var nodeMap = {};
starmapNodes.forEach(function(n) { nodeMap[n.id] = n });
var sorted = starmapNodes.slice().sort(function(a, b) {
return (b.mention_count || 0) - (a.mention_count || 0); return (b.mention_count || 0) - (a.mention_count || 0);
}); });
var mc = sorted.map(function(n) { return n.mention_count || 0 }); var mc = sorted.map(function(n) { return n.mention_count || 0 });
var maxMc = Math.max(...mc, 1), minMc = Math.min(...mc, 0), rng = maxMc - minMc || 1; var maxMc = Math.max(...mc, 1), minMc = Math.min(...mc, 0), rng = maxMc - minMc || 1;
// Layout positions
var pos = {}; var pos = {};
var baseR = 10, maxR = 50; var baseR = 15, maxR = 80;
var total = sorted.length;
var acc = 0; var acc = 0;
sorted.forEach(function(n, i) { sorted.forEach(function(n, i) {
var m = n.mention_count || 0, mn = rng > 0 ? (m - minMc) / rng : 0; var m = n.mention_count || 0, mn = rng > 0 ? (m - minMc) / rng : 0;
var rad = baseR + mn * (maxR - baseR); var radius = baseR + mn * (maxR - baseR);
var baseStep = (Math.PI * 2) / sorted.length; var baseStep = (Math.PI * 2) / total;
var step = baseStep + mn * baseStep * 2; var extra = mn * baseStep * 2;
var angle = acc + step / 2; var angle = acc + extra / 2;
acc += step; acc += baseStep + extra;
pos[n.id] = { pos[n.id] = {
x: rad * Math.cos(angle), x: radius * Math.cos(angle),
y: (Math.random() - 0.5) * (5 + mn * 15), y: (Math.random() - 0.5) * (10 + mn * 20),
z: rad * Math.sin(angle), z: radius * Math.sin(angle),
mn: n, mn: mn,
rad: rad rad: radius
}; };
}); });
// Leaf nodes (degree 1) reposition near parent
sorted.forEach(function(n) {
var deg = nodeDegs[n.id] || 0;
if (deg !== 1) return;
var edge = starmapEdges.find(function(e) { return e.source_id === n.id || e.target_id === n.id });
if (!edge) return;
var parentId = edge.source_id === n.id ? edge.target_id : edge.source_id;
if (!pos[parentId]) return;
var pp = pos[parentId];
var m = n.mention_count || 0, mn = rng > 0 ? (m - minMc) / rng : 0;
var off = 6 + mn * 8 + Math.random() * 4;
var a2 = Math.random() * Math.PI * 2;
pos[n.id] = {
x: pp.x + off * Math.cos(a2),
y: pp.y + (Math.random() - 0.5) * (4 + mn * 6),
z: pp.z + off * Math.sin(a2),
mn: mn,
rad: off
};
});
// Force-directed simulation
for (var it = 0; it < 50; it++) { for (var it = 0; it < 50; it++) {
Object.keys(pos).forEach(function(i) { var ids = Object.keys(pos);
Object.keys(pos).forEach(function(j) { // Repulsion
if (i >= j) return; for (var i = 0; i < ids.length; i++) {
var a = pos[i], b = pos[j]; for (var j = i + 1; j < ids.length; j++) {
var a = pos[ids[i]], b = pos[ids[j]];
var dx = a.x - b.x, dy = a.y - b.y, dz = a.z - b.z, d = Math.sqrt(dx*dx + dy*dy + dz*dz) + 0.1; var dx = a.x - b.x, dy = a.y - b.y, dz = a.z - b.z, d = Math.sqrt(dx*dx + dy*dy + dz*dz) + 0.1;
var f = (0.5 + (a.mn + b.mn) * 0.5); var rf = 0.5 + (a.mn + b.mn) * 0.5;
if (d < 20) { if (d < 25) {
var force = (0.08 * f) / Math.max(d, 0.5); var force = (0.06 * rf) / Math.max(d, 0.5);
a.x += dx / d * force; a.y += dy / d * force; a.z += dz / d * force; a.x += dx / d * force; a.y += dy / d * force; a.z += dz / d * force;
b.x -= dx / d * force; b.y -= dy / d * force; b.z -= dz / d * force; b.x -= dx / d * force; b.y -= dy / d * force; b.z -= dz / d * force;
} }
}); }
}); }
// Attraction along edges
starmapEdges.forEach(function(e) { starmapEdges.forEach(function(e) {
var a = pos[e.source_id], b = pos[e.target_id]; var a = pos[e.source_id], b = pos[e.target_id];
if (!a || !b) return; if (!a || !b) return;
var dx = b.x - a.x, dy = b.y - a.y, dz = b.z - a.z, d = Math.sqrt(dx*dx + dy*dy + dz*dz) + 0.1; var dx = b.x - a.x, dy = b.y - a.y, dz = b.z - a.z, d = Math.sqrt(dx*dx + dy*dy + dz*dz) + 0.1;
if (d > 15) { var af = Math.max(0.3, 1.0 - (a.mn + b.mn) * 0.3);
var f = 0.05 * Math.max(0.3, 1 - (a.mn + b.mn) * 0.3); if (d > 20) {
a.x += dx / d * f; a.y += dy / d * f; a.z += dz / d * f; var force = 0.04 * af;
b.x -= dx / d * f; b.y -= dy / d * f; b.z -= dz / d * f; a.x += dx / d * force; a.y += dy / d * force; a.z += dz / d * force;
b.x -= dx / d * force; b.y -= dy / d * force; b.z -= dz / d * force;
} }
}); });
Object.keys(pos).forEach(function(i) { // Centering constraint
var p = pos[i]; ids.forEach(function(id) {
var d = Math.sqrt(p.x * p.x + p.y * p.y + p.z * p.z); var p = pos[id];
var dist = Math.sqrt(p.x * p.x + p.y * p.y + p.z * p.z);
var maxA = maxR * 1.5; var maxA = maxR * 1.5;
if (d > maxA) { if (dist > maxA) { var s = maxA / dist; p.x *= s; p.y *= s; p.z *= s }
var s = maxA / d;
p.x *= s; p.y *= s; p.z *= s;
}
}); });
} }
// Create nodes
starmapNodes.forEach(function(n) { starmapNodes.forEach(function(n) {
var p = pos[n.id]; var p = pos[n.id];
if (!p) return; if (!p) return;
var mn = n.mention_count || 0, mnr = rng > 0 ? (mn - minMc) / rng : 0; var mn = n.mention_count || 0, mnr = rng > 0 ? (mn - minMc) / rng : 0;
var rad = 0.4 + mnr * 1.5; var rad = 0.5 + mnr * 2.0;
var col = smTypeColors[n.type] || 0xcccccc; var col = smTypeColors[n.type] || 0xcccccc;
var ei = 0.3 + mnr * 0.7; var ei = 0.3 + mnr * 0.7;
var g = new THREE.SphereGeometry(rad, 16, 12); var g = new THREE.SphereGeometry(rad, 16, 12);
var mat = new THREE.MeshPhongMaterial({ color: col, emissive: col, emissiveIntensity: ei }); var mat = new THREE.MeshPhongMaterial({ color: col, emissive: col, emissiveIntensity: ei, shininess: 30 });
var mesh = new THREE.Mesh(g, mat); var mesh = new THREE.Mesh(g, mat);
mesh.position.set(p.x, p.y, p.z); mesh.position.set(p.x, p.y, p.z);
mesh.userData.nodeData = n; mesh.userData.nodeData = n;
mesh.userData.nodeId = n.id; mesh.userData.nodeId = n.id;
starmapScene.add(mesh); mesh.userData.baseEmissive = ei;
starmapNodeMeshes.push(mesh); // Glow sphere
// label sprite var gr = rad * 1.2 + mnr * 0.5;
var gg = new THREE.SphereGeometry(gr, 16, 12);
var gm = new THREE.MeshBasicMaterial({ color: col, transparent: true, opacity: 0.12 + mnr * 0.08, side: THREE.BackSide, blending: THREE.AdditiveBlending });
var gs = new THREE.Mesh(gg, gm);
mesh.add(gs);
mesh.userData.glowSphere = gs;
// Label sprite
var canvas = document.createElement('canvas'); var canvas = document.createElement('canvas');
canvas.width = 256; canvas.width = 256;
canvas.height = 64; canvas.height = 64;
var ctx = canvas.getContext('2d'); var ctx = canvas.getContext('2d');
ctx.fillStyle = 'rgba(0,0,0,0)'; ctx.clearRect(0, 0, 256, 64);
ctx.fillRect(0, 0, 256, 64); ctx.font = 'Bold 24px Courier New';
ctx.font = 'Bold 28px Arial';
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.textBaseline = 'middle'; ctx.textBaseline = 'middle';
ctx.fillStyle = 'rgba(255,255,255,0.85)'; ctx.shadowColor = '#aaccff';
ctx.fillText(n.name || n.id, 128, 34); ctx.shadowBlur = 8;
ctx.fillStyle = '#ffffff';
ctx.fillText((n.name || n.id).substring(0, 12), 128, 32);
var tex = new THREE.CanvasTexture(canvas); var tex = new THREE.CanvasTexture(canvas);
var spMat = new THREE.SpriteMaterial({ map: tex, transparent: true, depthTest: false }); tex.needsUpdate = true;
var spMat = new THREE.SpriteMaterial({ map: tex, transparent: true, opacity: 0.9, depthTest: false, depthWrite: false, blending: THREE.AdditiveBlending });
var sprite = new THREE.Sprite(spMat); var sprite = new THREE.Sprite(spMat);
sprite.position.set(p.x, p.y + rad + 1.5, p.z); sprite.scale.set(8, 2, 1);
sprite.scale.set(6, 1.5, 1); sprite.position.y = rad + 2;
starmapScene.add(sprite); mesh.add(sprite);
starmapNodeMeshes.push(sprite); starmapScene.add(mesh);
starmapNodeMeshes.push(mesh);
}); });
// Create edges
starmapEdges.forEach(function(e) { starmapEdges.forEach(function(e) {
var a = pos[e.source_id], b = pos[e.target_id]; var a = pos[e.source_id], b = pos[e.target_id];
if (!a || !b) return; if (!a || !b) return;
var col = smEdgeColors[e.relation_type] || smEdgeColors[e.type] || 0x444466;
var pts = [ var pts = [
new THREE.Vector3(a.x, a.y, a.z), new THREE.Vector3(a.x, a.y, a.z),
new THREE.Vector3(b.x, b.y, b.z) new THREE.Vector3(b.x, b.y, b.z)
]; ];
var col = smEdgeColors[e.type] || 0x888888;
var geo = new THREE.BufferGeometry().setFromPoints(pts); var geo = new THREE.BufferGeometry().setFromPoints(pts);
var mat = new THREE.LineBasicMaterial({ color: col, transparent: true, opacity: 0.35 }); var mat = new THREE.LineBasicMaterial({ color: col, transparent: true, opacity: 0.4 });
var line = new THREE.Line(geo, mat); var line = new THREE.Line(geo, mat);
line.userData = { edgeId: e.id, edgeData: e };
starmapScene.add(line); starmapScene.add(line);
starmapEdgeLines.push(line); starmapEdgeLines.push(line);
}); });
@ -806,12 +910,17 @@ async function sendChat() {
inp.value = ''; inp.value = '';
rerenderChat(); rerenderChat();
state.chatLoading = true; state.chatLoading = true;
state.chatStage = __('等待AI回复...','Waiting for AI...');
btn.disabled = true; btn.disabled = true;
btn.textContent = '...'; btn.textContent = '';
rerenderChat();
try { try {
var r = await api('/chat', { method: 'POST', body: JSON.stringify({ message: text }) }); var r = await api('/chat', { method: 'POST', body: JSON.stringify({ message: text }) });
state.chatStage = '';
var last = state.messages[state.messages.length - 1]; var last = state.messages[state.messages.length - 1];
console.log('[sendChat] POST returned, last msg:', last ? {role:last.role, _streaming:last._streaming, _final:last._final, tool_calls:last.tool_calls?.length, content_len:last.content?.length} : null);
if (last && last.role === 'assistant' && last._streaming) { if (last && last.role === 'assistant' && last._streaming) {
console.log('[sendChat] updating existing streaming msg, tool_calls before:', last.tool_calls?.length);
last.content = r.response || __('(无响应)','(no response)'); last.content = r.response || __('(无响应)','(no response)');
last.reasoning_content = r.reasoning_content || ''; last.reasoning_content = r.reasoning_content || '';
last._final = true; last._final = true;
@ -832,8 +941,10 @@ async function sendChat() {
toast(__('请求失败: ','Request failed: ') + e.message, true); toast(__('请求失败: ','Request failed: ') + e.message, true);
} finally { } finally {
state.chatLoading = false; state.chatLoading = false;
state.chatStage = '';
btn.disabled = false; btn.disabled = false;
btn.textContent = '发送'; btn.textContent = __('发送','Send');
rerenderChat();
} }
} }
@ -996,23 +1107,26 @@ function renderCmdHistory() {
} }
function connectSSE() { function connectSSE() {
if (state.eventSource) state.eventSource.close(); if (state.eventSource) { console.log('[SSE] closing old connection'); state.eventSource.close() }
var es; var es;
try { es = new EventSource('/api/v1/chat/events'); state.eventSource = es } catch(ex) {} try { es = new EventSource('/api/v1/chat/events', { withCredentials: true }); state.eventSource = es } catch(ex) { console.error('[SSE] create failed', ex); return }
if (!es) return; if (!es) { console.error('[SSE] es is null'); return }
console.log('[SSE] connected');
es.addEventListener('agent_output', function(e) { es.addEventListener('agent_output', function(e) {
try { try {
var ev = JSON.parse(e.data); var ev = JSON.parse(e.data);
var p = ev.payload || {}; var p = ev.payload || {};
console.log('[SSE] agent_output received', p.content ? p.content.substring(0,50) : '(empty)');
if (!p.content) return; if (!p.content) return;
state.chatStage = __('AI 回复中...','AI replying...');
if (state.messages.length > 0 && state.messages[state.messages.length - 1].role === 'assistant' && !state.messages[state.messages.length - 1]._final) { if (state.messages.length > 0 && state.messages[state.messages.length - 1].role === 'assistant' && !state.messages[state.messages.length - 1]._final) {
state.messages[state.messages.length - 1].content += p.content; state.messages[state.messages.length - 1].content += p.content;
renderChat(); renderChatStarmap(); rerenderChat();
return; return;
} }
state.messages.push({ role: 'assistant', content: p.content, _streaming: true }); state.messages.push({ role: 'assistant', content: p.content, _streaming: true });
renderChat(); renderChatStarmap(); rerenderChat();
} catch(ex) {} } catch(ex) { console.error('[SSE] agent_output error', ex) }
}); });
es.addEventListener('reasoning', function(e) { es.addEventListener('reasoning', function(e) {
try { try {
@ -1021,8 +1135,9 @@ function connectSSE() {
if (p.content && state.messages.length > 0) { if (p.content && state.messages.length > 0) {
var last = state.messages[state.messages.length - 1]; var last = state.messages[state.messages.length - 1];
if (last.role === 'assistant') { if (last.role === 'assistant') {
state.chatStage = __('AI 思考中...','AI thinking...');
last.reasoning_content = (last.reasoning_content || '') + p.content; last.reasoning_content = (last.reasoning_content || '') + p.content;
renderChat(); renderChatStarmap(); rerenderChat();
} }
} }
} catch(ex) {} } catch(ex) {}
@ -1031,23 +1146,62 @@ function connectSSE() {
try { try {
var ev = JSON.parse(e.data); var ev = JSON.parse(e.data);
var p = ev.payload || {}; var p = ev.payload || {};
console.log('[SSE] tool_call', p);
if (!p.tool) return; if (!p.tool) return;
var last = state.messages.length > 0 ? state.messages[state.messages.length - 1] : null; var last = state.messages.length > 0 ? state.messages[state.messages.length - 1] : null;
if (last && last.role === 'assistant') { if (!last || last.role !== 'assistant') {
if (!last.tool_calls) last.tool_calls = []; state.messages.push({ role: 'assistant', content: '', tool_calls: [], _streaming: true });
last.tool_calls.push({ last = state.messages[state.messages.length - 1];
tool: p.tool,
name: p.tool,
args: p.args || {},
result: p.result || '',
status: p.status || 'ok',
plugin: p.plugin || ''
});
renderChat(); renderChatStarmap();
} }
if (!last.tool_calls) last.tool_calls = [];
last.tool_calls.push({
tool: p.tool,
name: p.tool,
args: p.args || {},
result: p.result || '',
status: p.status || 'ok',
plugin: p.plugin || ''
});
state.chatStage = __('工具调用: ','Tool: ') + (p.tool || '');
rerenderChat();
} catch(ex) { console.error('[SSE] tool_call error', ex) }
});
es.addEventListener('tool_result', function(e) {
try {
var ev = JSON.parse(e.data);
var p = ev.payload || {};
state.chatStage = __('工具结果已返回','Tool result received');
var badge = document.getElementById('chat-stage');
if (badge) badge.textContent = state.chatStage;
} catch(ex) {} } catch(ex) {}
}); });
es.onerror = function() { setTimeout(connectSSE, 5000) }; es.addEventListener('stage', function(e) {
try {
var ev = JSON.parse(e.data);
var p = ev.payload || {};
var phase = p.phase || '';
var tool = p.tool || '';
console.log('[SSE] stage event', phase, tool);
if (phase === 'pre_action') {
state.chatStage = __('AI 思考中...','AI thinking...');
} else if (phase === 'before_toolcall') {
state.chatStage = __('工具调用: ','Tool: ') + (tool || '');
} else if (phase === 'before_output') {
state.chatStage = __('生成回复中...','Generating response...');
}
var badge = document.getElementById('chat-stage');
if (badge) { badge.textContent = state.chatStage || ''; badge.style.display = state.chatLoading ? 'inline' : 'none' }
} catch(ex) { console.error('[SSE] stage error', ex) }
});
es.onopen = function() { console.log('[SSE] connection opened') };
es.onerror = function(e) { console.error('[SSE] error', e); setTimeout(connectSSE, 5000) };
// Periodically refresh sidebar data
if (state._sidebarRefresh) clearInterval(state._sidebarRefresh);
state._sidebarRefresh = setInterval(async function() {
try { var td = await api('/terminals'); if (td && td.terminals) state.terminals = td.terminals } catch(e) {}
try { var ch = await api('/cmd/history'); if (ch && ch.history) state.cmdHistory = ch.history } catch(e) {}
renderTerminals(); renderCmdHistory();
}, 5000);
} }
// ===== Plugins ===== // ===== Plugins =====
@ -1329,10 +1483,11 @@ function flyStarmapTo(nodeId, dur) {
} }
function onStarmapResize() { function onStarmapResize() {
var cont = document.getElementById('starmap-container'); if (!starmapRen || !starmapCam) return;
if (!cont || !starmapRen || !starmapCam) return; var cont = starmapRen.domElement.parentElement;
if (!cont) return;
var rect = cont.getBoundingClientRect(); var rect = cont.getBoundingClientRect();
var w = rect.width || 800, h = Math.max(rect.height || 500, 100); var w = rect.width || 800, h = Math.max(rect.height || 250, 100);
if (w > 0 && h > 0) { starmapCam.aspect = w / h; starmapCam.updateProjectionMatrix(); starmapRen.setSize(w, h) } if (w > 0 && h > 0) { starmapCam.aspect = w / h; starmapCam.updateProjectionMatrix(); starmapRen.setSize(w, h) }
} }
@ -1343,8 +1498,10 @@ function toggleStarmapAuto() {
} }
function resetStarmapCamera() { function resetStarmapCamera() {
if (!starmapCam || !starmapCtrl || !starmapNodeMeshes) return;
var maxD = 0; var maxD = 0;
starmapNodeMeshes.forEach(function(m) { var d = m.position.length(); if (d > maxD) maxD = d }); starmapNodeMeshes.forEach(function(m) { var d = m.position.length(); if (d > maxD) maxD = d });
if (maxD < 1) maxD = 30;
var td = Math.min(Math.max(maxD + 20, 30), 150); var td = Math.min(Math.max(maxD + 20, 30), 150);
var sp = starmapCam.position.clone(), ep = new THREE.Vector3(td * 0.9, td * 0.6, td * 0.9); var sp = starmapCam.position.clone(), ep = new THREE.Vector3(td * 0.9, td * 0.6, td * 0.9);
var st = starmapCtrl.target.clone(), t0 = Date.now(); var st = starmapCtrl.target.clone(), t0 = Date.now();
@ -1363,6 +1520,31 @@ function starmapAnimate() {
if (starmapRen && starmapScene && starmapCam) starmapRen.render(starmapScene, starmapCam); if (starmapRen && starmapScene && starmapCam) starmapRen.render(starmapScene, starmapCam);
} }
function createNebula() {
var nc = 500;
var p = new Float32Array(nc * 3), cl = new Float32Array(nc * 3);
for (var i = 0; i < nc; i++) {
var i3 = i * 3;
p[i3] = (Math.random() - 0.5) * 800;
p[i3+1] = (Math.random() - 0.5) * 800;
p[i3+2] = (Math.random() - 0.5) * 800;
var ch = Math.random();
if (ch < 0.33) {
cl[i3]=0.5+Math.random()*0.3; cl[i3+1]=0.2+Math.random()*0.2; cl[i3+2]=0.7+Math.random()*0.3;
} else if (ch < 0.66) {
cl[i3]=0.2+Math.random()*0.2; cl[i3+1]=0.3+Math.random()*0.3; cl[i3+2]=0.8+Math.random()*0.2;
} else {
cl[i3]=0.7+Math.random()*0.3; cl[i3+1]=0.2+Math.random()*0.2; cl[i3+2]=0.5+Math.random()*0.3;
}
}
var g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.BufferAttribute(p, 3));
g.setAttribute('color', new THREE.BufferAttribute(cl, 3));
var m = new THREE.PointsMaterial({ size: 8, vertexColors: true, transparent: true, opacity: 0.15, sizeAttenuation: true, blending: THREE.AdditiveBlending });
var np = new THREE.Points(g, m);
starmapScene.add(np);
}
// ===== Settings ===== // ===== Settings =====
function pluginDisplayName(p) { function pluginDisplayName(p) {
if (p === 'core') return __('核心', 'Core'); if (p === 'core') return __('核心', 'Core');
@ -1664,7 +1846,7 @@ async function logout() {
renderConfigDisabled(); renderConfigDisabled();
connectSSE(); connectSSE();
(async function() { await loadChatHistory(); renderAll() })(); (async function() { await loadChatHistory(); renderAll(); startUptimeTicker() })();
setInterval(renderAll, 15000); setInterval(renderAll, 15000);
</script> </script>
</body> </body>

View File

@ -7,6 +7,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"log"
"net/http" "net/http"
"os" "os"
"sort" "sort"
@ -131,12 +132,35 @@ func NewHandler(sup *supervisor.Daemon, mem *memory.GraphDB, sk *skill.Manager,
sessions: make(map[string]time.Time), sessions: make(map[string]time.Time),
termStates: make(map[string]*termState), termStates: make(map[string]*termState),
} }
h.loadChatHistory()
if evBus != nil { if evBus != nil {
go h.trackToolEvents() go h.trackToolEvents()
} }
return h return h
} }
func (h *Handler) loadChatHistory() {
if h.cfgReg == nil {
return
}
ps := h.cfgReg.PluginConfig("webui")
v, err := ps.Get("chathistory")
if err != nil || v == nil {
return
}
s, ok := v.(string)
if !ok || s == "" {
return
}
var msgs []ChatMsg
if err := json.Unmarshal([]byte(s), &msgs); err != nil {
return
}
h.chatMu.Lock()
h.chatHistory = msgs
h.chatMu.Unlock()
}
func (h *Handler) trackToolEvents() { func (h *Handler) trackToolEvents() {
h.eventBus.Subscribe(events.EventToolCall, func(ev *events.Event) { h.eventBus.Subscribe(events.EventToolCall, func(ev *events.Event) {
h.handleToolEvent(ev) h.handleToolEvent(ev)
@ -408,7 +432,7 @@ func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) {
agents := h.supervisor.ListAgents() agents := h.supervisor.ListAgents()
writeJSON(w, http.StatusOK, map[string]interface{}{ writeJSON(w, http.StatusOK, map[string]interface{}{
"status": "running", "status": "running",
"uptime": time.Since(h.startTime).String(), "uptime": time.Since(h.startTime).Round(time.Second).String(),
"agents": len(agents), "agents": len(agents),
"version": meta.Version, "version": meta.Version,
"startedAt": h.startTime, "startedAt": h.startTime,
@ -843,11 +867,17 @@ func (h *Handler) handleNetwork(w http.ResponseWriter, r *http.Request) {
func (h *Handler) addChatMsg(msg ChatMsg) { func (h *Handler) addChatMsg(msg ChatMsg) {
h.chatMu.Lock() h.chatMu.Lock()
defer h.chatMu.Unlock()
h.chatHistory = append(h.chatHistory, msg) h.chatHistory = append(h.chatHistory, msg)
if len(h.chatHistory) > maxChatHistory { if len(h.chatHistory) > maxChatHistory {
h.chatHistory = h.chatHistory[len(h.chatHistory)-maxChatHistory:] h.chatHistory = h.chatHistory[len(h.chatHistory)-maxChatHistory:]
} }
// persist to webui config table as compact JSON
if h.cfgReg != nil {
ps := h.cfgReg.PluginConfig("webui")
b, _ := json.Marshal(h.chatHistory)
ps.Set("chathistory", string(b))
}
h.chatMu.Unlock()
} }
func (h *Handler) handleChatHistory(w http.ResponseWriter, r *http.Request) { func (h *Handler) handleChatHistory(w http.ResponseWriter, r *http.Request) {
@ -944,23 +974,36 @@ func (h *Handler) handleChatEvents(w http.ResponseWriter, r *http.Request) {
defer close(writeCh) defer close(writeCh)
go func() { go func() {
defer func() {
if r := recover(); r != nil {
log.Printf("[SSE] writer panic: %v", r)
}
}()
for line := range writeCh { for line := range writeCh {
fmt.Fprintf(w, "%s\n", line) fmt.Fprintf(w, "%s\n", line)
flusher.Flush() flusher.Flush()
} }
}() }()
subTypes := []string{"agent_output", "reasoning", "agent_error", "tool_call"} subTypes := []string{"agent_output", "reasoning", "agent_error", "tool_call", "stage", "agent_llm_chain"}
var unsubs []func()
for _, t := range subTypes { for _, t := range subTypes {
t2 := t t2 := t
_ = h.eventBus.Subscribe(events.EventType(t2), func(evt *events.Event) { unsub := h.eventBus.Subscribe(events.EventType(t2), func(evt *events.Event) {
data, _ := json.Marshal(evt) data, _ := json.Marshal(evt)
select { select {
case writeCh <- fmt.Sprintf("event: %s\ndata: %s", evt.Type, string(data)): case writeCh <- fmt.Sprintf("event: %s\ndata: %s\n", evt.Type, string(data)):
default: default:
log.Printf("[SSE] DROPPED event %s (writeCh full, len=%d)", evt.Type, len(writeCh))
} }
}) })
unsubs = append(unsubs, unsub)
} }
defer func() {
for _, unsub := range unsubs {
unsub()
}
}()
for { for {
select { select {
case <-done: case <-done:

View File

@ -155,11 +155,30 @@ func (p *Plugin) ensureAuthBootstrap(s *sdk.PluginSDK) {
func (p *Plugin) Name() string { return p.name } func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error { func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.SetAutoRestart(true)
s.Settings().RegisterDef(sdk.ConfigDef{Key: "api_key", Default: "", Type: "password", DisplayName: "API 密钥", Description: "访问 API 时需要的密钥", Category: "webui"}) s.Settings().RegisterDef(sdk.ConfigDef{Key: "api_key", Default: "", Type: "password", DisplayName: "API 密钥", Description: "访问 API 时需要的密钥", Category: "webui"})
s.Settings().RegisterDef(sdk.ConfigDef{Key: "username", Default: "admin", Type: "string", DisplayName: "登录用户名", Description: "Web 控制台登录用户名", Category: "webui"}) s.Settings().RegisterDef(sdk.ConfigDef{Key: "username", Default: "admin", Type: "string", DisplayName: "登录用户名", Description: "Web 控制台登录用户名", Category: "webui"})
s.Settings().RegisterDef(sdk.ConfigDef{Key: "password", Default: "", Type: "password", DisplayName: "Web 控制台登录密码", Description: "Web 控制台登录密码", Category: "webui"}) s.Settings().RegisterDef(sdk.ConfigDef{Key: "password", Default: "", Type: "password", DisplayName: "Web 控制台登录密码", Description: "Web 控制台登录密码", Category: "webui"})
s.Settings().RegisterDef(sdk.ConfigDef{Key: "session_ttl_hours", Default: "24", Type: "int", DisplayName: "会话时长(小时)", Description: "登录 cookie 有效时长", Category: "webui"}) s.Settings().RegisterDef(sdk.ConfigDef{Key: "session_ttl_hours", Default: "24", Type: "int", DisplayName: "会话时长(小时)", Description: "登录 cookie 有效时长", Category: "webui"})
p.ensureAuthBootstrap(s) p.ensureAuthBootstrap(s)
s.RegisterStage(sdk.StagePreAction, func(ctx *sdk.StageContext) error {
p.evBus.Publish(&events.Event{Type: events.EventStage, Payload: map[string]interface{}{"phase": "pre_action", "message": "thinking"}})
return nil
})
s.RegisterStage(sdk.StageBeforeToolcall, func(ctx *sdk.StageContext) error {
tool := ""
if len(ctx.ToolCalls) > 0 {
tool = ctx.ToolCalls[0].Name
}
p.evBus.Publish(&events.Event{Type: events.EventStage, Payload: map[string]interface{}{"phase": "before_toolcall", "tool": tool, "message": "tool:" + tool}})
return nil
})
s.RegisterStage(sdk.StageBeforeOutput, func(ctx *sdk.StageContext) error {
p.evBus.Publish(&events.Event{Type: events.EventStage, Payload: map[string]interface{}{"phase": "before_output", "message": "output"}})
return nil
})
h := NewHandler(p.sup, p.mem, p.sk, p.lua, p.cfg, p.iom, p.tm, p.ks, p.tr, p.cr, p.pr, p.evBus, p.statusProvider, p.providerMgr, p.baseAPIKey) h := NewHandler(p.sup, p.mem, p.sk, p.lua, p.cfg, p.iom, p.tm, p.ks, p.tr, p.cr, p.pr, p.evBus, p.statusProvider, p.providerMgr, p.baseAPIKey)
p.handler = h p.handler = h
h.RegisterRoutes(p.mux) h.RegisterRoutes(p.mux)

View File

@ -38,6 +38,7 @@ type IOInjector = pubsdk.IOInjector
type ToolRegistrar = pubsdk.ToolRegistrar type ToolRegistrar = pubsdk.ToolRegistrar
type StageRegistrar = pubsdk.StageRegistrar type StageRegistrar = pubsdk.StageRegistrar
type APIRegistrar = pubsdk.APIRegistrar type APIRegistrar = pubsdk.APIRegistrar
type OutputChannelRegistrar = pubsdk.OutputChannelRegistrar
type PluginSDK struct { type PluginSDK struct {
*pubsdk.PluginSDK *pubsdk.PluginSDK
@ -68,23 +69,36 @@ func (a ioAdapter) InjectTextNoMemory(source, channel, text string) {
} }
} }
func New(name string, iom *agentIO.IOManager, eventBus *events.Bus, mem MemoryAPI, // SDKConfig holds all dependencies for creating a PluginSDK.
textMem TextMemoryAPI, docMem DocMemoryAPI, know KnowledgeAPI, llm LLMAPI, type SDKConfig struct {
sett SettingsAPI, regTool ToolRegistrar, regStage StageRegistrar, regAPI APIRegistrar, IOManager *agentIO.IOManager
) *PluginSDK { EventBus *events.Bus
base := pubsdk.New(name, sett, regTool, regStage, regAPI) Memory MemoryAPI
if iom != nil { TextMemory TextMemoryAPI
base.SetIOInjector(ioAdapter{iom: iom}) DocMemory DocMemoryAPI
Knowledge KnowledgeAPI
LLM LLMAPI
Settings SettingsAPI
RegTool ToolRegistrar
RegStage StageRegistrar
RegAPI APIRegistrar
RegOutput OutputChannelRegistrar
}
func New(name string, cfg SDKConfig) *PluginSDK {
base := pubsdk.New(name, cfg.Settings, cfg.RegTool, cfg.RegStage, cfg.RegAPI, cfg.RegOutput)
if cfg.IOManager != nil {
base.SetIOInjector(ioAdapter{iom: cfg.IOManager})
} }
base.SetMemoryAPI(mem) base.SetMemoryAPI(cfg.Memory)
base.SetTextMemoryAPI(textMem) base.SetTextMemoryAPI(cfg.TextMemory)
base.SetDocMemoryAPI(docMem) base.SetDocMemoryAPI(cfg.DocMemory)
base.SetKnowledgeAPI(know) base.SetKnowledgeAPI(cfg.Knowledge)
base.SetLLMAPI(llm) base.SetLLMAPI(cfg.LLM)
return &PluginSDK{ return &PluginSDK{
PluginSDK: base, PluginSDK: base,
iom: iom, iom: cfg.IOManager,
eventBus: eventBus, eventBus: cfg.EventBus,
logger: log.Default(), logger: log.Default(),
} }
} }