device gateway: remotedevice 插件(设备接入网关) + GUI/waiter 受控设备桥 + 设备页/授权开关/托盘/退出进托盘 + 白屏修复(惰性Tray) + deviceinfo 工具

This commit is contained in:
2026-08-17 09:34:25 +08:00
parent c52331cd5e
commit ef5f010e87
19 changed files with 3259 additions and 207 deletions

BIN
cmd/gui/icon-tray.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

BIN
cmd/gui/icon-tray@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@ -329,6 +329,17 @@ function createWindow() {
mainWindow.webContents.openDevTools();
}
// 退出进托盘拦截窗口关闭事件exitToTray 且托盘可用时 hide 而非 close
mainWindow.on("close", (e) => {
try {
const _p = loadGuiPrefs();
if (_p && _p.exitToTray && tray) {
e.preventDefault();
mainWindow.hide();
return;
}
} catch (err) {}
});
mainWindow.on("closed", () => {
mainWindow = null;
});
@ -344,7 +355,23 @@ ipcMain.handle("window:toggleMaximize", (e) => {
else win.maximize();
});
ipcMain.handle("window:close", (e) => {
BrowserWindow.fromWebContents(e.sender)?.close();
const win = BrowserWindow.fromWebContents(e.sender);
if (!win) return;
// 退出进托盘:偏好开启且托盘存在时隐藏而非关闭
try {
const _p = loadGuiPrefs();
console.log(
"[window:close] exitToTray=" +
!!(_p && _p.exitToTray) +
" tray=" +
!!tray,
);
if (_p && _p.exitToTray && tray) {
win.hide();
return true;
}
} catch (err) {}
win.close();
});
ipcMain.handle("connections:list", () => {
@ -742,6 +769,257 @@ ipcMain.handle("cli:request", (_, { socketPath, apiKey, line }) => {
});
});
// ============ Device Bridge: GUI 作为设备接入 remotedevice 网关 ============
// 复用 netraw TCP+ 手写 WS 帧;收到 {op:"cmd"} 用 spawn 在本机执行并回 cmd_result。
const devNet = require("net");
const devOs = require("os");
let deviceBridge = null; // 当前活动设备桥
let deviceBridgeId = ""; // 设备 meta device_idhello 后可用于 cmd_result
let deviceBridgeAddr = ""; // 设备桥网关地址
// 建立到 remotedevice WS 网关连接,返回 {send(obj), close()},消息经 onMsg 回调。
function connectDeviceWS(url, token, onMsg) {
const m = /^https?:\/\/([^:/]+)(?::(\d+))?/.exec(url || "");
const host = m ? m[1] : "127.0.0.1";
const port = m && m[2] ? parseInt(m[2], 10) : 9890;
const path = "/api/v1/device/ws?token=" + encodeURIComponent(token || "");
return new Promise((resolve, reject) => {
const sock = devNet.createConnection({ host, port }, () => {
const key = crypto.randomBytes(16).toString("base64");
sock.write(
"GET " +
path +
" HTTP/1.1\r\n" +
"Host: " +
host +
":" +
port +
"\r\n" +
"Upgrade: websocket\r\nConnection: Upgrade\r\n" +
"Sec-WebSocket-Key: " +
key +
"\r\nSec-WebSocket-Version: 13\r\n\r\n",
);
});
let buf = Buffer.alloc(0);
let upgraded = false;
let opened = false;
sock.on("data", (chunk) => {
buf = Buffer.concat([buf, chunk]);
if (!upgraded) {
const idx = buf.indexOf("\r\n\r\n");
if (idx === -1) return;
const head = buf.slice(0, idx).toString("utf8");
buf = buf.slice(idx + 4);
upgraded = true;
if (!head.includes("101")) {
sock.destroy();
return reject(
new Error("WS upgrade failed: " + head.split("\r\n")[0]),
);
}
opened = true;
resolve({
send: (obj) => sendDeviceFrame(sock, JSON.stringify(obj)),
close: () => sock.destroy(),
});
}
while (buf.length >= 2) {
const b0 = buf[0];
const opcode = b0 & 0x0f;
const b1 = buf[1];
let len = b1 & 0x7f;
let off = 2;
if (len === 126) {
if (buf.length < 4) break;
len = buf.readUInt16BE(2);
off = 4;
} else if (len === 127) {
if (buf.length < 10) break;
len = Number(buf.readBigUInt64BE(2));
off = 10;
}
if (buf.length < off + len) break;
const payload = buf.slice(off, off + len);
buf = buf.slice(off + len);
if (opcode === 0x1) {
try {
onMsg(JSON.parse(payload.toString("utf8")));
} catch (e) {}
} else if (opcode === 0x8) {
sock.destroy();
return;
}
}
});
sock.on("error", (e) => {
if (!opened) reject(e);
});
sock.on("close", () => {
deviceBridge = null;
});
});
}
// 发送 WS text 帧(客户端加掩码)
function sendDeviceFrame(sock, text) {
const payload = Buffer.from(text, "utf8");
const mask = crypto.randomBytes(4);
const masked = Buffer.from(payload);
for (let i = 0; i < masked.length; i++) masked[i] ^= mask[i % 4];
const len = masked.length;
let hdr;
if (len < 126) {
hdr = Buffer.from([0x81, 0x80 | len]);
} else if (len < 65536) {
hdr = Buffer.alloc(4);
hdr[0] = 0x81;
hdr[1] = 0x80 | 126;
hdr.writeUInt16BE(len, 2);
} else {
hdr = Buffer.alloc(10);
hdr[0] = 0x81;
hdr[1] = 0x80 | 127;
hdr.writeBigUInt64BE(BigInt(len), 2);
}
sock.write(Buffer.concat([hdr, mask, masked]));
}
// 启动设备桥:以 device 连接url=网关地址, apiKey=token接入网关。
async function startDeviceBridge(conn) {
if (!conn || conn.type !== "device") return;
const token = conn.apiKey || "";
if (!token) {
console.error("[device-bridge] missing token");
return;
}
deviceBridgeAddr = conn.url || "";
deviceBridgeId =
"gui-" + (devOs.hostname() || "local").replace(/[^a-zA-Z0-9_-]/g, "_");
try {
const ws = await connectDeviceWS(conn.url, token, onDeviceMsg);
deviceBridge = ws;
ws.send({
op: "hello",
device: {
device_id: deviceBridgeId,
name: "HomeAgent GUI",
kind: "computer",
caps: ["status", "cmdrun", "deviceinfo", "cmdresult"],
info: {
hostname: devOs.hostname() || "",
platform: process.platform || "",
arch: process.arch || "",
os_release: "", // 不提权读取 /etc/os-release避免破坏沙箱如需可在白名单命令里由 agent 探
node_version:
process.versions && process.versions.node
? process.versions.node
: "",
electron_version:
process.versions && process.versions.electron
? process.versions.electron
: "",
version: app.getVersion ? app.getVersion() : "",
cpus: devOs.cpus ? devOs.cpus().length : 0,
total_mem_bytes: devOs.totalmem ? devOs.totalmem() : 0,
},
},
});
ws.send({ op: "bind", device_id: deviceBridgeId, token });
console.log(
"[device-bridge] connected as " + deviceBridgeId + " @ " + conn.url,
);
} catch (e) {
console.error("[device-bridge] connect failed: " + e.message);
}
}
function stopDeviceBridge() {
if (deviceBridge) {
try {
deviceBridge.close();
} catch (e) {}
deviceBridge = null;
}
}
// ============ 系统托盘(惰性 + 安全降级) ============
let tray = null;
function initTray() {
if (tray) return;
try {
const electron = require("electron");
const Tray = electron.Tray;
const TMenu = electron.Menu;
const nImg = electron.nativeImage;
let img = null;
const cands = [
path.join(__dirname, "icon-tray@2x.png"),
path.join(__dirname, "icon-tray.png"),
path.join(__dirname, "icon.ico"),
];
for (const c of cands) {
try {
const m = nImg.createFromPath(c);
if (m && !m.isEmpty()) {
img = m;
break;
}
} catch (e) {}
}
if (!img || img.isEmpty()) {
try {
img = nImg.createFromPath(path.join(__dirname, "icon-tray.png"));
} catch (e) {}
}
if (!img || img.isEmpty()) {
img = nImg.createEmpty();
}
tray = new Tray(img);
const tmenu = TMenu.buildFromTemplate([
{ label: "HomeAgent", enabled: false },
{ type: "separator" },
{ label: "显示主界面", click: () => showMainWindow() },
{
label: "退出",
click: () => {
app.isQuitting = true;
app.quit();
},
},
]);
tray.setToolTip("HomeAgent - 个人智能管家");
tray.setContextMenu(tmenu);
tray.on("double-click", () => showMainWindow());
console.log("[tray] READY: " + (tray ? "tray-created" : "null"));
} catch (e) {
console.error("[tray] init failed (safe ignore): " + e.message);
try {
if (tray) {
tray.destroy();
tray = null;
}
} catch (e2) {}
}
}
function showMainWindow() {
try {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.show();
mainWindow.focus();
}
} catch (e) {}
}
function destroyTray() {
try {
if (tray) {
tray.destroy();
tray = null;
}
} catch (e) {}
}
app.whenReady().then(async () => {
installAuthRule();
const running = await isServerRunning();
@ -754,13 +1032,46 @@ app.whenReady().then(async () => {
console.error("homed failed to start within timeout");
}
}
// 设备桥:启动接入 remotedevice
try {
var _devs = loadConnections().connections || [];
var _dev = _devs.filter((c) => c.type === "device");
if (_dev.length > 0) startDeviceBridge(_dev[_dev.length - 1]);
} catch (e) {
console.error("device bridge init: " + e.message);
}
try {
initTray();
} catch (e) {
console.error("[tray] whenReady call: " + e.message);
}
createWindow();
});
app.on("before-quit", stopHomed);
app.on("before-quit", () => {
stopHomed();
try {
stopDeviceBridge();
} catch (e) {}
try {
destroyTray();
} catch (e) {}
});
app.on("window-all-closed", () => {
if (process.platform !== "darwin") app.quit();
// 退出进托盘:依偏好决定(安全降级:无托盘时退出)
let _exitTray = false;
try {
const _p = loadGuiPrefs();
_exitTray = !!(_p && _p.exitToTray);
} catch (e) {}
if (_exitTray && tray) {
try {
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.hide();
} catch (e) {}
} else {
app.quit();
}
});
app.on("activate", () => {
@ -768,3 +1079,73 @@ app.on("activate", () => {
createWindow();
}
});
// IPCprefsrenderer 设置页需要)
// IPC本机设备身份renderer 设备页需要)
ipcMain.handle("device:identity", () =>
deviceBridge
? {
device_id: deviceBridgeId,
active: true,
address: deviceBridgeAddr || "",
}
: { device_id: "", active: false, address: "" },
);
const GUI_PREFS_FILE = path.join(app.getPath("userData"), "gui-prefs.json");
function loadGuiPrefs() {
try {
if (fs.existsSync(GUI_PREFS_FILE)) {
const d = JSON.parse(fs.readFileSync(GUI_PREFS_FILE, "utf-8"));
return {
autoLaunch: !!d.autoLaunch,
silentStart: !!d.silentStart,
exitToTray: d.exitToTray === undefined ? true : !!d.exitToTray,
};
}
} catch (e) {}
return { autoLaunch: false, silentStart: false, exitToTray: true };
}
function saveGuiPrefs(p) {
try {
fs.writeFileSync(GUI_PREFS_FILE, JSON.stringify(p, null, 2), "utf-8");
} catch (e) {}
return p;
}
// 应用开机自启设置Electron LoginItem
function applyAutoLaunch(enabled) {
try {
app.setLoginItemSettings({
openAtLogin: enabled,
openAsHidden: true, // 开机自启时静默Windows/macOS 支持)
path: process.execPath,
});
return true;
} catch (e) {
console.error("setLoginItemSettings failed: " + e.message);
return false;
}
}
// 全局偏好缓存(供 createWindow 静默判断使用)
let guiPrefs = loadGuiPrefs();
// IPC本机设备身份设备桥登记的设备 ID 与状态)
// IPC读取偏好
ipcMain.handle("prefs:get", () => {
return loadGuiPrefs();
});
// IPC写入并应用偏好
ipcMain.handle("prefs:set", (_, p) => {
const cur = loadGuiPrefs();
const next = Object.assign({}, cur, p || {});
if (typeof next.autoLaunch === "boolean") {
next.autoLaunch = applyAutoLaunch(next.autoLaunch)
? next.autoLaunch
: false;
}
guiPrefs = saveGuiPrefs(next);
return guiPrefs;
});

View File

@ -7,11 +7,9 @@
"": {
"name": "homeagent-gui",
"version": "1.0.0",
"dependencies": {
"electron": "^33.0.0"
},
"devDependencies": {
"asar": "^3.2.0",
"electron": "^33.0.0",
"electron-builder": "^26.15.3",
"electron-packager": "^17.1.2",
"icojs": "^0.23.0",
@ -107,6 +105,7 @@
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz",
"integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "^4.1.1",
@ -1074,6 +1073,7 @@
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz",
"integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
@ -1086,6 +1086,7 @@
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz",
"integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==",
"dev": true,
"license": "MIT",
"dependencies": {
"defer-to-connect": "^2.0.0"
@ -1123,6 +1124,7 @@
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz",
"integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/http-cache-semantics": "*",
@ -1164,12 +1166,14 @@
"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==",
"dev": true,
"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==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
@ -1196,6 +1200,7 @@
"version": "20.19.43",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
"integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
@ -1205,6 +1210,7 @@
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz",
"integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
@ -1214,6 +1220,7 @@
"version": "2.10.3",
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
"integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@ -1873,6 +1880,7 @@
"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.",
"dev": true,
"license": "MIT",
"optional": true
},
@ -1890,6 +1898,7 @@
"version": "0.2.13",
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
"integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "*"
@ -1999,6 +2008,7 @@
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz",
"integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.6.0"
@ -2008,6 +2018,7 @@
"version": "7.0.4",
"resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz",
"integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==",
"dev": true,
"license": "MIT",
"dependencies": {
"clone-response": "^1.0.2",
@ -2099,6 +2110,7 @@
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz",
"integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==",
"dev": true,
"license": "MIT",
"dependencies": {
"mimic-response": "^1.0.0"
@ -2217,6 +2229,7 @@
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@ -2263,6 +2276,7 @@
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"mimic-response": "^3.1.0"
@ -2278,6 +2292,7 @@
"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==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
@ -2290,6 +2305,7 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz",
"integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
@ -2299,6 +2315,7 @@
"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==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@ -2317,6 +2334,7 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
"integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@ -2354,6 +2372,7 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz",
"integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==",
"dev": true,
"license": "MIT",
"optional": true
},
@ -2483,6 +2502,7 @@
"version": "33.4.11",
"resolved": "https://registry.npmjs.org/electron/-/electron-33.4.11.tgz",
"integrity": "sha512-xmdAs5QWRkInC7TpXGNvzo/7exojubk+72jn1oJL7keNeIlw7xNglf8TGtJtkR4rWC5FJq0oXiIXPS9BcK2Irg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@ -2751,6 +2771,7 @@
"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==",
"dev": true,
"license": "MIT",
"dependencies": {
"once": "^1.4.0"
@ -2760,6 +2781,7 @@
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
"integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@ -2784,7 +2806,7 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"devOptional": true,
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@ -2794,7 +2816,7 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"devOptional": true,
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@ -2831,6 +2853,7 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz",
"integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==",
"dev": true,
"license": "MIT",
"optional": true
},
@ -2847,6 +2870,7 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
@ -2866,6 +2890,7 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
"integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"debug": "^4.1.1",
@ -2908,6 +2933,7 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
"integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"pend": "~1.2.0"
@ -3085,6 +3111,7 @@
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
"integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
@ -3239,6 +3266,7 @@
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
"integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==",
"dev": true,
"license": "MIT",
"dependencies": {
"pump": "^3.0.0"
@ -3275,6 +3303,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz",
"integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==",
"dev": true,
"license": "BSD-3-Clause",
"optional": true,
"dependencies": {
@ -3293,6 +3322,7 @@
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"dev": true,
"license": "ISC",
"optional": true,
"bin": {
@ -3306,6 +3336,7 @@
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz",
"integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@ -3323,7 +3354,7 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"devOptional": true,
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@ -3336,6 +3367,7 @@
"version": "11.8.6",
"resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz",
"integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@sindresorhus/is": "^4.0.0",
@ -3361,6 +3393,7 @@
"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==",
"dev": true,
"license": "ISC"
},
"node_modules/has-flag": {
@ -3376,6 +3409,7 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@ -3434,6 +3468,7 @@
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
"integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==",
"dev": true,
"license": "BSD-2-Clause"
},
"node_modules/http-proxy-agent": {
@ -3453,6 +3488,7 @@
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz",
"integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"quick-lru": "^5.1.1",
@ -3670,6 +3706,7 @@
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
"integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
"dev": true,
"license": "MIT"
},
"node_modules/json-schema-traverse": {
@ -3682,6 +3719,7 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
"integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==",
"dev": true,
"license": "ISC",
"optional": true
},
@ -3701,6 +3739,7 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
"integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
"dev": true,
"license": "MIT",
"optionalDependencies": {
"graceful-fs": "^4.1.6"
@ -3719,6 +3758,7 @@
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
"integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
"dev": true,
"license": "MIT",
"dependencies": {
"json-buffer": "3.0.1"
@ -3775,6 +3815,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz",
"integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@ -3802,6 +3843,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz",
"integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@ -3857,6 +3899,7 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz",
"integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
@ -3921,6 +3964,7 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/node-abi": {
@ -4074,6 +4118,7 @@
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz",
"integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
@ -4086,6 +4131,7 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
@ -4096,6 +4142,7 @@
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"dev": true,
"license": "ISC",
"dependencies": {
"wrappy": "1"
@ -4105,6 +4152,7 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz",
"integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@ -4230,6 +4278,7 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
"integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
"dev": true,
"license": "MIT"
},
"node_modules/picocolors": {
@ -4359,6 +4408,7 @@
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
"integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.4.0"
@ -4392,6 +4442,7 @@
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
"dev": true,
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.1.0",
@ -4420,6 +4471,7 @@
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz",
"integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
@ -4555,12 +4607,14 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz",
"integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==",
"dev": true,
"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==",
"dev": true,
"license": "MIT",
"dependencies": {
"lowercase-keys": "^2.0.0"
@ -4596,6 +4650,7 @@
"version": "2.15.4",
"resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz",
"integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==",
"dev": true,
"license": "BSD-3-Clause",
"optional": true,
"dependencies": {
@ -4638,6 +4693,7 @@
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@ -4647,6 +4703,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz",
"integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==",
"dev": true,
"license": "MIT",
"optional": true
},
@ -4654,6 +4711,7 @@
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz",
"integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@ -4835,6 +4893,7 @@
"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==",
"dev": true,
"license": "BSD-3-Clause",
"optional": true
},
@ -4933,6 +4992,7 @@
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz",
"integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"debug": "^4.1.0"
@ -5158,6 +5218,7 @@
"version": "0.13.1",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz",
"integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==",
"dev": true,
"license": "(MIT OR CC0-1.0)",
"optional": true,
"engines": {
@ -5193,12 +5254,14 @@
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"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==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 4.0.0"
@ -5323,6 +5386,7 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"dev": true,
"license": "ISC"
},
"node_modules/xmlbuilder": {
@ -5383,6 +5447,7 @@
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
"integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==",
"dev": true,
"license": "MIT",
"dependencies": {
"buffer-crc32": "~0.2.3",

View File

@ -1,26 +1,44 @@
const { contextBridge, ipcRenderer } = require('electron');
const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld('homeagent', {
contextBridge.exposeInMainWorld("homeagent", {
win: {
minimize: () => ipcRenderer.invoke('window:minimize'),
toggleMaximize: () => ipcRenderer.invoke('window:toggleMaximize'),
close: () => ipcRenderer.invoke('window:close'),
minimize: () => ipcRenderer.invoke("window:minimize"),
toggleMaximize: () => ipcRenderer.invoke("window:toggleMaximize"),
close: () => ipcRenderer.invoke("window:close"),
},
connections: {
list: () => ipcRenderer.invoke('connections:list'),
add: (conn) => ipcRenderer.invoke('connections:add', conn),
update: (id, updates) => ipcRenderer.invoke('connections:update', { id, updates }),
delete: (id) => ipcRenderer.invoke('connections:delete', id),
setCurrent: (id) => ipcRenderer.invoke('connections:setCurrent', id),
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),
},
cli: {
request: (socketPath, apiKey, line) => ipcRenderer.invoke('cli:request', { socketPath, apiKey, line }),
request: (socketPath, apiKey, line) =>
ipcRenderer.invoke("cli:request", { socketPath, apiKey, line }),
},
webui: {
setAuth: (url, cookie, headers, username, password) => ipcRenderer.invoke('webui:setAuth', { url, cookie, headers, username, password }),
openLogin: (url, username, password) => ipcRenderer.invoke('webui:openLogin', { url, username, password }),
onLoginResult: (cb) => ipcRenderer.on('webui:login-result', (_e, d) => cb(d)),
setAuth: (url, cookie, headers, username, password) =>
ipcRenderer.invoke("webui:setAuth", {
url,
cookie,
headers,
username,
password,
}),
openLogin: (url, username, password) =>
ipcRenderer.invoke("webui:openLogin", { url, username, password }),
onLoginResult: (cb) =>
ipcRenderer.on("webui:login-result", (_e, d) => cb(d)),
},
cacheBg: (src) => ipcRenderer.invoke("bg:cache", { src }),
log: (m) => ipcRenderer.invoke("log:r", m),
prefs: {
get: () => ipcRenderer.invoke("prefs:get"),
set: (p) => ipcRenderer.invoke("prefs:set", p),
},
device: {
identity: () => ipcRenderer.invoke("device:identity"),
},
cacheBg: (src) => ipcRenderer.invoke('bg:cache', { src }),
log: (m) => ipcRenderer.invoke('log:r', m),
});

View File

@ -27,6 +27,9 @@ const state = {
lang: localStorage.getItem("ha-lang") || "zh",
connections: [],
currentConn: null,
devices: [],
selfDeviceId: "",
selfGateway: "",
};
// ===== I18n =====
@ -525,6 +528,11 @@ function renderAll() {
} catch (e) {
console.error("renderAdapters", e);
}
try {
renderDevices();
} catch (e) {
console.error("renderDevices", e);
}
applyI18n();
applyCardTilt();
refreshAll();
@ -557,6 +565,28 @@ async function refreshAll() {
try {
await loadCmdHistory();
} catch (e) {}
try {
if (state.currentConn && state.currentConn.type === "device") {
var d = await api("/device");
state.devices = d.devices || [];
}
} catch (e) {
state.devices = [];
}
try {
if (
state.currentConn &&
state.currentConn.type === "device" &&
window.homeagent &&
window.homeagent.device
) {
var idn = await window.homeagent.device.identity();
if (idn) {
state.selfDeviceId = idn.device_id || "";
state.selfGateway = idn.address || "";
}
}
} catch (e) {}
try {
renderOverview();
} catch (e) {
@ -592,6 +622,11 @@ async function refreshAll() {
} catch (e) {
console.error("renderAdapters", e);
}
try {
renderDevices();
} catch (e) {
console.error("renderDevices", e);
}
applyI18n();
applyCardTilt();
}
@ -970,19 +1005,33 @@ function chanLetter(src) {
function renderReasoningCard(text, isStreaming, idx) {
var preview =
typeof marked === "undefined"
? escHtml(text).replace(/<[^>]+>/g, " ").slice(0, 60)
? escHtml(text)
.replace(/<[^>]+>/g, " ")
.slice(0, 60)
: text.replace(/[\s\n]+/g, " ").slice(0, 60);
return (
'<div class="reasoning-card' + (isStreaming ? " rc-streaming" : "") + '" data-idx="' + (idx | 0) + '">' +
'<div class="reasoning-card' +
(isStreaming ? " rc-streaming" : "") +
'" data-idx="' +
(idx | 0) +
'">' +
'<div class="reasoning-head" onclick="toggleReasoning(this)">' +
'<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="rc-ico"><path d="M9 3a2 2 0 0 0-2 2v2a2 2 0 0 1-2 2H3a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2a2 2 0 0 1 2 2v2a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v-2a2 2 0 0 1 2-2h2a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2a2 2 0 0 1-2-2V3a2 2 0 0 0-2-2H9zM12 8v4m0 4h.01"/></svg>' +
'<span class="rc-title">' + (isStreaming ? __("思考中...","Thinking...") : __("思考","Thinking")) + '</span>' +
'<span class="rc-title">' +
(isStreaming ? __("思考中...", "Thinking...") : __("思考", "Thinking")) +
"</span>" +
'<span class="rc-chev">▾</span></div>' +
'<div class="reasoning-body" style="display:' + (isStreaming ? "block" : "none") + '">' +
'<div class="reasoning-body" style="display:' +
(isStreaming ? "block" : "none") +
'">' +
(isStreaming
? '<div class="reasoning-preview">' + escHtml(preview) + '</div><div class="reasoning-sweep"></div>'
: '<div class="reasoning-content">' + (typeof marked !== "undefined" ? marked.parse(text) : escHtml(text)) + '</div>') +
'</div></div>'
? '<div class="reasoning-preview">' +
escHtml(preview) +
'</div><div class="reasoning-sweep"></div>'
: '<div class="reasoning-content">' +
(typeof marked === "undefined" ? escHtml(text) : marked.parse(text)) +
"</div>") +
"</div></div>"
);
}
@ -1106,20 +1155,29 @@ function renderChat() {
: String(tc.result)
: "";
var running = !resultStr && tc.status !== "denied";
var error = tc.status === "error" || tc.status === "denied" || !!tc.error;
var drip = newlyDone.indexOf(tc.tool || tc.name || "") !== -1 ? " tool-drip-in" : "";
var iconSvg =
error
? '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M5.6 5.6l12.8 12.8"/></svg>'
: running
? '<span class="tc-spinner"></span>'
: '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M14.7 6.3a4 4 0 0 0-5.4 5.4L3 18l3 3 6.3-6.3a4 4 0 0 0 5.4-5.4l-2.9 2.9-2.5-.6-.6-2.5z"/></svg>';
var error =
tc.status === "error" || tc.status === "denied" || !!tc.error;
var drip =
newlyDone.indexOf(tc.tool || tc.name || "") === -1
? ""
: " tool-drip-in";
var iconSvg = error
? '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M5.6 5.6l12.8 12.8"/></svg>'
: running
? '<span class="tc-spinner"></span>'
: '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M14.7 6.3a4 4 0 0 0-5.4 5.4L3 18l3 3 6.3-6.3a4 4 0 0 0 5.4-5.4l-2.9 2.9-2.5-.6-.6-2.5z"/></svg>';
var statusHtml =
tc.status === "denied"
? '<span class="tc-state tc-deny">' + __("已拒绝","Denied") + "</span>"
? '<span class="tc-state tc-deny">' +
__("已拒绝", "Denied") +
"</span>"
: running
? '<span class="tc-state tc-run">' + __("调用中","Running") + "</span>"
: '<span class="tc-state tc-done">' + __("完成","Done") + "</span>";
? '<span class="tc-state tc-run">' +
__("调用中", "Running") +
"</span>"
: '<span class="tc-state tc-done">' +
__("完成", "Done") +
"</span>";
var pluginHtml = tc.plugin
? '<span class="tc-plugin">' + escHtml(tc.plugin) + "</span>"
: "";
@ -1140,10 +1198,18 @@ function renderChat() {
'<span class="tc-caret">▾</span></div>' +
'<div class="tc-detail" style="display:none">' +
(argsStr && argsStr !== "{}"
? '<div class="tc-args"><div class="tc-detail-label">' + __("参数","Args") + "</div>" + escHtml(argsStr) + "</div>"
? '<div class="tc-args"><div class="tc-detail-label">' +
__("参数", "Args") +
"</div>" +
escHtml(argsStr) +
"</div>"
: "") +
(resultStr
? '<div class="tc-result"><div class="tc-detail-label">' + __("结果","Result") + "</div>" + escHtml(resultStr) + "</div>"
? '<div class="tc-result"><div class="tc-detail-label">' +
__("结果", "Result") +
"</div>" +
escHtml(resultStr) +
"</div>"
: "") +
"</div></div>";
});
@ -1329,9 +1395,10 @@ function toggleReasoning(el) {
var content = card.querySelector(".reasoning-content");
if (content && !content.childElementCount) {
var idx = parseInt(card.getAttribute("data-idx"), 10) || 0;
var text = (state.messages[idx] && state.messages[idx].reasoning_content) || "";
var text =
(state.messages[idx] && state.messages[idx].reasoning_content) || "";
content.innerHTML =
typeof marked !== "undefined" ? marked.parse(text) : escHtml(text);
typeof marked === "undefined" ? escHtml(text) : marked.parse(text);
}
body.style.display = "block";
card.classList.add("open");
@ -1691,6 +1758,16 @@ async function sendChat() {
var btn = document.getElementById("chat-send-btn");
var text = inp.value.trim();
if (!text || state.chatLoading) return;
if (state.currentConn && state.currentConn.type === "device") {
toast(
__(
"设备网关连接不支持聊天",
"Device gateway connection does not support chat",
),
true,
);
return;
}
state.chatStick = true;
state.chatFinalIdx = -1;
state.messages.push({ role: "user", content: text });
@ -1702,51 +1779,57 @@ async function sendChat() {
btn.textContent = "";
rerenderChat();
try {
var r = await api("/chat", {
method: "POST",
body: JSON.stringify({ message: text }),
timeout: 120000,
});
state.chatStage = "";
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) {
console.log(
"[sendChat] updating existing streaming msg, tool_calls before:",
last.tool_calls?.length,
);
last.content = r.response || __("(无响应)", "(no response)");
last._grow = true;
if (!last.reasoning_content)
last.reasoning_content = r.reasoning_content || "";
last._final = true;
delete last._streaming;
} else {
state.messages.push({
role: "assistant",
content: r.response || __("(无响应)", "(no response)"),
reasoning_content: r.reasoning_content,
tool_calls:
last && last.role === "assistant" && last.tool_calls
? last.tool_calls
: [],
_final: true,
_grow: true,
});
// webui 连接:触发式 + SSE 流式接管(避免 POST 与 SSE 双通道重复渲染卡死)
var isWebui = !state.currentConn || state.currentConn.type !== "cli";
var r = null;
try {
if (isWebui) {
// 触发请求:短超时确认受理,回复靠 SSE
r = await api("/chat", {
method: "POST",
body: JSON.stringify({ message: text }),
timeout: 15000,
headers: { "X-Trigger-Only": "1" },
});
} else {
r = await api("/chat", {
method: "POST",
body: JSON.stringify({ message: text }),
timeout: 120000,
});
}
} catch (e) {
// 触发请求超时/失败:不阻塞 UI等 SSE 兜底;若 SSE 也无响应则报错
console.warn("[sendChat] trigger failed: " + e.message);
r = null;
}
state.chatFinalIdx = state.messages.length - 1;
rerenderChat();
state.chatStage = __("AI 回复中...", "AI replying...");
var last = state.messages[state.messages.length - 1];
// CLI/device 无 SSEPOST 完整结果直接填充
if (!isWebui && r) {
if (last && last.role === "assistant" && last._streaming) {
last.content = r.response || __("(无响应)", "(no response)");
last._grow = true;
if (!last.reasoning_content)
last.reasoning_content = r.reasoning_content || "";
last._final = true;
delete last._streaming;
} else {
state.messages.push({
role: "assistant",
content: r.response || __("(无响应)", "(no response)"),
reasoning_content: r.reasoning_content,
tool_calls:
last && last.role === "assistant" && last.tool_calls
? last.tool_calls
: [],
_final: true,
_grow: true,
});
}
state.chatFinalIdx = state.messages.length - 1;
}
if (!isWebui) rerenderChat();
} catch (e) {
state.messages.push({
role: "assistant",
@ -2950,6 +3033,7 @@ function renderOneSettings() {
__("后端连接", "Backend Connections") +
"</h2>" +
'<div id="conn-manager"></div></div>' +
'<div id="gui-prefs"></div>' +
'<div class="settings-tabs" id="settings-tabs"></div>' +
'<div class="settings-content">';
if (
@ -3290,8 +3374,10 @@ function renderOneSettings() {
}
html += "</div></div>";
document.getElementById("view-settings").innerHTML = html;
renderGuiPrefs();
renderSettingsTabs();
renderConnSection();
loadGuiPrefs();
}
function markDirty(k) {
@ -3689,6 +3775,76 @@ function openConnManager() {
switchView("settings");
}
// ===== 客户端偏好(开机自启/静默启动/退出进托盘) =====
var guiPrefs = { autoLaunch: false, silentStart: false, exitToTray: true };
async function loadGuiPrefs() {
try {
if (window.homeagent && window.homeagent.prefs) {
guiPrefs = (await window.homeagent.prefs.get()) || guiPrefs;
}
} catch (e) {}
}
async function saveGuiPrefs(key, val) {
var next = Object.assign({}, guiPrefs, { [key]: val });
try {
if (window.homeagent && window.homeagent.prefs) {
guiPrefs = (await window.homeagent.prefs.set(next)) || next;
} else {
guiPrefs = next;
}
} catch (e) {
toast(__("保存偏好失败: ", "Save prefs failed: ") + e.message, true);
return;
}
toast(__("已保存", "Saved"));
renderGuiPrefs();
}
function renderGuiPrefs() {
var el = document.getElementById("gui-prefs");
if (!el) return;
function sw(key, label, desc, onchange) {
return (
'<div class="pref-row" style="margin:8px 0;display:flex;justify-content:space-between;align-items:center">' +
"<div><div>" +
label +
"</div>" +
(desc
? '<div style="font-size:11px;color:var(--text-muted)">' +
desc +
"</div>"
: "") +
"</div>" +
'<label class="switch"><input type="checkbox" ' +
(guiPrefs[key] ? "checked" : "") +
' onchange="' +
onchange +
'"><span></span></label></div>'
);
}
el.innerHTML =
'<div class="card" id="gui-prefs-card"><h2>' +
__("客户端偏好", "Client Preferences") +
"</h2>" +
sw(
"autoLaunch",
__("开机自启", "Auto launch on start"),
__("登录后自动启动 HomeAgent静默", "Start silently at login"),
"saveGuiPrefs('autoLaunch', this.checked)",
) +
sw(
"silentStart",
__("静默启动", "Silent start"),
__("启动时不显示主窗口,驻留托盘后台运行", "Start hidden, keep in tray"),
"saveGuiPrefs('silentStart', this.checked)",
) +
sw(
"exitToTray",
__("退出进托盘", "Exit to tray"),
__("关闭窗口时驻留托盘而不是退出", "Closing window keeps app in tray"),
"saveGuiPrefs('exitToTray', this.checked)",
) +
"</div>";
}
function renderConnSection() {
var cont = document.getElementById("conn-manager");
if (!cont) return;
@ -3755,7 +3911,8 @@ function renderConnSection() {
__("连接类型", "Type") +
'</label><select id="conn-type" onchange="toggleConnType()">' +
'<option value="webui">WebUI (HTTP)</option>' +
'<option value="cli">CLI (unix socket)</option></select>' +
'<option value="cli">CLI (unix socket)</option>' +
'<option value="device">设备网关 (remotedevice)</option></select>' +
'<div id="conn-addr-webui"><label>' +
__("地址", "URL") +
'</label><input id="conn-url" placeholder="http://localhost:18080"></div>' +
@ -3832,6 +3989,11 @@ function toggleConnType() {
t === "cli" ? "block" : "none";
document.getElementById("conn-auth-webui").style.display =
t === "cli" ? "none" : "block";
// device 类型不适用 webui 网关登录
var gwField = document.getElementById("conn-gw");
if (gwField)
gwField.closest("label, div").style.display =
t === "device" ? "none" : "block";
toggleGwFields();
}
@ -4160,6 +4322,38 @@ async function saveConnForm() {
testBtn.disabled = false;
return;
}
} else if (ctype === "device") {
// 设备网关:直接探活 /api/v1/device带 token
var devTest;
try {
devTest = await fetch(url + "/api/v1/device", {
headers: apiKey ? { "X-API-Key": apiKey } : {},
});
} catch (e) {
toast(
__("无法连接到设备网关 ", "Cannot connect to device gateway ") +
url +
": " +
e.message,
true,
);
testBtn.textContent = __("保存", "Save");
testBtn.disabled = false;
return;
}
if (!devTest.ok) {
toast(
__("设备网关测试失败: ", "Device gateway test failed: ") +
devTest.status +
"" +
(await devTest.text()).slice(0, 120) +
"",
true,
);
testBtn.textContent = __("保存", "Save");
testBtn.disabled = false;
return;
}
} else {
if (window.homeagent && window.homeagent.webui) {
if (gwEnabled && !cookie) {
@ -4260,19 +4454,31 @@ async function saveConnForm() {
testBtn.textContent = __("保存", "Save");
testBtn.disabled = false;
var connData =
ctype === "cli"
? { name: name, type: "cli", socketPath: sock, url: "", apiKey: apiKey }
: {
ctype === "device"
? {
name: name,
type: "webui",
type: "device",
url: url,
apiKey: apiKey,
username: username,
password: password,
cookie: cookie,
headers: headers,
gateway: gwEnabled,
};
username: "",
password: "",
cookie: "",
headers: "",
gateway: false,
}
: ctype === "cli"
? { name: name, type: "cli", socketPath: sock, url: "", apiKey: apiKey }
: {
name: name,
type: "webui",
url: url,
apiKey: apiKey,
username: username,
password: password,
cookie: cookie,
headers: headers,
gateway: gwEnabled,
};
var data;
if (editingConnId) {
data = await window.homeagent.connections.update(editingConnId, connData);
@ -4322,8 +4528,9 @@ connectSSE = () => {
state.eventSource = null;
}
if (!state.currentConn) return;
// CLI 连接无 SSE 通道,聊天走同步 cli:request
if (state.currentConn.type === "cli") return;
// CLI/device 连接无 SSE 通道,聊天走同步
if (state.currentConn.type === "cli" || state.currentConn.type === "device")
return;
connectFetchSSE(state.currentConn.url + "/api/v1/chat/events");
};
@ -4530,7 +4737,7 @@ function rerenderChatIfActive() {
!!last && last.role === "assistant" && !last._final && state.chatLoading;
if (streamingLast) {
if (state._streamTimer) clearTimeout(state._streamTimer);
state._streamTimer = setTimeout(function () {
state._streamTimer = setTimeout(() => {
state._streamTimer = null;
renderChatStreamChunk();
}, 90);
@ -4567,7 +4774,7 @@ function renderChatStreamChunk() {
var lastLen = el.__lastLen || 0;
if (c.length - lastLen > 200 || now - lastParse > 300) {
textEl.innerHTML =
typeof marked !== "undefined" ? marked.parse(c) : escHtml(c);
typeof marked === "undefined" ? escHtml(c) : marked.parse(c);
el.__lastParse = now;
el.__lastLen = c.length;
} else {
@ -4597,3 +4804,196 @@ function renderChatStreamChunk() {
renderChat();
}
// ===== Devices (remotedevice gateway) =====
function renderDevices() {
var el = document.getElementById("view-devices");
if (!el) return;
var conn = state.currentConn;
if (!conn || conn.type !== "device") {
el.innerHTML =
'<div class="card"><h2>' +
__("设备", "Devices") +
'</h2><p style="color:var(--text-muted)">' +
__(
"请先在设置中添加并切换到「设备网关 (remotedevice)」连接,以查看已接入设备。",
"Add and switch to a Remote Device Gateway connection in Settings to view devices.",
) +
"</p>" +
'<button class="btn btn-primary" onclick="goSettingsConn()">' +
__("管理连接", "Manage Connections") +
"</button></div>";
return;
}
var devs = state.devices || [];
var selfDev = null;
if (state.selfDeviceId) {
for (var si = 0; si < devs.length; si++) {
if (devs[si].device_id === state.selfDeviceId) {
selfDev = devs[si];
break;
}
}
}
var selfHtml =
'<div class="card" style="border-left:3px solid #4f8cff"><h2>' +
__("本机 GUI 设备", "Local GUI Device") +
"</h2>";
if (state.selfDeviceId) {
var sOnline = selfDev ? selfDev.online : false;
var sAuth = selfDev ? selfDev.authorized : false;
selfHtml +=
'<div class="kv-row"><span class="key">' +
__("设备 ID", "Device ID") +
'</span><span class="val">' +
escHtml(state.selfDeviceId) +
"</span></div>" +
'<div class="kv-row"><span class="key">' +
__("网关", "Gateway") +
'</span><span class="val">' +
escHtml(state.selfGateway || "-") +
"</span></div>" +
'<div class="kv-row"><span class="key">' +
__("状态", "Status") +
"</span><span>" +
(sOnline
? '<span class="dot-green">' + __("在线", "Online") + "</span>"
: '<span class="dot-gray">' + __("离线", "Offline") + "</span>") +
"</span></div>" +
'<div class="kv-row"><span class="key">' +
__("授权", "Authorized") +
'</span><span class="val" style="display:flex;align-items:center;gap:8px">' +
'<label class="switch"><input type="checkbox" ' +
(sAuth ? "checked" : "") +
" onchange=\"deviceToggleAuth('" +
state.selfDeviceId +
"',this.checked)\"><span></span></label>" +
(sAuth
? '<span class="dot-green">' + __("已授权", "Yes") + "</span>"
: '<span class="dot-red">' + __("未授权", "No") + "</span>") +
"</span></div>";
} else {
selfHtml +=
'<p style="color:var(--text-muted)">' +
__(
"未接入设备网关。请在设置中配置「设备网关 (remotedevice)」连接后重启。",
"Not connected. Configure a Remote Device Gateway connection in Settings then restart.",
) +
"</p>";
}
selfHtml += "</div>";
var html =
selfHtml +
'<div class="card"><h2>' +
__("设备网关", "Device Gateway") +
" (" +
devs.length +
")" +
"</h2>" +
'<div style="margin-bottom:8px"><button class="btn btn-ghost btn-sm" onclick="deviceRefresh()">' +
__("刷新", "Refresh") +
"</button></div>";
if (devs.length === 0) {
html +=
'<p style="color:var(--text-muted)">' +
__(
"暂无设备接入。设备通过 WebSocket 连接到设备网关(默认 127.0.0.1:9890/api/v1/device/ws携带 token 后 hello 登记、bind 授权。",
"No devices yet. Devices connect via WebSocket (default 127.0.0.1:9890/api/v1/device/ws), hello to register, bind to authorize.",
) +
"</p>";
} else {
html +=
"<table><tr><th>" +
__("设备", "Device") +
"</th><th>" +
__("种类", "Kind") +
"</th><th>" +
__("状态", "Status") +
"</th><th>" +
__("授权", "Authorized") +
"</th><th>" +
__("能力", "Caps") +
"</th><th>" +
__("操作", "Actions") +
"</th></tr>";
devs.forEach((d) => {
var online = d.online
? '<span class="dot-green">' + __("在线", "Online") + "</span>"
: '<span class="dot-gray">' + __("离线", "Offline") + "</span>";
var auth = d.authorized
? '<span class="dot-green">' + __("已授权", "Yes") + "</span>"
: '<span class="dot-red">' + __("未授权", "No") + "</span>";
var caps = (d.caps || []).join(", ") || "-";
html +=
"<tr><td><b>" +
escHtml(d.name || d.device_id) +
'</b><br><span style="font-size:11px;color:var(--text-muted)">' +
escHtml(d.device_id) +
"</span></td><td>" +
escHtml(d.kind || "-") +
"</td><td>" +
online +
"</td><td>" +
auth +
"</td><td>" +
escHtml(caps) +
"</td><td>" +
(d.authorized
? '<label class="switch"><input type="checkbox" checked' +
" onchange=\"deviceToggleAuth('" +
d.device_id +
"',this.checked)\"><span></span></label> " +
__("已授权", "Yes")
: '<label class="switch"><input type="checkbox"' +
" onchange=\"deviceToggleAuth('" +
d.device_id +
"',this.checked)\"><span></span></label> " +
__("未授权", "No")) +
"</td></tr>";
});
html += "</table>";
}
html += "</div>";
el.innerHTML = html;
}
async function deviceRefresh() {
try {
var d = await api("/device");
state.devices = d.devices || [];
renderDevices();
} catch (e) {
toast(__("设备列表刷新失败: ", "Refresh failed: ") + e.message, true);
}
}
async function deviceToggleAuth(deviceID, auth) {
try {
await api("/device/auth", {
method: "POST",
body: JSON.stringify({ device_id: deviceID, authorize: auth }),
});
toast(__("授权已更新", "Authorization updated"));
deviceRefresh();
} catch (e) {
toast(__("授权失败: ", "Auth failed: ") + e.message, true);
}
}
function deviceSendCmd(deviceID) {
var cmd = prompt(__("输入要执行的命令", "Enter command to run"));
if (!cmd) return;
api("/device/push", {
method: "POST",
body: JSON.stringify({
device_id: deviceID,
payload: { op: "cmd", command: cmd },
}),
})
.then(() => {
toast(__("命令已下发", "Command sent"));
deviceRefresh();
})
.catch((e) => {
toast(__("下发失败: ", "Send failed: ") + e.message, true);
});
}

View File

@ -1,88 +1,315 @@
<!DOCTYPE html>
<!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="icon" type="image/svg+xml" href="icon.svg">
<link rel="stylesheet" href="style.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js" onerror="window._THREE_FAILED=true"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js" onerror="window._THREE_FAILED=true"></script>
<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>
</head>
<body>
<div id="bg-layer"></div>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>HomeAgent</title>
<link rel="icon" type="image/svg+xml" href="icon.svg" />
<link rel="stylesheet" href="style.css" />
<script
src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"
onerror="window._THREE_FAILED = true"
></script>
<script
src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"
onerror="window._THREE_FAILED = true"
></script>
<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>
</head>
<body>
<div id="bg-layer"></div>
<div class="titlebar" id="titlebar">
<div class="titlebar-controls">
<button class="tb-btn" id="tb-min" title="最小化" onclick="window.homeagent&&homeagent.win.minimize()">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><line x1="4" y1="12" x2="20" y2="12"/></svg>
</button>
<button class="tb-btn" id="tb-max" title="最化" onclick="window.homeagent&&homeagent.win.toggleMaximize()">
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><rect x="5" y="5" width="14" height="14" rx="1.5"/></svg>
</button>
<button class="tb-btn tb-close" id="tb-close" title="关闭" onclick="window.homeagent&&homeagent.win.close()">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg>
</button>
</div>
</div>
<div id="app">
<!-- 左侧图标栏:主页=对话,其余为二级页面 -->
<aside class="rail" id="rail">
<div class="rail-logo"><img src="icon.svg" alt="HomeAgent"></div>
<button class="rail-btn active" id="rail-chat" onclick="switchView('chat')" title="对话">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg>
</button>
<button class="rail-btn" id="rail-overview" onclick="switchView('overview')" title="概览">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
</button>
<button class="rail-btn" id="rail-plugins" onclick="switchView('plugins')" title="插件">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
</button>
<button class="rail-btn" id="rail-adapters" onclick="switchView('adapters')" title="适配器">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg>
</button>
<button class="rail-btn" id="rail-kernel" onclick="switchView('kernel')" title="内核">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><path d="M9 1v4M15 1v4M9 19v4M15 19v4M1 9h4M1 15h4M19 9h4M19 15h4"/></svg>
</button>
<div class="rail-spacer"></div>
<button class="rail-btn" id="rail-settings" onclick="switchView('settings')" title="设置">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
</button>
<button class="rail-btn" onclick="toggleTheme()" title="切换亮色/暗色模式" id="theme-btn"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg></button>
<div class="palette-pop" id="palette-pop" title="外观"></div>
<button class="rail-btn" onclick="toggleAppearance()" id="palette-btn" title="外观:主题色 / 背景图"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22a10 10 0 1 1 10-10c0 2.2-1.8 4-4 4h-2a3 3 0 0 0-3 3c0 1.1.9 2 2 2 1.4 0 2.5-1.1 3.6-2.1C19.3 18.5 20 20 21.5 20.5A1 1 0 0 0 22 19c0-4-3.6-7-10-7-5.5 0-9 3.8-9 9z"/><circle cx="7.5" cy="10.5" r="1"/><circle cx="12" cy="7.5" r="1"/><circle cx="16.5" cy="10.5" r="1"/></svg></button>
<span class="conn-dot dot-gray" id="rail-conn-dot" title="连接状态"></span>
</aside>
<div class="main">
<div class="topbar">
<h1>HomeAgent</h1>
<span class="conn-indicator" id="conn-indicator" onclick="goSettingsConn()" title="点击管理后端连接">
<span class="status-dot dot-gray" id="conn-dot"></span>
<span id="conn-name-display">未连接</span>
<span style="font-size:10px;margin-left:4px;opacity:.6"></span>
</span>
<div class="spacer"></div>
<button class="lang-btn" onclick="toggleLang()" id="lang-btn">EN</button>
<div class="titlebar" id="titlebar">
<div class="titlebar-controls">
<button
class="tb-btn"
id="tb-min"
title="最化"
onclick="window.homeagent && homeagent.win.minimize()"
>
<svg
viewBox="0 0 24 24"
width="14"
height="14"
fill="none"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
>
<line x1="4" y1="12" x2="20" y2="12" />
</svg>
</button>
<button
class="tb-btn"
id="tb-max"
title="最大化"
onclick="window.homeagent && homeagent.win.toggleMaximize()"
>
<svg
viewBox="0 0 24 24"
width="13"
height="13"
fill="none"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
>
<rect x="5" y="5" width="14" height="14" rx="1.5" />
</svg>
</button>
<button
class="tb-btn tb-close"
id="tb-close"
title="关闭"
onclick="window.homeagent && homeagent.win.close()"
>
<svg
viewBox="0 0 24 24"
width="14"
height="14"
fill="none"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
>
<path d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
</div>
</div>
<div class="container">
<div id="view-chat" class="view active"></div>
<div id="view-overview" class="view"></div>
<div id="view-plugins" class="view"></div>
<div id="view-adapters" class="view"></div>
<div id="view-kernel" class="view"></div>
<div id="view-settings" class="view"></div>
<div id="app">
<!-- 左侧图标栏:主页=对话,其余为二级页面 -->
<aside class="rail" id="rail">
<div class="rail-logo"><img src="icon.svg" alt="HomeAgent" /></div>
<button
class="rail-btn active"
id="rail-chat"
onclick="switchView('chat')"
title="对话"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"
/>
</svg>
</button>
<button
class="rail-btn"
id="rail-overview"
onclick="switchView('overview')"
title="概览"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
</svg>
</button>
<button
class="rail-btn"
id="rail-plugins"
onclick="switchView('plugins')"
title="插件"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
</svg>
</button>
<button
class="rail-btn"
id="rail-adapters"
onclick="switchView('adapters')"
title="适配器"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"
/>
</svg>
</button>
<button
class="rail-btn"
id="rail-kernel"
onclick="switchView('kernel')"
title="内核"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="4" y="4" width="16" height="16" rx="2" />
<rect x="9" y="9" width="6" height="6" />
<path
d="M9 1v4M15 1v4M9 19v4M15 19v4M1 9h4M1 15h4M19 9h4M19 15h4"
/>
</svg>
</button>
<button
class="rail-btn"
id="rail-devices"
onclick="switchView('devices')"
title="设备"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="7" cy="7" r="3" />
<circle cx="17" cy="17" r="3" />
<circle cx="17" cy="7" r="3" />
<path d="M7 7h7a3 3 0 0 1 3 3v4" />
</svg>
</button>
<div class="rail-spacer"></div>
<button
class="rail-btn"
id="rail-settings"
onclick="switchView('settings')"
title="设置"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="3" />
<path
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"
/>
</svg>
</button>
<button
class="rail-btn"
onclick="toggleTheme()"
title="切换亮色/暗色模式"
id="theme-btn"
>
<svg
viewBox="0 0 24 24"
width="15"
height="15"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
>
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" />
</svg>
</button>
<div class="palette-pop" id="palette-pop" title="外观"></div>
<button
class="rail-btn"
onclick="toggleAppearance()"
id="palette-btn"
title="外观:主题色 / 背景图"
>
<svg
viewBox="0 0 24 24"
width="15"
height="15"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M12 22a10 10 0 1 1 10-10c0 2.2-1.8 4-4 4h-2a3 3 0 0 0-3 3c0 1.1.9 2 2 2 1.4 0 2.5-1.1 3.6-2.1C19.3 18.5 20 20 21.5 20.5A1 1 0 0 0 22 19c0-4-3.6-7-10-7-5.5 0-9 3.8-9 9z"
/>
<circle cx="7.5" cy="10.5" r="1" />
<circle cx="12" cy="7.5" r="1" />
<circle cx="16.5" cy="10.5" r="1" />
</svg>
</button>
<span
class="conn-dot dot-gray"
id="rail-conn-dot"
title="连接状态"
></span>
</aside>
<div class="main">
<div class="topbar">
<h1>HomeAgent</h1>
<span
class="conn-indicator"
id="conn-indicator"
onclick="goSettingsConn()"
title="点击管理后端连接"
>
<span class="status-dot dot-gray" id="conn-dot"></span>
<span id="conn-name-display">未连接</span>
<span style="font-size: 10px; margin-left: 4px; opacity: 0.6"
></span
>
</span>
<div class="spacer"></div>
<button class="lang-btn" onclick="toggleLang()" id="lang-btn">
EN
</button>
</div>
<div class="container">
<div id="view-chat" class="view active"></div>
<div id="view-overview" class="view"></div>
<div id="view-plugins" class="view"></div>
<div id="view-adapters" class="view"></div>
<div id="view-kernel" class="view"></div>
<div id="view-devices" class="view"></div>
<div id="view-settings" class="view"></div>
</div>
</div>
</div>
</div>
</div>
<div id="toast" class="toast"></div>
<div id="confirm-overlay" class="confirm-overlay"></div>
<div id="toast" class="toast"></div>
<div id="confirm-overlay" class="confirm-overlay"></div>
<script src="app.js"></script>
</body>
<script src="app.js"></script>
</body>
</html>

View File

@ -286,6 +286,7 @@ body {
color: var(--text-primary);
min-height: 100vh;
overflow-x: hidden;
font-family: var(--font-sans);
transition:
background 0.2s,
color 0.2s;
@ -590,7 +591,9 @@ body.maximized .tb-max svg {
margin: 3px;
padding: 0;
vertical-align: middle;
transition: transform 0.15s, box-shadow 0.15s;
transition:
transform 0.15s,
box-shadow 0.15s;
}
.palette-pop button.cdot:hover {
transform: scale(1.25);
@ -1249,8 +1252,12 @@ code {
opacity: 0.6;
}
@keyframes reasoningSweep {
from { background-position: 200% 0; }
to { background-position: -200% 0; }
from {
background-position: 200% 0;
}
to {
background-position: -200% 0;
}
}
/* ===== 工具卡片PiDeck 风格,扁平不套气泡)===== */
@ -1264,7 +1271,9 @@ code {
border: 1px solid var(--kv-border);
border-left: 3px solid var(--accent);
cursor: pointer;
transition: border-color 0.2s var(--ease-out), background 0.2s var(--ease-out);
transition:
border-color 0.2s var(--ease-out),
background 0.2s var(--ease-out);
}
.tool-card.tc-running {
border-left-color: var(--accent);
@ -2071,3 +2080,95 @@ code {
gap: 4px;
}
}
/* ===== Toggle switch左右滑动开关按钮===== */
.switch {
position: relative;
display: inline-block;
width: 44px;
height: 24px;
vertical-align: middle;
flex-shrink: 0;
cursor: pointer;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
position: absolute;
}
.switch span {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: #888;
border-radius: 24px;
transition: background 0.25s;
}
.switch span::before {
content: "";
position: absolute;
width: 18px;
height: 18px;
left: 3px;
top: 3px;
background: #fff;
border-radius: 50%;
transition: transform 0.25s;
}
.switch input:checked + span {
background: #34c759;
}
.switch input:checked + span::before {
transform: translateX(20px);
}
td .switch {
margin-right: 6px;
}
/* ===== 设备页样式修复 ===== */
/* 开关输入框彻底隐藏,避免浅蓝方框露出 */
.switch input {
-webkit-appearance: none;
appearance: none;
border: none;
outline: none;
background: transparent;
}
/* 设备列表表格圆角化(去方框感) */
.card table {
width: 100%;
border-collapse: collapse;
overflow: hidden;
border-radius: var(--radius-md);
font-size: 13px;
}
.card table th,
.card table td {
text-align: left;
padding: 8px 10px;
border-bottom: 1px solid var(--glass-border, rgba(128, 128, 128, 0.15));
vertical-align: middle;
}
.card table th {
font-weight: 600;
color: var(--text-muted, #888);
white-space: nowrap;
}
.card table tr:last-child td {
border-bottom: none;
}
.card table tr:hover td {
background: var(--glass-hover, rgba(128, 128, 128, 0.06));
}
/* kv-row 值区对齐(含开关) */
.kv-row .val {
display: flex;
align-items: center;
gap: 8px;
}
.kv-row .val .switch {
margin-right: 4px;
}

View File

@ -16,11 +16,13 @@ type Connection struct {
}
type Config struct {
Socket string `yaml:"socket"`
Remote string `yaml:"remote"`
APIKey string `yaml:"api_key"`
Default string `yaml:"default"`
Connections []Connection `yaml:"connections,omitempty"`
Socket string `yaml:"socket"`
Remote string `yaml:"remote"`
APIKey string `yaml:"api_key"`
Default string `yaml:"default"`
Connections []Connection `yaml:"connections,omitempty"`
DeviceGateway string `yaml:"device_gateway,omitempty"` // remotedevice 网关地址(如 127.0.0.1:9890
DeviceToken string `yaml:"device_token,omitempty"` // 设备接入 token
}
func (c *Config) Active() *Connection {

340
cmd/waiter/device.go Normal file
View File

@ -0,0 +1,340 @@
package main
import (
"bufio"
"context"
"crypto/rand"
"encoding/base64"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"net"
"os"
"os/exec"
"regexp"
"runtime"
"strings"
"sync"
"time"
)
type deviceBridge struct {
addr string
token string
deviceID string
conn net.Conn
mu sync.Mutex
stop chan struct{}
}
func newDeviceBridge(addr, token string) *deviceBridge {
return &deviceBridge{addr: addr, token: token, stop: make(chan struct{})}
}
func (b *deviceBridge) Start() error {
if b.addr == "" || b.token == "" {
return fmt.Errorf("device bridge: addr/token required")
}
host, port := b.addr, "9890"
if h, p, err := net.SplitHostPort(b.addr); err == nil {
host, port = h, p
} else if idx := strings.LastIndex(b.addr, ":"); idx >= 0 {
host = strings.TrimPrefix(b.addr[:idx], "http://")
port = b.addr[idx+1:]
}
hostname, _ := os.Hostname()
if hostname == "" {
hostname = "local"
}
b.deviceID = "waiter-" + sanitizeID(hostname)
conn, err := net.DialTimeout("tcp", net.JoinHostPort(host, port), 5*time.Second)
if err != nil {
return err
}
b.conn = conn
key := wsKey()
path := "/api/v1/device/ws?token=" + urlEscape(b.token)
var sb strings.Builder
sb.WriteString("GET " + path + " HTTP/1.1" + CRLF)
sb.WriteString("Host: " + host + ":" + port + CRLF)
sb.WriteString("Upgrade: websocket" + CRLF)
sb.WriteString("Connection: Upgrade" + CRLF)
sb.WriteString("Sec-WebSocket-Key: " + key + CRLF)
sb.WriteString("Sec-WebSocket-Version: 13" + CRLF + CRLF)
if _, err := conn.Write([]byte(sb.String())); err != nil {
conn.Close()
return err
}
br := bufio.NewReader(conn)
headerBuf := ""
for {
line, err := br.ReadString('\n')
if err != nil {
conn.Close()
return err
}
headerBuf += line
if strings.Contains(headerBuf, CRLF+CRLF) {
break
}
}
if !strings.Contains(headerBuf, " 101 ") {
conn.Close()
return fmt.Errorf("ws upgrade failed: %s", firstLine(headerBuf))
}
b.sendJSON(helloMsg(b.deviceID))
b.sendJSON(map[string]interface{}{"op": "bind", "device_id": b.deviceID, "token": b.token})
go b.readLoop(br)
return nil
}
// CRLF 用常量避免转义地狱
const CRLF = "\r\n"
func helloMsg(deviceID string) map[string]interface{} {
hostname, _ := os.Hostname()
return map[string]interface{}{
"op": "hello",
"device": map[string]interface{}{
"device_id": deviceID,
"name": "HomeAgent CLI (waiter)",
"kind": "computer",
"caps": []string{"status", "cmdrun", "deviceinfo"},
"info": map[string]interface{}{
"hostname": hostname,
"platform": runtime.GOOS,
"arch": runtime.GOARCH,
"cpus": runtime.NumCPU(),
"mem_mb": memTotalMB(),
},
},
}
}
func memTotalMB() int64 {
if runtime.GOOS != "linux" {
return 0
}
data, err := os.ReadFile("/proc/meminfo")
if err != nil {
return 0
}
for _, line := range strings.Split(string(data), "\n") {
if strings.HasPrefix(line, "MemTotal:") {
f := strings.Fields(line)
if len(f) >= 2 {
var kb int64
fmt.Sscanf(f[1], "%d", &kb)
return kb / 1024
}
}
}
return 0
}
func (b *deviceBridge) sendJSON(v interface{}) {
payload, err := json.Marshal(v)
if err != nil {
return
}
b.mu.Lock()
defer b.mu.Unlock()
if b.conn == nil {
return
}
writeWSFrame(b.conn, 0x1, payload)
}
func writeWSFrame(conn net.Conn, opcode byte, payload []byte) {
length := len(payload)
hdr := []byte{0x80 | opcode}
switch {
case length < 126:
hdr = append(hdr, 0x80|byte(length))
case length <= 0xffff:
hdr = append(hdr, 0x80|126, 0, 0)
binary.BigEndian.PutUint16(hdr[len(hdr)-2:], uint16(length))
default:
hdr = append(hdr, 0x80|127)
hdr = append(hdr, 0, 0, 0, 0, 0, 0, 0, 0)
binary.BigEndian.PutUint64(hdr[len(hdr)-8:], uint64(length))
}
var mask [4]byte
rand.Read(mask[:])
masked := make([]byte, length)
for i := 0; i < length; i++ {
masked[i] = payload[i] ^ mask[i&3]
}
conn.Write(append(append(hdr, mask[:]...), masked...))
}
func (b *deviceBridge) readLoop(br *bufio.Reader) {
for {
select {
case <-b.stop:
return
default:
}
payload, err := readWSFrame(br)
if err != nil {
return
}
var msg map[string]interface{}
if err := json.Unmarshal(payload, &msg); err != nil {
continue
}
if msg["op"] != "cmd" {
continue
}
reqID, _ := msg["req_id"].(string)
command, _ := msg["command"].(string)
if reqID == "" {
continue
}
go b.runCommand(reqID, command)
}
}
func readWSFrame(br *bufio.Reader) ([]byte, error) {
b0, err := br.ReadByte()
if err != nil {
return nil, err
}
opcode := b0 & 0x0f
b1, err := br.ReadByte()
if err != nil {
return nil, err
}
length := uint64(b1 & 0x7f)
if length == 126 {
var ext [2]byte
if _, err := io.ReadFull(br, ext[:]); err != nil {
return nil, err
}
length = uint64(binary.BigEndian.Uint16(ext[:]))
} else if length == 127 {
var ext [8]byte
if _, err := io.ReadFull(br, ext[:]); err != nil {
return nil, err
}
length = binary.BigEndian.Uint64(ext[:])
}
if length > 1<<20 {
return nil, fmt.Errorf("frame too large")
}
payload := make([]byte, length)
if _, err := io.ReadFull(br, payload); err != nil {
return nil, err
}
switch opcode {
case 0x1:
return payload, nil
case 0x8:
return nil, io.EOF
default:
return nil, nil
}
}
var waiterAllowCmd = regexp.MustCompile("^(ls|pwd|whoami|uname|date|echo|uptime|hostname|cat|df|free|ps|ip|dir|node|python3?|npm|git|curl|wget|systeminfo|tasklist)\\b")
func (b *deviceBridge) runCommand(reqID, command string) {
res := b.execCommand(command)
res["op"] = "cmd_result"
res["req_id"] = reqID
res["device_id"] = b.deviceID
b.sendJSON(res)
}
func (b *deviceBridge) execCommand(cmdLine string) map[string]interface{} {
if strings.TrimSpace(cmdLine) == "" {
return map[string]interface{}{"status": "error", "output": "empty command", "exit_code": -1}
}
if !waiterAllowCmd.MatchString(strings.TrimSpace(cmdLine)) {
return map[string]interface{}{"status": "error", "output": "command not in whitelist", "exit_code": -1}
}
parts := strings.Fields(strings.TrimSpace(cmdLine))
if len(parts) == 0 {
return map[string]interface{}{"status": "error", "output": "empty", "exit_code": -1}
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, parts[0], parts[1:]...)
out, err := cmd.Output()
if err != nil {
if ctx.Err() != nil {
return map[string]interface{}{"status": "error", "output": "timeout", "exit_code": -2}
}
return map[string]interface{}{"status": "error", "output": truncate8k(string(out)) + " err: " + err.Error(), "exit_code": -1}
}
return map[string]interface{}{"status": "ok", "output": truncate8k(string(out)), "exit_code": 0}
}
const devMaxOut = 8192
func truncate8k(s string) string {
if len(s) <= devMaxOut {
return s
}
return s[:devMaxOut]
}
func firstLine(s string) string {
if i := strings.IndexByte(s, '\n'); i >= 0 {
return strings.TrimSpace(s[:i])
}
return strings.TrimSpace(s)
}
func sanitizeID(s string) string {
var sb strings.Builder
for _, r := range s {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
sb.WriteRune(r)
} else {
sb.WriteByte('_')
}
}
return sb.String()
}
func urlEscape(s string) string {
var sb strings.Builder
const hex = "0123456789ABCDEF"
for i := 0; i < len(s); i++ {
c := s[i]
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.' || c == '~' {
sb.WriteByte(c)
} else {
sb.WriteByte('%')
sb.WriteByte(hex[c>>4])
sb.WriteByte(hex[c&0xf])
}
}
return sb.String()
}
func wsKey() string {
var b [16]byte
rand.Read(b[:])
return base64.StdEncoding.EncodeToString(b[:])
}
func (b *deviceBridge) Stop() {
b.mu.Lock()
defer b.mu.Unlock()
select {
case <-b.stop:
default:
close(b.stop)
}
if b.conn != nil {
b.conn.Close()
b.conn = nil
}
}

View File

@ -81,6 +81,8 @@ func main() {
configPath := flag.String("config", "", "config file path")
chat := flag.String("chat", "", "send a message and print final text (one-shot)")
say := flag.String("say", "", "deprecated alias of -chat")
deviceGateway := flag.String("device", "", "remotedevice 网关地址(如 127.0.0.1:9890启动设备桥")
deviceToken := flag.String("device-token", "", "设备接入 token")
flag.Parse()
cfg := discoverConfig(*configPath)
@ -103,6 +105,26 @@ func main() {
}
defer state.Disconnect()
// 设备桥:--device 或配置 device_gateway 时waiter 作为被控设备接入 remotedevice
var bridge *deviceBridge
dg := *deviceGateway
if dg == "" {
dg = cfg.DeviceGateway
}
dt := *deviceToken
if dt == "" {
dt = cfg.DeviceToken
}
if dg != "" && dt != "" {
bridge = newDeviceBridge(dg, dt)
if err := bridge.Start(); err != nil {
printlnC(colorYellow, fmt.Sprintf("device bridge: %v (continue without)", err))
} else {
printlnC(colorGreen, "device bridge active: "+bridge.deviceID)
defer bridge.Stop()
}
}
if oneShotMsg != "" {
oneshot(state, oneShotMsg)
return
@ -236,6 +258,3 @@ func printServerOutput(content string) {
fmt.Printf("%s%s%s\n", clearLine, content, colorReset)
}
}

View File

@ -11,6 +11,7 @@ import (
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/healthcheck"
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/mcp"
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/pluginmgr"
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/remotedevice"
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/timer"
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/webui"
)

View File

@ -0,0 +1,262 @@
package remotedevice
import (
"fmt"
"sort"
"strings"
"time"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
)
// devicectlDevice 把设备网关暴露为 IOManager 的一个 Device
// Tools() 提供 devicedetect / device_ctl_status / device_ctl_cmdrun / device_ctl_cmdresult
// Execute() 检查授权并路由到 WS 在线设备。
type devicectlDevice struct {
reg *Registry
persist func() // 授权变更后持久化
}
func (d *devicectlDevice) Name() string { return "devicectl" }
func (d *devicectlDevice) Type() agentIO.DeviceType { return agentIO.DeviceIO }
func (d *devicectlDevice) OutputCapabilities() agentIO.OutputCapability { return agentIO.CapStructured }
func (d *devicectlDevice) Description() string {
return "远程设备控制网关:查看已接入/已授权的设备并发送控制指令(经用户授权的设备)"
}
func (d *devicectlDevice) ChannelDef() agentIO.ChannelDef { return agentIO.ChannelDef{} }
func (d *devicectlDevice) Start() error { return nil }
func (d *devicectlDevice) Stop() error { return nil }
func (d *devicectlDevice) Tools() []agentIO.ToolDef {
return []agentIO.ToolDef{
{
Name: "devicedetect",
Description: "扫描并列出已接入设备网关的设备(含在线/离线状态与授权状态)。" +
"用途:查看当前有哪些设备连接了 HomeAgent、是否在线、是否已授权。" +
"参数 kind 可选,只返回该种类的设备。返回值 device_id 用于后续 device_ctl_* 工具。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"kind": map[string]interface{}{"type": "string", "description": "按设备种类过滤light/camera/computer/phone/...),可省略"},
},
},
},
{
Name: "device_ctl_status",
Description: "查询一台已在线设备的实时状态。" +
"仅返回设备上报的状态信息(如电量/温度/运行状态)。" +
"需要 device_id来自 devicedetect。设备必须已授权且在线。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"device_id": map[string]interface{}{"type": "string", "description": "目标设备 ID"},
},
"required": []interface{}{"device_id"},
},
},
{
Name: "device_ctl_cmdrun",
Description: "向一台已授权且在线设备发送命令执行请求(如开机/重启/播放/自定义命令)。" +
"⚡ 高危:设备必须已授权,且该操作会改变设备行为。" +
"返回 accepted=true 表示已下发并等待设备执行,之后可用 device_ctl_cmdresult 查询结果。" +
"若设备未授权或离线,返回错误信息。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"device_id": map[string]interface{}{"type": "string", "description": "目标设备 ID"},
"command": map[string]interface{}{"type": "string", "description": "要执行的命令(设备自定义语义,如 power_on、play:xxx 或 shell 命令)"},
},
"required": []interface{}{"device_id", "command"},
},
},
{
Name: "device_ctl_cmdresult",
Description: "查询之前 device_ctl_cmdrun 下发命令的执行结果(按 req_id 或 device_id 最近一次)。" +
"若非阻塞或已超时,用此工具取回设备执行输出。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"req_id": map[string]interface{}{"type": "string", "description": "命令请求 IDdevice_ctl_cmdrun 返回)"},
"device_id": map[string]interface{}{"type": "string", "description": "设备 ID查询最近一次结果"},
},
},
},
{
Name: "deviceinfo",
Description: "探查一台设备接入网关时声明的详细信息与支持能力。" +
"返回设备的 OS/架构/CPU/内存/能力 caps 等(设备接入时上报,非实时)。" +
"需要 device_id来自 devicedetect。设备必须已授权。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"device_id": map[string]interface{}{"type": "string", "description": "目标设备 ID"},
},
"required": []interface{}{"device_id"},
},
},
}
}
func (d *devicectlDevice) Execute(tool string, args map[string]interface{}) (interface{}, error) {
switch tool {
case "devicedetect":
return d.detect(args)
case "device_ctl_status":
return d.status(args)
case "device_ctl_cmdrun":
return d.cmdrun(args)
case "device_ctl_cmdresult":
return d.cmdresult(args)
case "deviceinfo":
return d.info(args)
default:
return nil, fmt.Errorf("unknown device tool %s", tool)
}
}
// publicDevices 把设备列表转为简洁 JSON无内部字段
func publicDevices(in []DeviceMeta) []map[string]interface{} {
out := make([]map[string]interface{}, 0, len(in))
for _, m := range in {
entry := map[string]interface{}{
"device_id": m.DeviceID,
"name": m.Name,
"kind": m.Kind,
"caps": m.Caps,
"authorized": m.Authorized,
"online": m.Online,
"last_seen": m.LastSeen,
}
if len(m.Info) > 0 {
entry["info"] = m.Info
}
out = append(out, entry)
}
return out
}
// detect 实现 devicedetect。
func (d *devicectlDevice) detect(args map[string]interface{}) (interface{}, error) {
kind, _ := args["kind"].(string)
devs := d.reg.List()
if kind != "" {
var filtered []DeviceMeta
for _, m := range devs {
if strings.EqualFold(m.Kind, kind) {
filtered = append(filtered, m)
}
}
devs = filtered
}
sort.Slice(devs, func(i, j int) bool { return devs[i].DeviceID < devs[j].DeviceID })
if len(devs) == 0 {
return map[string]interface{}{"devices": []interface{}{}, "message": "暂无设备接入"}, nil
}
return map[string]interface{}{"devices": publicDevices(devs)}, nil
}
// status 实现 device_ctl_status只读查询设备最后一次上报状态
func (d *devicectlDevice) status(args map[string]interface{}) (interface{}, error) {
id, _ := args["device_id"].(string)
if id == "" {
return nil, fmt.Errorf("device_id required")
}
m, ok := d.reg.Get(id)
if !ok {
return nil, fmt.Errorf("device %s 不存在", id)
}
if !m.Authorized {
return nil, fmt.Errorf("device %s 未授权,无法查询状态(需先在设备管理页或经 bind 授权)", id)
}
if !m.Online {
return publicDevices([]DeviceMeta{m}), nil // 带 offline=true
}
return publicDevices([]DeviceMeta{m}), nil
}
// cmdrun 实现 device_ctl_cmdrun检查授权+在线push 命令,异步等待结果。
func (d *devicectlDevice) cmdrun(args map[string]interface{}) (interface{}, error) {
id, _ := args["device_id"].(string)
cmd, _ := args["command"].(string)
if id == "" || cmd == "" {
return nil, fmt.Errorf("device_id and command required")
}
m, ok := d.reg.Get(id)
if !ok {
return nil, fmt.Errorf("device %s 不存在", id)
}
if !m.Authorized {
return nil, fmt.Errorf("device %s 未授权,无法执行命令(请先在设备管理页授权)", id)
}
if !m.Online {
return nil, fmt.Errorf("device %s 不在线,无法执行命令", id)
}
reqID := newReqID()
if err := d.reg.PushCmd(id, reqID, cmd); err != nil {
return nil, fmt.Errorf("下发命令失败: %w", err)
}
// 阻塞等待设备结果(带超时);结果同时由 registry 留档。
res, err := d.reg.AwaitResult(reqID, 30*time.Second)
if err != nil {
d.reg.SaveResult(reqID, map[string]interface{}{"accepted": true, "error": err.Error(), "pending": true})
return map[string]interface{}{"accepted": true, "req_id": reqID, "pending": true, "note": "命令已下发但设备未在超时内回执,可用 device_ctl_cmdresult 再查"}, nil
}
out := res
out["req_id"] = reqID
d.reg.SaveResult(reqID, out)
return out, nil
}
// cmdresult 实现 device_ctl_cmdresult。
func (d *devicectlDevice) cmdresult(args map[string]interface{}) (interface{}, error) {
reqID, _ := args["req_id"].(string)
deviceID, _ := args["device_id"].(string)
if reqID != "" {
res, ok := d.reg.GetResult(reqID)
if !ok {
return nil, fmt.Errorf("no result for req %s设备可能尚未回执", reqID)
}
return res, nil
}
if deviceID != "" {
// 返回该设备最近一次结果(简化:遍历 results 找 device_id 匹配的最近一条)
// 说明:当前只按 req_id 查询device_id 查询留给后续迭代。
m, ok := d.reg.Get(deviceID)
if !ok {
return nil, fmt.Errorf("device %s 不存在", deviceID)
}
if !m.Authorized {
return nil, fmt.Errorf("device %s 未授权", deviceID)
}
return map[string]interface{}{"device_id": deviceID, "note": "请用 device_ctl_cmdrun 返回的 req_id 查询命令结果"}, nil
}
return nil, fmt.Errorf("req_id 或 device_id 至少提供一个")
}
// info 实现 deviceinfo返回设备详情 + 支持能力。
func (d *devicectlDevice) info(args map[string]interface{}) (interface{}, error) {
id, _ := args["device_id"].(string)
if id == "" {
return nil, fmt.Errorf("device_id required")
}
m, ok := d.reg.Get(id)
if !ok {
return nil, fmt.Errorf("device %s 不存在", id)
}
if !m.Authorized {
return nil, fmt.Errorf("device %s 未授权,无法探查信息(请先在设备管理页授权)", id)
}
out := map[string]interface{}{
"device_id": m.DeviceID,
"name": m.Name,
"kind": m.Kind,
"caps": m.Caps,
"authorized": m.Authorized,
"online": m.Online,
"last_seen": m.LastSeen,
}
if len(m.Info) > 0 {
out["info"] = m.Info
}
return out, nil
}

View File

@ -0,0 +1,260 @@
package remotedevice
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"log"
"net/http"
"strings"
"time"
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
func init() {
plugin.RegisterPluginMeta("remotedevice", "远程设备网关", "Remote Device")
plugin.RegisterFactory("remotedevice", func(name string, config map[string]interface{}) (sdk.Plugin, error) {
return New(name), nil
})
}
const defaultAddr = "127.0.0.1:9890"
// Plugin 是 remotedevice 设备接入网关插件:
// 持有 HTTP 服务WS 设备通道 + REST 管理面)与 devicectl Deviceagent 工具)。
type Plugin struct {
name string
registry *Registry
mux *http.ServeMux
server *http.Server
addr string
token string
sdk *sdk.PluginSDK
dev *devicectlDevice
}
func New(name string) *Plugin {
return &Plugin{
name: name,
registry: NewRegistry(),
mux: http.NewServeMux(),
}
}
func (p *Plugin) Name() string { return p.name }
func genToken() string {
buf := make([]byte, 16)
if _, err := rand.Read(buf); err != nil {
return fmt.Sprintf("tok-%d", time.Now().UnixNano())
}
return hex.EncodeToString(buf)
}
func newReqID() string {
buf := make([]byte, 8)
if _, err := rand.Read(buf); err != nil {
return fmt.Sprintf("req-%d", time.Now().UnixNano())
}
return hex.EncodeToString(buf)
}
func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.SetAutoRestart(true)
p.sdk = s
// ---- 设置 ----------------
s.Settings().RegisterDef(sdk.ConfigDef{Key: "listen_addr", Default: defaultAddr, Type: "string", DisplayName: "监听地址", Description: "设备网关 HTTP/WS 监听地址(默认 127.0.0.1:9890仅本机", Category: "remotedevice"})
s.Settings().RegisterDef(sdk.ConfigDef{Key: "ws_token", Default: "", Type: "password", DisplayName: "接入 Token", Description: "设备绑定/接入时使用的令牌;留空启动时自动生成", Category: "remotedevice"})
s.Settings().RegisterDef(sdk.ConfigDef{Key: "authorized_devices", Default: "", Type: "text", DisplayName: "已授权设备", Description: "逗号分隔的已授权设备 ID 列表(由系统维护)", Category: "remotedevice"})
p.addr = defaultAddr
if v, _ := s.Settings().Get("listen_addr"); v != nil {
if s2, ok := v.(string); ok && s2 != "" {
p.addr = s2
}
}
p.token = ""
if v, _ := s.Settings().Get("ws_token"); v != nil {
if s2, ok := v.(string); ok && s2 != "" {
p.token = s2
}
}
if p.token == "" {
p.token = genToken()
if err := s.Settings().Set("ws_token", p.token); err != nil {
log.Printf("[remotedevice] persist ws_token: %v", err)
}
}
p.registry.SetAcceptToken(func(provided string) bool {
return provided != "" && provided == p.token
})
// 恢复已授权设备集合
if v, _ := s.Settings().Get("authorized_devices"); v != nil {
if s2, ok := v.(string); ok && s2 != "" {
var ids []string
for _, id := range strings.Split(s2, ",") {
if id = strings.TrimSpace(id); id != "" {
ids = append(ids, id)
}
}
p.registry.RestoreAuthorized(ids)
}
}
// ---- devicectl Deviceagent 工具) ----------------
p.dev = &devicectlDevice{reg: p.registry, persist: p.persistAuthorized}
if err := s.RegisterChannel("devicectl", p.dev); err != nil {
log.Printf("[remotedevice] register devicectl channel: %v", err)
}
// ---- REST 管理面 + WS 设备通道 ----------------
p.registerRoutes()
p.server = &http.Server{Addr: p.addr, Handler: p.mux}
go func() {
log.Printf("[remotedevice] device gateway listening on %s", p.addr)
if err := p.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Printf("[remotedevice] server error: %v", err)
}
}()
return nil
}
// persistAuthorized 在授权变更后写回设置(持久化重启不丢)。
func (p *Plugin) persistAuthorized() {
if p.sdk == nil {
return
}
ids := p.registry.AuthorizedIDs()
_ = p.sdk.Settings().Set("authorized_devices", strings.Join(ids, ","))
}
func (p *Plugin) registerRoutes() {
// 设备通道WS
p.mux.HandleFunc("/api/v1/device/ws", p.registry.ServeWS)
// REST 管理面(全部需 token
p.mux.HandleFunc("/api/v1/device", p.requireToken(p.handleDeviceList))
p.mux.HandleFunc("/api/v1/device/online", p.requireToken(p.handleDeviceOnline))
p.mux.HandleFunc("/api/v1/device/", p.requireToken(p.handleDeviceByID))
p.mux.HandleFunc("/api/v1/device/push", p.requireToken(p.handleDevicePush))
p.mux.HandleFunc("/api/v1/device/auth", p.requireToken(p.handleDeviceAuth))
}
// requireToken 校验 REST 请求的接入令牌X-API-Key header 或 ?token=)。
func (p *Plugin) requireToken(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
k := r.Header.Get("X-API-Key")
if k == "" {
k = r.URL.Query().Get("token")
}
if k == "" || k != p.token {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
next(w, r)
}
}
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func (p *Plugin) handleDeviceList(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.NotFound(w, r)
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"devices": p.registry.List()})
}
func (p *Plugin) handleDeviceOnline(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.NotFound(w, r)
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"devices": p.registry.OnlineList()})
}
func (p *Plugin) handleDeviceByID(w http.ResponseWriter, r *http.Request) {
id := strings.TrimPrefix(r.URL.Path, "/api/v1/device/")
if id == "" || strings.Contains(id, "/") {
http.NotFound(w, r)
return
}
if r.Method == http.MethodGet {
m, ok := p.registry.Get(id)
if !ok {
writeJSON(w, http.StatusNotFound, map[string]interface{}{"error": "device not found"})
return
}
writeJSON(w, http.StatusOK, m)
return
}
http.NotFound(w, r)
}
func (p *Plugin) handleDevicePush(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.NotFound(w, r)
return
}
var req struct {
DeviceID string `json:"device_id"`
Payload map[string]interface{} `json:"payload"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
return
}
if req.DeviceID == "" {
writeJSON(w, http.StatusBadRequest, map[string]interface{}{"error": "device_id required"})
return
}
if err := p.registry.PushJSON(req.DeviceID, req.Payload); err != nil {
writeJSON(w, http.StatusNotFound, map[string]interface{}{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"status": "ok"})
}
func (p *Plugin) handleDeviceAuth(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.NotFound(w, r)
return
}
var req struct {
DeviceID string `json:"device_id"`
Authorize bool `json:"authorize"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
return
}
if req.DeviceID == "" {
writeJSON(w, http.StatusBadRequest, map[string]interface{}{"error": "device_id required"})
return
}
if _, ok := p.registry.Get(req.DeviceID); !ok {
writeJSON(w, http.StatusNotFound, map[string]interface{}{"error": "device not found"})
return
}
p.registry.SetAuthorized(req.DeviceID, req.Authorize)
p.persistAuthorized()
writeJSON(w, http.StatusOK, map[string]interface{}{"device_id": req.DeviceID, "authorized": req.Authorize})
}
func (p *Plugin) Stop() error {
if p.server != nil {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
return p.server.Shutdown(ctx)
}
return nil
}

View File

@ -0,0 +1,592 @@
package remotedevice
import (
"bufio"
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"log"
"net"
"net/http"
"strings"
"sync"
"time"
)
// DeviceMeta 描述一台接入了网关的设备。
type DeviceMeta struct {
DeviceID string `json:"device_id"`
Name string `json:"name"`
Kind string `json:"kind"`
Caps []string `json:"caps"`
Info map[string]interface{} `json:"info,omitempty"`
Authorized bool `json:"authorized"`
Online bool `json:"online"`
LastSeen int64 `json:"last_seen"`
RemoteAddr string `json:"remote_addr"`
}
// wconn 表示一条活跃的 WS 连接(由网关持有)。
type wconn struct {
deviceID string
w *bufio.Writer
}
// Registry 是设备接入网关的注册表:管理在线连接、设备元数据与已授权集合。线程安全。
type Registry struct {
mu sync.RWMutex
devices map[string]*DeviceMeta // deviceID -> meta在线/历史)
authorized map[string]bool // deviceID -> 是否已授权(持久化恢复)
conns map[string]*wconn // deviceID -> 活跃连接(支持 push
onlineCh chan string
onStatus func(msg map[string]interface{})
acceptFn func(token string) bool
cmdPending map[string]chan map[string]interface{} // reqID -> 结果 channel
results map[string]resultEntry // reqID -> 已留档结果
}
// resultEntry 保存一次 cmdrun 的结果(供 device_ctl_cmdresult 查询)。
type resultEntry struct {
Result map[string]interface{}
Time time.Time
}
func NewRegistry() *Registry {
return &Registry{
devices: make(map[string]*DeviceMeta),
authorized: make(map[string]bool),
conns: make(map[string]*wconn),
onlineCh: make(chan string, 16),
cmdPending: make(map[string]chan map[string]interface{}),
results: make(map[string]resultEntry),
}
}
// SetAcceptToken 设置绑定 token 校验函数(插件注入,来自 Settings
func (r *Registry) SetAcceptToken(fn func(token string) bool) {
r.mu.Lock()
defer r.mu.Unlock()
r.acceptFn = fn
}
// SetStatusHandler 注册设备上报状态的回调。
func (r *Registry) SetStatusHandler(h func(msg map[string]interface{})) {
r.mu.Lock()
defer r.mu.Unlock()
r.onStatus = h
}
func (r *Registry) acceptBind(token string) bool {
r.mu.RLock()
fn := r.acceptFn
r.mu.RUnlock()
if fn == nil {
return false
}
return fn(token)
}
// ============ 设备查询 ============
// Online 返回设备是否在线。
func (r *Registry) Online(id string) bool {
r.mu.RLock()
defer r.mu.RUnlock()
m, ok := r.devices[id]
return ok && m.Online
}
// Authorized 返回设备是否已授权。
func (r *Registry) Authorized(id string) bool {
r.mu.RLock()
defer r.mu.RUnlock()
return r.authorized[id]
}
// List 返回全部设备(在线或历史),合并授权态。
func (r *Registry) List() []DeviceMeta {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]DeviceMeta, 0, len(r.devices))
for _, m := range r.devices {
c := *m
c.Authorized = r.authorized[c.DeviceID]
out = append(out, c)
}
return out
}
// OnlineList 返回在线的设备列表。
func (r *Registry) OnlineList() []DeviceMeta {
r.mu.RLock()
defer r.mu.RUnlock()
var out []DeviceMeta
for _, m := range r.devices {
if m.Online {
c := *m
c.Authorized = r.authorized[c.DeviceID]
out = append(out, c)
}
}
return out
}
// Get 返回单个设备。
func (r *Registry) Get(id string) (DeviceMeta, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
m, ok := r.devices[id]
if !ok {
return DeviceMeta{}, false
}
c := *m
c.Authorized = r.authorized[id]
return c, true
}
// ============ 授权 ============
// SetAuthorized 标记某设备已授权/取消授权(持久化由插件负责)。
func (r *Registry) SetAuthorized(id string, auth bool) {
r.mu.Lock()
defer r.mu.Unlock()
r.authorized[id] = auth
if m, ok := r.devices[id]; ok {
m.Authorized = auth
}
}
// RestoreAuthorized 插件启动时从配置恢复已授权设备集合。
func (r *Registry) RestoreAuthorized(ids []string) {
r.mu.Lock()
defer r.mu.Unlock()
for _, id := range ids {
r.authorized[id] = true
}
}
// AuthorizedIDs 返回全部已授权设备 ID供插件持久化
func (r *Registry) AuthorizedIDs() []string {
r.mu.RLock()
defer r.mu.RUnlock()
var out []string
for id, ok := range r.authorized {
if ok {
out = append(out, id)
}
}
return out
}
// ============ 在线状态维护 ============
func (r *Registry) register(meta DeviceMeta) {
r.mu.Lock()
meta.Online = true
meta.LastSeen = time.Now().Unix()
meta.Authorized = r.authorized[meta.DeviceID]
r.devices[meta.DeviceID] = &meta
r.mu.Unlock()
r.notifyChange(meta.DeviceID)
}
func (r *Registry) markOffline(id string) {
r.mu.Lock()
if m, ok := r.devices[id]; ok {
m.Online = false
}
delete(r.conns, id)
r.mu.Unlock()
r.notifyChange(id)
}
func (r *Registry) notifyChange(id string) {
select {
case r.onlineCh <- id:
default:
}
}
// ChangeChan 返回设备上下线变更通知。
func (r *Registry) ChangeChan() <-chan string { return r.onlineCh }
// SaveResult 保存一次命令执行结果(供 cmdresult 查询)。
func (r *Registry) SaveResult(reqID string, res map[string]interface{}) {
r.mu.Lock()
defer r.mu.Unlock()
if r.results == nil {
r.results = make(map[string]resultEntry)
}
r.results[reqID] = resultEntry{Result: res, Time: time.Now()}
}
// GetResult 返回某次命令执行的结果。
func (r *Registry) GetResult(reqID string) (map[string]interface{}, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
e, ok := r.results[reqID]
if !ok {
return nil, false
}
return e.Result, true
}
// ============ Pushagent -> 设备) ============
// PushJSON 向在线设备推送一条 JSON 消息。
func (r *Registry) PushJSON(deviceID string, payload map[string]interface{}) error {
r.mu.RLock()
c, ok := r.conns[deviceID]
r.mu.RUnlock()
if !ok {
return fmt.Errorf("device %s not online", deviceID)
}
return writeText(c.w, mustJSON(payload))
}
// PushCmd 向设备发送命令执行请求。
func (r *Registry) PushCmd(deviceID, reqID, command string) error {
return r.PushJSON(deviceID, map[string]interface{}{
"op": "cmd",
"req_id": reqID,
"command": command,
})
}
// AwaitResult 等待某请求的结果(带超时)。
func (r *Registry) AwaitResult(reqID string, timeout time.Duration) (map[string]interface{}, error) {
ch := make(chan map[string]interface{}, 1)
r.mu.Lock()
r.cmdPending[reqID] = ch
r.mu.Unlock()
defer func() {
r.mu.Lock()
delete(r.cmdPending, reqID)
r.mu.Unlock()
}()
select {
case res := <-ch:
return res, nil
case <-time.After(timeout):
return nil, fmt.Errorf("timeout waiting for device result")
}
}
// deliverResult 设备回执结果时由 handleWS 调用。
func (r *Registry) deliverResult(reqID string, res map[string]interface{}) {
r.mu.RLock()
ch, ok := r.cmdPending[reqID]
r.mu.RUnlock()
if ok {
select {
case ch <- res:
default:
}
}
}
// ============ WS 网关(标准库 HijackerRFC6455 子集) ============
const wsGUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
func wsAccept(key string) string {
h := sha256.Sum256([]byte(key + wsGUID))
return base64.StdEncoding.EncodeToString(h[:])
}
func headerListHas(h, sub string) bool {
for _, part := range strings.Split(h, ",") {
if strings.EqualFold(strings.TrimSpace(part), sub) {
return true
}
}
return false
}
func httpUpgrade(w http.ResponseWriter, r *http.Request) (net.Conn, *bufio.ReadWriter, error) {
if !headerListHas(r.Header.Get("Upgrade"), "websocket") ||
!headerListHas(r.Header.Get("Connection"), "upgrade") {
return nil, nil, fmt.Errorf("not a websocket upgrade request")
}
key := r.Header.Get("Sec-WebSocket-Key")
if key == "" {
return nil, nil, fmt.Errorf("missing Sec-WebSocket-Key")
}
hj, ok := w.(http.Hijacker)
if !ok {
return nil, nil, fmt.Errorf("hijack not supported")
}
conn, rw, err := hj.Hijack()
if err != nil {
return nil, nil, err
}
accept := wsAccept(key)
resp := "HTTP/1.1 101 Switching Protocols" + "\x0d\x0a" +
"Upgrade: websocket\x0d\x0aConnection: Upgrade\x0d\x0a" +
"Sec-WebSocket-Accept: " + accept + "\x0d\x0a\x0d\x0a"
if _, err := rw.WriteString(resp); err != nil {
conn.Close()
return nil, nil, err
}
if err := rw.Flush(); err != nil {
conn.Close()
return nil, nil, err
}
return conn, rw, nil
}
func readFrame(r *bufio.Reader) ([]byte, bool, error) {
b0, err := r.ReadByte()
if err != nil {
return nil, true, err
}
opcode := b0 & 0x0f
b1, err := r.ReadByte()
if err != nil {
return nil, true, err
}
masked := b1&0x80 != 0
length := uint64(b1 & 0x7f)
if length == 126 {
var ext [2]byte
if _, err := io.ReadFull(r, ext[:]); err != nil {
return nil, true, err
}
length = uint64(binary.BigEndian.Uint16(ext[:]))
} else if length == 127 {
var ext [8]byte
if _, err := io.ReadFull(r, ext[:]); err != nil {
return nil, true, err
}
length = binary.BigEndian.Uint64(ext[:])
}
if length > 1<<20 {
return nil, true, fmt.Errorf("frame too large")
}
var maskKey [4]byte
if masked {
if _, err := io.ReadFull(r, maskKey[:]); err != nil {
return nil, true, err
}
}
payload := make([]byte, length)
if _, err := io.ReadFull(r, payload); err != nil {
return nil, true, err
}
if masked {
for i := range payload {
payload[i] ^= maskKey[i%4]
}
}
switch opcode {
case 0x1:
return payload, false, nil
case 0x8:
return nil, true, nil
case 0xa:
return nil, false, nil
case 0x9:
return nil, false, errPing
default:
return nil, false, fmt.Errorf("unsupported opcode %x", opcode)
}
}
var errPing = fmt.Errorf("ping")
func writeText(w *bufio.Writer, payload []byte) error {
if err := writeFrameHeader(w, 0x1, len(payload)); err != nil {
return err
}
if _, err := w.Write(payload); err != nil {
return err
}
return w.Flush()
}
func writePong(w *bufio.Writer) error {
return writeFrameHeader(w, 0xa, 0)
}
func writeFrameHeader(w *bufio.Writer, opcode byte, length int) error {
if err := w.WriteByte(0x80 | opcode); err != nil {
return err
}
if length < 126 {
if err := w.WriteByte(byte(length)); err != nil {
return err
}
} else if length <= 0xffff {
if err := w.WriteByte(126); err != nil {
return err
}
var ext [2]byte
binary.BigEndian.PutUint16(ext[:], uint16(length))
if _, err := w.Write(ext[:]); err != nil {
return err
}
} else {
if err := w.WriteByte(127); err != nil {
return err
}
var ext [8]byte
binary.BigEndian.PutUint64(ext[:], uint64(length))
if _, err := w.Write(ext[:]); err != nil {
return err
}
}
return nil
}
func mustJSON(v interface{}) []byte {
b, err := json.Marshal(v)
if err != nil {
return []byte("{}")
}
return b
}
// ServeWS 是 WS 端点的 HTTP handler认证 tokenquery 或 Sec-WebSocket-Protocol
// 升级后进入 handleWS。未带 token 也允许 hello登记设备bind 时校验。
func (r *Registry) ServeWS(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
token := req.URL.Query().Get("token")
if token == "" {
for _, p := range req.Header.Values("Sec-WebSocket-Protocol") {
if strings.HasPrefix(p, "homeagent.") {
token = strings.TrimPrefix(p, "homeagent.")
break
}
}
}
if token != "" && !r.acceptBind(token) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
conn, rw, err := httpUpgrade(w, req)
if err != nil {
http.Error(w, "upgrade failed: "+err.Error(), http.StatusBadRequest)
return
}
log.Printf("[remotedevice] ws connected from %s", conn.RemoteAddr())
go r.handleWS(conn, rw)
}
func (r *Registry) handleWS(conn net.Conn, rw *bufio.ReadWriter) {
defer conn.Close()
var curID string
defer func() {
if curID != "" {
r.markOffline(curID)
}
}()
for {
payload, isClose, err := readFrame(rw.Reader)
if err != nil {
if err == errPing {
if werr := writePong(rw.Writer); werr != nil {
return
}
continue
}
return
}
if isClose {
return
}
var msg map[string]interface{}
if err := json.Unmarshal(payload, &msg); err != nil {
continue
}
op, _ := msg["op"].(string)
switch op {
case "hello":
meta := metaFromMsg(msg)
if meta.DeviceID == "" {
continue
}
meta.RemoteAddr = conn.RemoteAddr().String()
curID = meta.DeviceID
r.register(meta)
r.mu.Lock()
r.conns[meta.DeviceID] = &wconn{deviceID: meta.DeviceID, w: rw.Writer}
r.mu.Unlock()
if err := writeText(rw.Writer, mustJSON(map[string]interface{}{
"op": "hello_ack",
"device": meta.DeviceID,
"online": true,
})); err != nil {
return
}
case "bind":
token, _ := msg["token"].(string)
if r.acceptBind(token) {
id, _ := msg["device_id"].(string)
if id != "" {
// 默认不授权bind 仅验证 token + 登记设备;授权完全由用户手动
// GUI 设备页 / REST /api/v1/device/auth控制绝不自动授权。
}
if err := writeText(rw.Writer, mustJSON(map[string]interface{}{"op": "bind_ack", "ok": true})); err != nil {
return
}
} else {
if err := writeText(rw.Writer, mustJSON(map[string]interface{}{"op": "bind_ack", "ok": false, "error": "bad token"})); err != nil {
return
}
}
case "status":
id, _ := msg["device_id"].(string)
r.mu.Lock()
if m, ok := r.devices[id]; ok {
m.LastSeen = time.Now().Unix()
}
r.mu.Unlock()
r.mu.RLock()
h := r.onStatus
r.mu.RUnlock()
if h != nil {
h(msg)
}
case "cmd_result":
reqID, _ := msg["req_id"].(string)
if reqID != "" {
r.deliverResult(reqID, msg)
}
}
}
}
func metaFromMsg(msg map[string]interface{}) DeviceMeta {
var meta DeviceMeta
if d, ok := msg["device"].(map[string]interface{}); ok {
if v, ok := d["device_id"].(string); ok {
meta.DeviceID = v
}
if v, ok := d["name"].(string); ok {
meta.Name = v
}
if v, ok := d["kind"].(string); ok {
meta.Kind = v
}
if caps, ok := d["caps"].([]interface{}); ok {
for _, c := range caps {
if s, ok := c.(string); ok {
meta.Caps = append(meta.Caps, s)
}
}
}
if info, ok := d["info"].(map[string]interface{}); ok {
if len(info) > 0 {
meta.Info = info
}
}
}
return meta
}

View File

@ -611,6 +611,8 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/kernel", h.requireAPI(h.handleKernel))
mux.HandleFunc("/api/v1/plugins", h.requireAPI(h.handlePlugins))
mux.HandleFunc("/api/v1/plugins/", h.requireAPI(h.handlePluginByID))
// 设备网关(可配置反代到 remotedevice默认禁用未启用时返回 404
mux.HandleFunc("/api/v1/device/", h.requireAPI(h.handleDeviceGatewayProxy))
mux.HandleFunc("/v1/chat/completions", h.requireAPI(h.handleOpenAICompletions))
mux.HandleFunc("/", h.requireWeb(h.handleStatic))
}
@ -1663,6 +1665,55 @@ func (h *Handler) handleTracker(w http.ResponseWriter, r *http.Request) {
}
}
// ======== Remote Device Gateway (proxied to remotedevice, opt-in) ========
// deviceGatewayEnabled / deviceGatewayAddr 由 webui 插件启动时从设置读取并注入。
// 默认禁用:用户显式配置 device_gateway_enabled=true 后,/api/v1/device/* 才会反代到
// remotedevice 插件self-contained避免与 remotedevice 耦合。
var (
deviceGatewayEnabled bool
deviceGatewayAddr string
deviceGatewayToken string
)
// handleDeviceGatewayProxy 将 /api/v1/device/* 反代到 remotedevice 内部 HTTP 服务。
// 鉴权:本端走 requireAPIwebui API key转发时带 remotedevice 的 tokenX-API-Key
func (h *Handler) handleDeviceGatewayProxy(w http.ResponseWriter, r *http.Request) {
if !deviceGatewayEnabled {
http.NotFound(w, r)
return
}
addr := deviceGatewayAddr
if addr == "" {
addr = "127.0.0.1:9890"
}
path := r.URL.Path // 保留 /api/v1/device/... 全路径
url := "http://" + addr + path
if r.URL.RawQuery != "" {
url += "?" + r.URL.RawQuery
}
req, err := http.NewRequestWithContext(r.Context(), r.Method, url, r.Body)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
req.Header = r.Header.Clone()
if deviceGatewayToken != "" {
req.Header.Set("X-API-Key", deviceGatewayToken)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "device gateway unreachable: " + err.Error()})
return
}
defer resp.Body.Close()
for k, v := range resp.Header {
w.Header()[k] = v
}
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body)
}
// ======== Plugin Management (proxied to pluginmgr HTTP API) ========
func (h *Handler) pluginmgrAddr() string {

View File

@ -704,7 +704,7 @@ func TestSettingsWithPluginRegistry(t *testing.T) {
type echoProvider struct{ name string }
func (p *echoProvider) Name() string { return p.name }
func (p *echoProvider) Name() string { return p.name }
func (p *echoProvider) MaxContextTokens() int { return 8192 }
func (p *echoProvider) Chat(ctx context.Context, req *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
content := "echo: " + lastUserContent(req.Messages)

View File

@ -95,7 +95,26 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
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: "session_ttl_hours", Default: "24", Type: "int", DisplayName: "会话时长(小时)", Description: "登录 cookie 有效时长", Category: "webui"})
s.Settings().RegisterDef(sdk.ConfigDef{Key: "device_gateway_enabled", Default: "false", Type: "bool", DisplayName: "设备网关反代", Description: "启用后 /api/v1/device/* 反代到 remotedevice 插件(默认关闭,避免硬耦合)", Category: "webui"})
s.Settings().RegisterDef(sdk.ConfigDef{Key: "device_gateway_addr", Default: "127.0.0.1:9890", Type: "string", DisplayName: "设备网关地址", Description: "remotedevice 插件的内部监听地址", Category: "webui"})
s.Settings().RegisterDef(sdk.ConfigDef{Key: "device_gateway_token", Default: "", Type: "password", DisplayName: "设备网关令牌", Description: "访问 remotedevice 的 token与 remotedevice 的 ws_token 一致)", Category: "webui"})
p.ensureAuthBootstrap(s)
// 注入设备网关反代配置(默认禁用;仅当用户开启时才挂载路由)
if v, _ := s.Settings().Get("device_gateway_enabled"); v != nil {
if s2, ok := v.(string); ok && s2 == "true" {
deviceGatewayEnabled = true
}
}
if v, _ := s.Settings().Get("device_gateway_addr"); v != nil {
if s2, ok := v.(string); ok && s2 != "" {
deviceGatewayAddr = s2
}
}
if v, _ := s.Settings().Get("device_gateway_token"); v != nil {
if s2, ok := v.(string); ok && s2 != "" {
deviceGatewayToken = s2
}
}
s.RegisterStage(sdk.StagePreAction, func(ctx *sdk.StageContext) error {
s.Publish(&sdk.Event{Type: sdk.EventStage, Payload: map[string]interface{}{"phase": "pre_action", "message": "thinking"}})

330
plan.md
View File

@ -22,7 +22,7 @@
**根因**`internal/plugins/healthcheck/plugin.go` 的三个"写入通道"自检**全部在真实生产存储上写入再删除**
| 函数 | 写入 | 清理 | 固化问题 |
|------|------|------|----------|
| ------ | ------ | ------ | ---------- |
| `testMemoryRaw` (:460) | `Memory().Commit(_hc_<ts> triple)` | `Purge(hard)` | GraphDB 空实体/AUTOINCREMENT id 膨胀 |
| `testKnowledgeRaw` (:485) | `Knowledge().Add(_hc_knowledge_test_<ts>)` | `Remove(marker)` | Add 异步 `go writeIndex` vs Remove 异步重写**竞态**`dirName`(用了 `/` 解析)与 `Remove``id=sanitize(name)` 计算**不一致**→ 删除可能失效 → **残留目录固化成文件** |
| `testDocStoreRaw` (:521) | `DocMemory().Insert(...)` | Query 后 Remove | 真实 docStore 写文件再删,抖动 |
@ -30,6 +30,7 @@
**原则**:健康检查验证的是"写入通道是否可用"**结果不应固化进生产记忆**。改为**独立虚拟/影子空间**或**不落盘验证**。
**实现**
- [x] **核心实现**SDK 新增 `VirtualInstance``internal/sdk/selftest.go`healthcheck 的三个 raw 自检改为在完全隔离的虚拟空间(`os.MkdirTemp` 独立图库/知识库/文档/文本)上做真实"写→查→删",绝不碰生产存储。
- [x] `PluginSDK.Selftest()/SelftestReset()` 暴露隔离实例(含 mutex 防并发),每轮自检前 `SelftestReset` 重建清空上轮数据。
- [x] **LLM 驱动自检防护**`collectToolDefsForLLM` 改为只收集**只读白名单**工具(`isSafeReadonlyTool`),写/删/改生产数据及外部副作用工具memory_commit/doc_commit/knowledge_create/cmd_run/files_write/terminal_*/output_send/spawn_child 等)一律不交给 LLM 自检,防止 LLM 乱调污染生产。
@ -75,7 +76,7 @@
## 核心问题清单
| # | 问题 | 影响 | 位置 |
|---|------|------|------|
| --- | ------ | ------ | ------ |
| 1 | GraphDB.Commit 对 relations **裸 INSERT 无去重** | 同一三元组每次归档无限重复,生产 5613 条 `文档→来源→context_archived` 重复垃圾 | `internal/memory/graph.go:209` |
| 2 | `docToTriples()` **对每篇冷文档永远生成固定模板三元组**`文档→来源→context_archived``文档→主题→{summary}` | 归档即写垃圾配合问题1指数级累积 | `internal/agent/core/distill.go:390-437` |
| 3 | `core.agent.distill_interval=2d` 配置 **静默失效**Go `time.ParseDuration` 不支持 `d` 单位),回退 30 分钟默认值 | archiveColdDocs **每小时跑**而非每 2 天,放大问题 1/2 | `internal/config/registry.go:698` |
@ -142,10 +143,12 @@
**背景**`readLoop``internal/plugins/agentcli/plugin.go:556-608`用写死常量 `NotifyOutputDelay = 500ms``plugin.go:24`**定时节流**只要终端持续输出 `git sparse clone` 进度就每 500ms 注入一条 `[终端 X 有新输出]` agent造成无限自喂送占满 eventLoop使 QQ 消息永远只能塞进回环通道且被 echo 上下文淹没
**原则(明确保留通知,不改中断机制)**
- **通知机制必须保留**——agent 需要感知"终端仍在运行可能有待读取的输出"否则会忘记终端的存在不知何时去 `terminal_read`
- 真正要改的是**写死的 500ms 定时节流**改为**基于输出语义/任务生命周期的通知策略**"多少输出一通知 / 命令执行结束再通知"由可配策略决定而非插件写死
**实现**
- [x] 通知从" 500ms 定时"改为**按终端生命周期事件触发**
- **进程结束 / 超时 / 读取错误 立即通知**已存在 + 增强 EOF 即时触发)。
- **持续运行仅产出进度 低频"有新输出"通知**累计 `notify_bytes` 默认 2KB 未读字节或距上次通知 `notify_interval` 默认 2s两条件满足任一即触发而非每 500ms
@ -172,8 +175,8 @@
## 验收标准
| 指标 | 当前 | 目标 | 验收方式 |
|------|------|------|----------|
| Graph relations 重复率 | ~90% (5613/6225) | 0% | `SELECT count(*), count(DISTINCT source_id||target_id||relation_type) FROM relations` |
| ------ | ------ | ------ | ---------- |
| Graph relations 重复率 | ~90% (5613/6225) | 0% | `SELECT count(*), count(DISTINCT source_id | | target_id | | relation_type) FROM relations` |
| `context_archived` 关系残留 | 5613 | 0 | `grep` 关系表 |
| `distill_interval` 配置生效 | 失效(30m) | 2d | 日志 `heartbeat distill tick` 间隔 = 48h |
| Pipeline distiller 频率 | 7天一批 | 10min | 日志 `distilled N records` 10min |
@ -219,8 +222,9 @@ internal/memory/static_embedder.go # 量化/裁剪入口(可选)
> HomeAgent WebUI 为 Go 嵌入式 HTML/JS非 React改进方向**在原有技术栈内尽可能逼近设计原则**,重点提升信息架构、交互反馈、视觉层次。
### 设计系统适配Go 模板 + 原生 CSS/JS
| 维度 | NapCat 参考 | HomeAgent 适配策略 |
|------|-------------|-------------------|
| ------ | ------------- | ------------------- |
| **配色** | 樱花粉 `#FF7FAC` / 霜蓝 `#88C0D0` / 玫瑰红 `#F33B7C`粉色调中性色 | CSS 变量定义同色系/深色模式切换主色用于 CTA/聚焦态 |
| **字体** | Quicksand/Nunito 圆润无衬线 + JetBrains Mono | 引入 Google Fonts Quicksand + JetBrains Mono标题 -0.02em tracking |
| **间距** | 4px 基准单位卡片 p-4~6区块 gap-4/6 | CSS Grid/Flex 统一 4px 节奏卡片内边距 16/20/24px |
@ -231,16 +235,18 @@ internal/memory/static_embedder.go # 量化/裁剪入口(可选)
| **图标** | Lucide 2px stroke | 引入 Lucide 静态 SVG内联或同风格 iconfont |
### 信息架构重构(核心痛点)
| 现状 | 目标对标 NapCat Dashboard |
|------|-----------------------------|
| ------ | ----------------------------- |
| 单页面堆砌所有功能 | **左侧可折叠侧边栏**16rem 固定)→ 导航分组概览/记忆/工具/插件/配置/日志 |
| 无面包屑无状态反馈 | 顶部面包屑 + 悬停微动效关键操作 Toast 反馈右上角 |
| 表格/列表无视觉分组 | 卡片网格布局每卡片 = 一个功能模块(记忆统计/插件状态/工具调用/系统资源) |
| 无数据可视化 | Canvas 2D 绘制记忆增长趋势图CPU/内存环图工具调用热力图 |
### 交互体验对标
| 场景 | NapCat 做法 | HomeAgent 改进 |
|------|-------------|----------------|
| ------ | ------------- | ---------------- |
| 卡片悬停 | 3D 透视倾斜 + 光标跟随渐变光斑 | CSS `transform: perspective(1000px) rotateX/Y(±5deg)` + 伪元素光斑跟随鼠标 |
| 按钮点击 | 弹簧 scale + loading | `:active { transform: scale(0.97) }` + 内置 spinner |
| 页面切换 | Framer Motion fade-up+scale stagger | CSS `@keyframes fadeUpScale` + JS 交错延迟 50ms |
@ -248,6 +254,7 @@ internal/memory/static_embedder.go # 量化/裁剪入口(可选)
| 错误处理 | Toast 右上 + 破坏性操作确认弹窗 | 统一 `showToast(type, msg)` + `confirmDialog(action, onConfirm)` |
### 实施路线非阻塞Phase 8+
```
Phase 8.1: ✅ CSS 变量系统 + Glassmorphism 基础样式(浅/深色)— dashboard.html :root 重写 NapCat DNA tokenssakura/frost 色板、玻璃变量、阴影、圆角、字体、动效)
Phase 8.2: ✅ 布局重构 — 左侧 16rem 可折叠侧边栏 + 顶部面包屑 topbar + 卡片网格响应式toggleSidebar/switchTab 联动)
@ -258,9 +265,11 @@ Phase 8.6: ✅ 空状态/错误/确认弹窗统一组件库 — showToast(type,m
Phase 8.7: ✅ 无障碍/键盘导航/移动端适配 — :focus-visible ring、prefers-reduced-motion 全停动效、主题滚动条、移动端自动折叠侧边栏
Phase 8.8: ✅ 用户反馈迭代8.x 收尾)— ①健康检查 UI 去冗余(系统操作卡仅保留重载插件,健康检查卡自带右上角「运行」按钮+空态文案);②主题跟随系统(无手动偏好时用 prefers-color-scheme并监听系统实时切换③设置页内容列 max-width 860px 居中④emoji 清理(☰/☀️/🌙/⛔/🔧/🧠 → 内联 SVG/纯文本,聊天工具调用状态用语义色图标);⑤总览页重构为插件页式全宽单列卡片(移除看板娘大照片卡、移除不准确的记忆分布环图+运行时资源条形图runtime/memory 改 kv-row 精确数字展示kernel 页同化
```
> 实现均在 `internal/plugins/webui/dashboard.html`(纯 CSS + Vanilla JS无构建链`handler_test.go:641` 修复上游遗留断言失配(`api('/settings'` → `api("/settings"`)。
### 技术约束
- **保持 Go `html/template` + 内嵌静态资源** —— 不引入 Node/构建链
- 静态资源CSS/JS/字体/图标 `embed.FS` 内嵌二进制
- 复杂动效用纯 CSS + 极简 Vanilla JS无框架依赖
@ -277,4 +286,309 @@ Phase 8.8: ✅ 用户反馈迭代8.x 收尾)— ①健康检查 UI 去冗
---
*更新时间2026-08-11*
*生产实例`/home/newqqagent`systemd 托管二进制 `/usr/local/bin/homed` (v0.8.0, 2026-07-28 build)*
*生产实例:`/home/newqqagent`systemd 托管,二进制 `/usr/local/bin/homed` (v0.8.0, 2026-07-28 build)*
---
## 9. device_ctl 设备接入网关WebUI 监听 + devicedetect 扫描
### 设计意图备忘(对齐核心架构原则)
1. **插件即 Agent 的 AppIO 全在插件层** —— 设备接入是 IO 能力**必须全部收敛到 webui 插件**内核零 IO 原则
设备状态归 webui 插件管理agent 只经工具访问。**不触碰 CLI/waiter**CLI 本机自执行命令已有 `cmd` 插件反向操控 CLI 属重复造轮子不在本计划范围)。
2. **认知负荷最小 / 反提示词注入** —— 设备元数据dev_id/ip/status只出现在工具参数与返回值**绝不进 system prompt**
agent 只通过 `devicedetect` / `device_ctl_*` 工具按需查询避免设备名/IP 污染对话上下文
3. **工具语义自解释** —— `devicedetect` / `device_ctl_*` Description agent读完即知怎么用」,无需额外指令
4. **显式授权为唯一信任源** —— 任何 device_ctl 执行必须 **先授权、后执行**高危操作cmdrun/open**每次二次确认**授权确认响应中的 accept 字段)。授权状态持久化重启不丢
### 目标(需求澄清)
用户明确收敛为**只做 webui**webui 开监听端口作为设备接入网关提供 `devicedetect` 工具供 agent 扫描设备连接状态
CLI 本机自执行命令已有 cmd 插件不做反向操控 CLI
### 一、协议与接入webui 充当“设备注册网关”)
**背景**当前 webui 是纯 HTTP/SSE 服务`internal/plugins/webui``/api/v1/*` RESTwebui API key 认证+ 聊天 SSE设备接入需要一条**独立受控可被反向推送**的通道
**设计**新增 **JSON-over-WebSocket 监听**agent设备反向推送 + 设备agent 上报双向长连接复用 webui 插件
| # | 内容 | 位置 | 风险 |
| --- | ------ | ------ | ------ |
| A1 | 新增 WebSocket 端点 `/api/v1/device/ws`API key 认证仅连接期有效JSON 消息帧 | `internal/plugins/webui/handler.go` | WS 依赖库 `github.com/gorilla/websocket`判断是否已引入若无则用 `golang.org/x/net/websocket` 或静态协议 |
| A2 | 新增 REST`GET /api/v1/device/online`在线设备列表/状态)、`POST /api/v1/device/push`向已连接设备推 JSON | 同上 | |
| A3 | **设备能力声明**连接时设备上传 `{op:"hello", device:{name,kind,caps:[...]}}`webui 记录并登记在线 | 同上 | |
| A4 | **设备侧授权绑定**首次连接需 `{op:"bind", token}`token webui 设置页生成`device_gateway.token` 配置绑定成功后该设备进入已授权集合 | `plugin.go`(设置) + handler | |
### 二、Agent 工具devicedetect 等)
**设计**注册一个 `devicectl` 设备实现 `agentIO.Device` 接口`internal/plugins/webui` 内定义
`Tools()` 返回三工具`Execute()` 检查授权 + 路由到 WebSocket 在线设备
| 工具 | 语义 | 参数 | 返回值 |
| ------ | ------ | ------ | -------- |
| `devicedetect` | 扫描/列出已连接且已授权的设备 | `kind`(可选) | `[{device_id,name,kind,caps,online}]` |
| `device_ctl_status` | 查询单设备实时状态 | `device_id` | `{device,status,last_seen}` |
| `device_ctl_cmdrun` | 向设备发送命令执行请求**高危需授权+二次确认** | `device_id, command` | `{accepted:true, request_id}` 或拒绝 |
**注意**webui 插件属于内置插件注册 `devicectl` 设备走 `s.RegisterChannel("devicectl", dev)`IOManager 自动并入工具集`ExecuteTool` 自动可达)。设备 Execute **同步阻塞** cmdrun 是异步的——需维护 **pending 请求表request_id → chan**WS 收到设备结果后写回工具循环内超时返回
### 三、webui 前端:设备管理页
| # | 内容 | 位置 | 风险 |
| --- | ------ | ------ | ------ |
| F1 | 侧栏新增设备入口 + 页面设备列表在线/离线/已授权/未授权)、连接状态徽标 | `dashboard.html` | 纯前端 |
| F2 | 设备详情基本信息名称/种类/caps)、状态/历史授权/取消授权按钮`device_ctl_cmdrun` 命令输入+结果展示 | 同上 | |
| F3 | 设备接入引导显示 `device_gateway.token` + 接入方式说明WS URL + 绑定 token供外部设备复制 | 同上 | token 明文展示显示/隐藏」) |
### 四、安全与授权
| # | 内容 | 位置 | 风险 |
| --- | ------ | ------ | ------ |
| S1 | `device_gateway.token`启动时生成并持久化复用 `Settings()` 机制webui 插件设置页可显示/重置」) | `plugin.go` / handler | |
| S2 | **授权级别**只读status/detect无需确认**执行类cmdrun需二次确认**——工具返回 `{accepted:false, require_confirm:true}`agent 需再调 `device_ctl_confirm` 或经 webui 前端用户点击允许 | handler + 工具 | |
| S3 | **授权状态持久化**已授权设备集合存 webui 插件配置SQLite config.db复用 Settings重启不丢 | 同上 | |
| S4 | **WS 连接安全**连接即要求 API key`Sec-WebSocket-Protocol` query token每消息帧校验超时/断开自动清理在线表 | handler | |
### 五、实施顺序(本计划按此逐步 push每步可独立验证
| Phase | 内容 | 验证 |
| ------- | ------ | ------ |
| **P1** | 协议与接入新增 WS 端点 + hello/bind 握手 + 在线设备登记内存 | curl/WS 客户端连上 `GET /api/v1/device/online` 可见 |
| **P2** | REST `/api/v1/device/online``/api/v1/device/push`、(device_gateway.token 设置注册 | token 生成/持久化push 到在线设备收到 JSON |
| **P3** | Agent 工具`devicectl` Device + `devicedetect` / `device_ctl_status` / `device_ctl_cmdrun` | agent 工具面板可见三工具devicedetect 返回在线设备 |
| **P4** | 异步 cmdrunpending 请求表 + WS 结果回写 + 超时 | 模拟设备返回 cmdrun 结果agent 拿到 |
| **P5** | 授权bind 绑定 + token 校验 + 持久化已授权集合 + cmdrun 二次确认流 | 未绑定拒绝已绑定可查询cmdrun 需确认 |
| **P6** | webui 前端设备管理页F1F3 | 页面可见在线/授权/执行状态 |
| **P7** | 单测 + 文档handler/工具/授权单测README/架构文档补充 | `make test` 全绿 |
### 六、里程碑外延(明确不做)
- **不做 CLI/waiter 反向操控**本机命令已由 cmd 插件承担预期语义是设备本来就是远程的”)。
- 不做 QQ/微信等具体设备插件——设备按通用 WS 协议接入即可
- 单设备回调/事件推送的复杂路由多设备扇出订阅过滤留给后续迭代
### 七、回滚预案
- P1P5 均为新增代码/路由不触碰现有 webui 路由与 CLI socket既有功能完全不受影响
- WS 依赖库引入失败回退为**独立 TCP 监听**复用 cli 插件逐行 JSON 模式协议一致)——同样满足设备接入网关”。
- 所有改动先 `make build build-cli` + `make test` 后再部署生产回滚即替换旧 `homed` 二进制
> **更新2026-08-16**用户定案——device 接入独立为 `remotedevice` 插件webui 只做前端反代,如 `proxyToPluginmgr` 先例;不把 WS 监听直接长在 webui 插件上,避免 webui 过重)。本插件的所有实现细节沿用上述设计,落点全部移到 `internal/plugins/remotedevice/`
>
> - 设备网关WS 监听 + hello/bind/在线登记)→ remotedevice 自持监听端口(默认 127.0.0.1:9890配置 `listen_addr`
> - RESTonline/push→ remotedevice 自带 HTTP mux地址同上
> - Agent 工具devicectl Device + devicedetect/device_ctl_*)→ remotedevice 内 `s.RegisterChannel("devicectl", dev)`
> - 设置项listen_addr / ws token / 已授权集合)→ remotedevice 插件 Settingsconfig_remotedevice 表)
> - webui 前端设备页 → webui `/api/v1/device/*` **可选反代**到 remotedevice参照 `proxyToPluginmgr` 模式):**默认禁用**,用户配置 `device_gateway_enabled` / `device_gateway_addr` / `device_gateway_token` 后才挂载,避免硬耦合
> - 设备网关鉴权webui 层 API key`requireAPI`+ 网关层 remotedevice token`X-API-Key`)双鉴权
## 10. 实施remotedevice 独立插件(逐步推进)
### Phase 0插件骨架 + 设备注册表 + WS 网关(本轮)
### Phase 0插件骨架 + 设备注册表 + WS 网关 + devicectl 工具(已实施)
**已交付**
- `internal/plugins/remotedevice/` 独立插件`registry.go`设备注册表 + 标准库 WebSocket 网关 + push/await/结果留档)、`plugin.go`设置 + REST + HTTP 服务)、`device.go`devicectl Device + 四工具
- Agent 工具`devicedetect` / `device_ctl_status` / `device_ctl_cmdrun` / `device_ctl_cmdresult` `s.RegisterChannel("devicectl", dev)` 并入 IOManager 工具集
- 设备接入WS 端点 `/api/v1/device/ws`token 认证hello/bind/status/cmd_result 协议默认监听 127.0.0.1:9890
- REST 管理面`/api/v1/device`列表)、`/api/v1/device/online``/api/v1/device/{id}``/api/v1/device/push``/api/v1/device/auth`全部 token 鉴权
- 授权token 校验`ws_token`启动生成持久化+ 已授权集合持久化`authorized_devices`逗号分隔+ `RestoreAuthorized` 重启恢复
- webui 可配置反代新增 `device_gateway_enabled`(默认 false)/`device_gateway_addr`/`device_gateway_token` 设置仅启用时挂 `/api/v1/device/` 反代路由webui API key 鉴权 转发带 remotedevice token
- 装配`internal/plugins/all.go` 注册 remotedevice
**验证**`gofmt -w` + `go build ./...` exit:0
**待办(后续 Phase**
- [] 单测注册表/WS 握手/push/cmdrun/授权
- [] webui 前端设备管理页非必选可经 REST/CLI 使用
- [] 心跳/离线自动清理的周期 goroutine当前断开即清理
### Phase 0bGUI 连接 remotedevice已实施
**需求**本机有 GUIElectron为其添加连接 remotedevice 网关的逻辑可查看/授权/控制设备
**已交付**cmd/gui/
- **连接类型**表单新增 `device`设备网关 remotedevice类型`saveConnForm` device 分支url+apiKey=ws_token连接测试加 device 探活GET /api/v1/device X-API-Key
- **normalize 修复**`main.js normalizeConnections` 允许 `type==="device"`原来会强制改回 webui
- **侧栏设备入口** + `view-devices` 容器index.html
- **renderDevices()**设备列表名称/种类/在线/授权/能力)、空态引导、"执行命令"prompt 输入POST /device/push)、"授权/取消授权"POST /device/auth)、"刷新"
- `renderAll`/`refreshAll` 挂载 renderDevicesstate.devices
**验证**`node --check renderer/app.js` `node --check main.js` 全通过`go test ./internal/plugins/... ./internal/sdk/... ./internal/agent/...` 全绿remotedevice 端到端WS 设备接入 REST 列表/online/鉴权/push验证通过
**设备模拟全链路**验证实录
- WS /api/v1/device/ws?token=... hello_ack bind_ack 服务端 push {op:cmd, command} 设备回 cmd_result
- GET /api/v1/device 返回全部设备合并授权态GET /api/v1/device/online 只返回在线 token 401
- GUI "执行命令" /device/push 已验证下发到达设备
### Phase 0c端到端验收已完成
**环境**临时 homed-data /tmp/ha-dev+ Python WS 设备模拟 + Electron GUI 冒烟
**验证结果**
- `go build ./...` exit:0`go test ./internal/plugins/... ./internal/sdk/... ./internal/agent/...` 全绿
- remotedevice 网关监听 127.0.0.1:9890config_remotedevice listen_addr/ws_token/authorized_devices 持久化
- WS 设备接入全链路hello_ack bind_ack 服务端 push {op:cmd} 设备回 cmd_result
- RESTGET /api/v1/device含授权态合并)、/online仅在线)、 token 401
- GUI`node --check renderer/app.js` + `node --check main.js` 通过Electron 冒烟--disable-gpu稳定运行无 JS 错误headless 容器需禁 GPU
- 说明webui 反代默认禁用device_gateway_enabled=falseGUI 直连 remotedevice 网关不受影响
**遗留/后续**
- [ ] remotedevice 单测registry/WS 握手/push/授权
- [ ] agent 真实工具调用验证 LLM key本环境无
- [ ] webui 设备管理页可选GUI 已覆盖
- [ ] cmdrun 二次确认流当前为已授权即下发
### Phase 0d真实 LLM 驱动工具调用测试(完成 ✅)
**环境**本机 LLM 网关 `http://127.0.0.1:8080/v1`deepseek-v4-flash-freeBearer key+ 临时 homed + Python WS 模拟设备living-light 客厅灯已授权在线)。
**配置**`core.llm.base_url/api_key/model=AUTO/adapter=openai` + `core.llm.sources.default.*`重启后 `model=AUTO base=http://127.0.0.1:8080/v1 sources=2`
**测试1devicedetect扫描设备**
> 用户指令:"请扫描一下当前有哪些设备在线用devicedetect工具"
> Agent 真实调用 devicedetect → 返回客厅智能灯living-light类型/状态/授权/能力/最近在线时间 ✓7.8s
**测试2device_ctl_cmdrun反向操控设备—— 决定性验证**
> 用户指令:"用device_ctl_cmdrun让客厅灯living-light执行 turn_on 命令打开灯"
> 设备模拟日志:`PUSHED: {command: turn_on, op: cmd, req_id: f00dd0596ffeee2d}` → 设备回 `cmd_result`
> Agent 回复:"开灯指令已成功下发执行status: ok请求 ID f00dd0596ffeee2d" ✓16.6s
**结论**HomeAgent LLM 决策 devicectl 工具 WS push 设备执行 cmd_result 回执 agent 汇报 全链路真实跑通"agent 反向操控设备"核心能力已验证
**清理**已终止临时 homed / 模拟设备 / 临时文件代码改动与 plan.md 保留
### Phase 0eGUI 作为设备接入 + 托盘驻留 + 偏好设置 + 聊天卡顿修复(已实施)
**设备桥GUI 作为设备)**
- `cmd/gui/main.js` 新增设备桥GUI `device_id: gui-<hostname>` 接入 remotedevice WShello/bind收到 `{op:cmd}` `spawn` 本机执行白名单命令 + 15s 限时 + 8KB 截断 `cmd_result`
- whenReady 时若存在 `type="device"` 连接则自动启动设备桥before-quit 停设备桥
**托盘驻留**
- `initTray()`nativeImage icon + 菜单显示主界面/退出+ 双击显示whenReady 调用
- `window-all-closed` `exitToTray` 偏好true 则隐藏驻留后台维持设备桥false quit
- `window:close` handler 改为 exitToTray hide修复了托盘块重复注册崩溃
**偏好设置prefs:get/set + 设置页 UI**
- `gui-prefs.json`userData `autoLaunch`开机自启Electron `app.setLoginItemSettings` + openAsHidden/ `silentStart`静默启动createWindow hide/ `exitToTray`退出进托盘默认 true
- preload 暴露 `homeagent.prefs`设置页客户端偏好卡片三个开关
**聊天卡顿/输入框卡死修复**
- 根因webui 连接 sendChat POST `/chat`同步等 60s 完整结果 SSE 流式agent_output 增量**双通道重复**回复长时反复全量 innerHTML 重建 + marked.parse 占满主线程 UI 卡死输入冻结
- 修复webui **触发式 POST**15s 短超时确认受理回复靠 SSE 流式增量渲染cli/device SSE 保持同步等完整结果
- `connectSSE` 跳过 device设备网关无 chat/eventssendChat device 连接提示不支持聊天
- 流式增量渲染`renderChatStreamChunk` 90ms 防抖 + 200字符/300ms 节流 parse保留输入框 DOM 不被重建
**验证**node --check main.js/preload.js/app.js 全通过Electron 冒烟--disable-gpu --in-process-gpu稳定运行无 JS 错误go build ./... exit:0go test plugins 全绿
**待后续**screenuseGUI 拉起窗口显示信息规划中cmdrun 二次确认流remotedevice 单测
### Phase 0fdeviceinfo 工具 + GUI 被控端能力声明(已实施并真实验证)
**deviceinfo 工具agent 探查设备详情+能力)**
- `internal/plugins/remotedevice/device.go`新增第五个工具 `deviceinfo`参数 device_id返回设备接入时声明的 infohostname/platform/arch/cpus/mem/版本+ caps 能力列表需已授权
- `registry.go``DeviceMeta` 新增 `Info map[string]interface{}``metaFromMsg` 解析 hello `device.info``publicDevices` 输出带 info
**GUI 被控端能力**
- `cmd/gui/main.js` 设备桥 hello 声明 `caps: ["status","cmdrun","deviceinfo","cmdresult"]` + `info`本机 hostname/platform/arch/cpus/totalmem/node/electron 版本
**真实验证(模拟 GUI 设备 + 本机 LLM 网关)**
> 用户指令:"用deviceinfo探查 gui-testhost 这台设备的信息和它支持什么能力"
> Agent 调 deviceinfo → 返回基本信息device_id/名称/类型/平台/主机名/在线授权、硬件8核/16G/Node v22/Electron 33、能力status/cmdrun/deviceinfo11.7s
**遗留**screenuseGUI 拉起窗口显示规划中cmdrun 二次确认流remotedevice 单测
### Phase 0gwaiter CLI 设备桥(已实施并端到端验证)
**实现**cmd/waiter/
- `device.go``deviceBridge` Go 标准库 WS 客户端)——握手//hello/bind/readLoop/execCommand白名单命令 + 15s 超时 + 8KB 截断cap 声明 status/cmdrun/deviceinfoinfo 上报 hostname/platform/arch/cpus/mem_mb
- `config.go`Config `device_gateway` + `device_token`waiter.yaml
- `main.go``--device` / `--device-token` 启动设备桥或读配置
**端到端验证(真实执行)**
- `waiter -device 127.0.0.1:9890 -device-token ...` `device bridge active: waiter-jianf-Station`
- remotedevice 登记waiter-jianf-Station / HomeAgent CLI (waiter) / computer / caps[status,cmdrun,deviceinfo] / info{arch:amd64,cpus:32,mem_mb:23129,hostname:jianf-Station} authorized online
- **device_ctl_cmdrun**"用device_ctl_cmdrun让 waiter-jianf-Station 执行 uname -a" agent 调工具 waiter 本机 exec 返回 `Linux jianf-Station 6.12.101+deb13-amd64 ... x86_64 GNU/Linux` ✓(9.2s
- **deviceinfo**"用deviceinfo查看 waiter-jianf-Station" 32 / 22.6GB / Linux amd64 / 三能力 ✓(11.3s
**修复的关键 Bug**9890 被残留进程占用导致新 homed remotedevice 监听失败acceptBind 用旧 token 401杀残留后恢复正常
**验证环境**临时 homedLLM 网关+ 新编译 waiter全量 go build/test 通过仅剩 2 个与改动无关的既有 system 失败)。
### Phase 0h设备默认不授权用户手动授权已实施并真实验证
**需求**GUI waiter 设备默认不授权必须用户手动授权
**修复registry.go bind**
- bind 原无条件 `SetAuthorized(id, true)`每次重连/心跳都自动授权撤销被覆盖)→ 改为**仅验证 token + 登记设备绝不自动授权**
- 授权完全由用户手动控制GUI 本机卡片/设备页授权本机按钮 REST `/api/v1/device/auth` waiter 用户手动操作
- 新增设备接入即 `authorized:false`agent device_ctl_* 默认拒绝
**配套修复cmd/gui/main.js**
- 设备桥选连接优先 `currentId`否则最后一个 device 连接避免列表里旧连接旧 token 抢先 401
- 清理 connections.json 残留的旧 device 连接
**真实验证GUI 被控端 + 本机 LLM 网关)**
- GUI 接入后 `gui-jianf-Station authorized: **False**` ✅(默认不授权
- **手动授权后** agent `device_ctl_cmdrun echo AUTH_OK` 返回 AUTH_OK
- **撤销授权后** agent `device_ctl_cmdrun` 返回 "device gui-jianf-Station 未授权无法执行命令" ❌;本地 cmd_run 兜底成功本机能力合理
- agent 智能未授权时自动改用本地 cmd_run 并提示"去设备管理页授权"
**结论**GUI/waiter 被控设备默认不可被 agent 远程操控用户手动授权后才可撤销即时生效
### Phase 0iGUI 视觉修复(圆角 / 字体方框 / 托盘图标 / 授权开关融合)
**托盘图标**cmd/gui/
- 生成 `icon-tray.png`(22x22) + `icon-tray@2x.png`(44x44)ImageMagick icon.svg 转换
- `initTray` 平台化Linux PNGico/svg Linux 托盘不受支持)、Windows icoLinux resize 22x22 兜底
**窗口圆角**cmd/gui/
- `createWindow` `transparent: true` + `roundedCorners`透明帧圆角窗口
- CSS`body` 透明 + 18px 圆角`#app` 18px 圆角 + 背景背景移入容器内裁切
**设备页字体/方框修复**renderer/style.css
- table 10px 圆角 + collapse + hoverth 加粗kv-row .val flex 对齐
- `.switch input` `-webkit-appearance:none` + 背景透明消除 checkbox 浅蓝方框
**授权开关融合**renderer/app.js
- 本机授权改为 **kv-row 信息行内嵌滑动开关**key=授权val=开关+状态圆点与设备ID/网关/状态行同构,消除割裂
- 设备列表"执行命令"按钮替换为**授权开关**checked=已授权)
- 修复 onchange `JSON.stringify(id)` 双引号冲突 单引号转义 `deviceToggleAuth('id',this.checked)`
**验证**CDP 计算样式body/app 18px 圆角可见card 14pxtable 10pxswitch input 背景透明CDP 模拟点击授权开关 authorized true 持久化agent device_ctl_cmdrun 操控 GUI 成功
### Phase 0j白屏根因定位 + 逐步安全加回(已完成)
**白屏根因(二分定位确定)**新版 main.js 顶层 `const { Tray, Menu: ElectronMenu, nativeImage } = require("electron")` asar/无托盘环境加载异常)→ 导致 renderer 合成卡死窗口全灰白无绘制设备桥/新版 renderer 本身安全混合测试证明)。
**修复**托盘改**函数内惰性 require + try/catch 安全降级**失败不动托盘不影响窗口)。
**逐步加回验证**每步实测窗口字节 70KB 正常
- F3 main 主体 + 设备桥 + handlers + 新版 renderer可用基线
- Step1安全托盘惰性 require)— 显示正常
- Step2完整 prefsGUI_PREFS_FILE 持久化 / loadGuiPrefs / applyAutoLaunch / silentStart / 开机自启)— 显示正常
- Step3生命周期window-all-closed 退出进托盘依偏好before-quit 清理托盘/设备桥)— 显示正常
**最终版已安装**/opt/HomeAgentmd5 047f2f1bbe备份 /tmp/ha-app-step3-final.asar设备桥 / 设备页+授权开关 / 惰性托盘 / prefs 持久化 / 退出进托盘 / 字体/圆角renderer)。