diff --git a/internal/plugins/webui/dashboard.html b/internal/plugins/webui/dashboard.html index c5b354c..5e5d06e 100644 --- a/internal/plugins/webui/dashboard.html +++ b/internal/plugins/webui/dashboard.html @@ -1,1912 +1,4413 @@ - + - - - -HomeAgent Dashboard - - - - - - - - - -
-
-
-
-
-
-
-
-
- + + + + + + + +
+
+
+
+
+
+
+
+
+ - + async function removePlugin(name) { + if ( + !confirm( + __("确定卸载插件", "Are you sure to unload plugin") + + "「" + + name + + "」?", + ) + ) + return; + try { + var r = await api("/plugins/" + encodeURIComponent(name), { + method: "DELETE", + }); + toast(__("已卸载: ", "Unloaded: ") + (r.status || r.name)); + if (r.action === "reload_required") + toast( + __( + "已卸载,请点击「重载插件」生效", + 'Unloaded, click "Reload Plugins" to apply', + ), + false, + ); + loadInstalledPlugins(); + renderPlugins(); + } catch (e) { + toast(__("卸载失败: ", "Unload failed: ") + e.message, true); + } + } + + async function disablePlugin(name) { + if (name === "webui") { + var r = confirm( + __( + "禁用 WebUI 后将无法通过 URL:端口访问此管理面板,若要重新启用需要通过 CLI 命令 /plugin enable webui 恢复。\n\n确定要禁用吗?", + "Disabling WebUI will make this management panel inaccessible via URL:port. To re-enable, use CLI command /plugin enable webui.\n\nAre you sure?", + ), + ); + if (!r) return; + } + try { + var r = await api( + "/plugins/" + encodeURIComponent(name) + "/disable", + { method: "POST" }, + ); + toast(__("已禁用: ", "Disabled: ") + name); + state.kernel = await api("/kernel"); + var s = await api("/settings"); + state.disabledPlugins = s.disabled_plugins || []; + renderPlugins(); + } catch (e) { + toast(__("禁用失败: ", "Disable failed: ") + e.message, true); + } + } + + async function enablePlugin(name) { + try { + var r = await api( + "/plugins/" + encodeURIComponent(name) + "/enable", + { method: "POST" }, + ); + toast(__("已启用: ", "Enabled: ") + name); + state.kernel = await api("/kernel"); + var s = await api("/settings"); + state.disabledPlugins = s.disabled_plugins || []; + renderPlugins(); + } catch (e) { + toast(__("启用失败: ", "Enable failed: ") + e.message, true); + } + } + + async function reloadPlugins() { + try { + var r = await api("/plugins/reload", { method: "POST" }); + toast(__("插件已重载", "Plugins reloaded")); + state.kernel = await api("/kernel"); + renderPlugins(); + } catch (e) { + toast(__("重载失败: ", "Reload failed: ") + e.message, true); + } + } + + async function runHealthcheck() { + var panel = document.getElementById("health-panel"); + if (!panel) return; + panel.innerHTML = + '

' + + __("运行中...", "Running...") + + "

"; + try { + var r = await api("/kernel"); + var tools = r?.tools || []; + var healthTool = tools.find(function (t) { + return t.name === "healthcheck"; + }); + if (!healthTool) { + panel.innerHTML = + '

' + + __("healthcheck 工具未注册", "healthcheck tool not registered") + + "

"; + return; + } + panel.innerHTML = + '

' + + __( + "通过 Agent 对话触发 healthcheck...", + "Triggering healthcheck via Agent...", + ) + + "

"; + var chatR = await api("/chat", { + method: "POST", + body: JSON.stringify({ + message: __( + "请运行 healthcheck 工具进行全面健康检查并报告结果", + "Please run the healthcheck tool for a full system check and report the results", + ), + }), + }); + panel.innerHTML = + "
" + escHtml(JSON.stringify(chatR, null, 2)) + "
"; + } catch (e) { + panel.innerHTML = + '

' + + __("错误: ", "Error: ") + + escHtml(e.message) + + "

"; + toast( + __("健康检查失败: ", "Health check failed: ") + e.message, + true, + ); + } + } + + function renderHealthResult(r) { + if (!r || !r.checks) + return ( + '

' + + __("暂无健康检查数据", "No health check data") + + "

" + ); + var checks = r.checks || []; + var passed = checks.filter(function (c) { + return c.pass; + }).length; + var failed = checks.filter(function (c) { + return !c.pass; + }).length; + var html = + '
' + + '' + + __("通过: ", "Pass: ") + + passed + + "" + + '' + + __("失败: ", "Fail: ") + + failed + + "" + + '' + + __("总计: ", "Total: ") + + checks.length + + "
"; + checks.forEach(function (c) { + var passClass = c.pass ? "check-pass" : "check-fail"; + if (c.status === "skip") passClass = "check-skip"; + html += + '
' + + '' + + escHtml(c.name) + + "" + + '' + + (c.status || "unknown") + + "" + + '' + + escHtml(c.detail || "") + + "
"; + }); + return html; + } + + // ===== Kernel ===== + function renderKernel() { + var k = state.kernel; + if (!k) { + document.getElementById("tab-kernel").innerHTML = + '

' + + __("内核未响应", "Kernel not responding") + + "

"; + return; + } + var html = + '

' + + __("运行时", "Runtime") + + '

' + + statCard("Goroutines", k?.runtime?.goroutines || "-", "") + + statCard( + __("内存", "Memory"), + k?.runtime?.memory_mb ? k.runtime.memory_mb + " MB" : "-", + "", + ) + + statCard( + "Go " + __("版本", "Version"), + k?.runtime?.go_version || "-", + "", + ) + + "
"; + html += + '

LLM

' + + '
Provider' + + (k.llm?.provider || __("未配置", "Not configured")) + + "
" + + '
' + + __("可用源", "Sources") + + '' + + (k.llm?.sources || 0) + + "
" + + '
' + + __("状态", "Status") + + '' + + (k.llm?.available + ? __("运行中", "Running") + : __("不可用", "Unavailable")) + + "
"; + html += + '

' + + __("记忆", "Memory") + + "

" + + '
' + + __("图记忆", "Graph Memory") + + '' + + (k.memory?.available + ? k.memory.entity_count + + __(" 实体, ", " entities, ") + + k.memory.relation_count + + __(" 关系", " relations") + : __("未初始化", "Uninitialized")) + + "
" + + '
' + + __("文档记忆", "Document Memory") + + '' + + (k.documents?.available + ? k.documents.doc_count + __(" 文档", " docs") + : __("未初始化", "Uninitialized")) + + "
" + + '
' + + __("文本记忆", "Text Memory") + + '' + + (k.text_memory?.available + ? k.text_memory.file_count + __(" 文件", " files") + : __("未初始化", "Uninitialized")) + + "
" + + '
' + + __("知识库", "Knowledge") + + '' + + (k.knowledge?.available + ? k.knowledge.item_count + __(" 项", " items") + : __("未初始化", "Uninitialized")) + + "
"; + html += + '

' + + __("插件", "Plugins") + + " (" + + (k.plugins?.length || 0) + + ")

"; + if (k.plugins?.length) { + html += '
'; + k.plugins.forEach(function (p) { + html += + '' + escHtml(p.name) + ""; + }); + html += "
"; + } else { + html += + '

' + __("无", "None") + "

"; + } + html += "
"; + document.getElementById("tab-kernel").innerHTML = html; + } + + // ===== Star Map ===== + var starmapScene = null, + starmapCam = null, + starmapRen = null, + starmapCtrl = null; + var starmapNodes = [], + starmapEdges = []; + var starmapNodeMeshes = [], + starmapEdgeLines = [], + starmapStarField = null; + var starmapHovered = null, + starmapSelected = null, + starmapAutoView = true; + var starmapRaf = null; + var smTypeColors = { + person: 0x4488ff, + task: 0xff8844, + ai: 0xaa44ff, + concept: 0x44ff88, + object: 0xff4444, + }; + var smEdgeColors = { + 喜欢: 0xff6b6b, + 学习: 0x4ecdc4, + 属于: 0x45b7d1, + 相关: 0x96ceb4, + 使用: 0xfeca57, + 创建: 0xff9ff3, + }; + + function createStarField() { + var c = 3000; + var p = new Float32Array(c * 3), + cl = new Float32Array(c * 3), + s = new Float32Array(c); + for (var i = 0; i < c; i++) { + var i3 = i * 3; + var r = 400 + Math.random() * 600, + th = Math.random() * Math.PI * 2, + ph = Math.acos(2 * Math.random() - 1); + p[i3] = r * Math.sin(ph) * Math.cos(th); + p[i3 + 1] = r * Math.sin(ph) * Math.sin(th); + p[i3 + 2] = r * Math.cos(ph); + if (Math.random() < 0.7) { + cl[i3] = 0.8 + Math.random() * 0.2; + cl[i3 + 1] = 0.8 + Math.random() * 0.2; + cl[i3 + 2] = 1; + } else { + cl[i3] = 1; + cl[i3 + 1] = 0.9 + Math.random() * 0.1; + cl[i3 + 2] = 0.8 + Math.random() * 0.2; + } + s[i] = 0.5 + Math.random() * 2; + } + var g = new THREE.BufferGeometry(); + g.setAttribute("position", new THREE.BufferAttribute(p, 3)); + g.setAttribute("color", new THREE.BufferAttribute(cl, 3)); + g.setAttribute("size", new THREE.BufferAttribute(s, 1)); + var m = new THREE.PointsMaterial({ + size: 1.5, + vertexColors: true, + transparent: true, + opacity: 0.8, + sizeAttenuation: true, + }); + starmapStarField = new THREE.Points(g, m); + starmapScene.add(starmapStarField); + } + + function onStarmapMove(e) { + if (!starmapRen || !starmapCam) return; + var rect = starmapRen.domElement.getBoundingClientRect(); + var mouse = new THREE.Vector2( + ((e.clientX - rect.left) / rect.width) * 2 - 1, + -((e.clientY - rect.top) / rect.height) * 2 + 1, + ); + var rc = new THREE.Raycaster(); + rc.setFromCamera(mouse, starmapCam); + var hits = rc.intersectObjects(starmapNodeMeshes); + var infoEl = document.getElementById("starmap-info"); + if (hits.length > 0) { + var n = hits[0].object; + if (starmapHovered !== n) { + if (starmapHovered) starmapHovered.scale.set(1, 1, 1); + starmapHovered = n; + n.scale.set(1.2, 1.2, 1.2); + var nd = n.userData.nodeData; + if (infoEl) { + var e1 = document.getElementById("sm-info-name"); + if (e1) e1.textContent = nd.name || ""; + var e2 = document.getElementById("sm-info-type"); + if (e2) e2.textContent = nd.type || ""; + var e3 = document.getElementById("sm-info-mentions"); + if (e3) e3.textContent = (nd.mention_count || 0) + ""; + var lk = starmapEdges.filter(function (e) { + return e.source_id === nd.id || e.target_id === nd.id; + }).length; + var e4 = document.getElementById("sm-info-links"); + if (e4) e4.textContent = lk + ""; + infoEl.style.display = "block"; + } + } + } else { + if (starmapHovered) { + starmapHovered.scale.set(1, 1, 1); + starmapHovered = null; + } + if (!starmapSelected && infoEl) infoEl.style.display = "none"; + } + } + + function onStarmapClick(e) { + if (!starmapRen || !starmapCam) return; + var rect = starmapRen.domElement.getBoundingClientRect(); + var mouse = new THREE.Vector2( + ((e.clientX - rect.left) / rect.width) * 2 - 1, + -((e.clientY - rect.top) / rect.height) * 2 + 1, + ); + var rc = new THREE.Raycaster(); + rc.setFromCamera(mouse, starmapCam); + var hits = rc.intersectObjects(starmapNodeMeshes); + if (hits.length > 0) { + var n = hits[0].object; + starmapSelected = starmapSelected === n ? null : n; + if (starmapAutoView && starmapSelected) + flyStarmapTo(starmapSelected.userData.nodeId, 500); + onStarmapMove(e); + } else { + starmapSelected = null; + } + } + + function flyStarmapTo(nodeId, dur) { + if (!starmapAutoView) return; + var m = starmapNodeMeshes.find(function (x) { + return x.userData.nodeId === nodeId; + }); + if (!m) return; + var tp = m.position.clone(), + sp = starmapCam.position.clone(), + st = starmapCtrl.target.clone(); + var dist = tp.length() + 25, + ep = new THREE.Vector3(tp.x, tp.y + dist * 0.4, tp.z + dist * 0.8); + var t0 = Date.now(); + (function lerp() { + var t = Math.min((Date.now() - t0) / dur, 1), + e = 1 - Math.pow(1 - t, 3); + starmapCam.position.lerpVectors(sp, ep, e); + starmapCtrl.target.lerpVectors(st, tp, e); + if (t < 1) requestAnimationFrame(lerp); + })(); + } + + function onStarmapResize() { + if (!starmapRen || !starmapCam) return; + var cont = starmapRen.domElement.parentElement; + if (!cont) return; + var rect = cont.getBoundingClientRect(); + var w = rect.width || 800, + h = Math.max(rect.height || 250, 100); + if (w > 0 && h > 0) { + starmapCam.aspect = w / h; + starmapCam.updateProjectionMatrix(); + starmapRen.setSize(w, h); + } + } + + function toggleStarmapAuto() { + starmapAutoView = !starmapAutoView; + var b = document.getElementById("sm-auto-btn"); + if (b) b.className = starmapAutoView ? "on" : ""; + } + + function resetStarmapCamera() { + if (!starmapCam || !starmapCtrl || !starmapNodeMeshes) return; + var maxD = 0; + starmapNodeMeshes.forEach(function (m) { + var d = m.position.length(); + if (d > maxD) maxD = d; + }); + if (maxD < 1) maxD = 30; + var td = Math.min(Math.max(maxD + 20, 30), 150); + var sp = starmapCam.position.clone(), + ep = new THREE.Vector3(td * 0.9, td * 0.6, td * 0.9); + var st = starmapCtrl.target.clone(), + t0 = Date.now(); + (function lerp() { + var t = Math.min((Date.now() - t0) / 400, 1), + e = 1 - Math.pow(1 - t, 3); + starmapCam.position.lerpVectors(sp, ep, e); + starmapCtrl.target.lerpVectors(st, new THREE.Vector3(0, 0, 0), e); + if (t < 1) requestAnimationFrame(lerp); + })(); + } + + function starmapAnimate() { + starmapRaf = requestAnimationFrame(starmapAnimate); + if (starmapCtrl) starmapCtrl.update(); + if (starmapStarField) starmapStarField.rotation.y += 0.0001; + if (starmapRen && starmapScene && starmapCam) + starmapRen.render(starmapScene, starmapCam); + } + + function createNebula() { + var nc = 500; + var p = new Float32Array(nc * 3), + cl = new Float32Array(nc * 3); + for (var i = 0; i < nc; i++) { + var i3 = i * 3; + p[i3] = (Math.random() - 0.5) * 800; + p[i3 + 1] = (Math.random() - 0.5) * 800; + p[i3 + 2] = (Math.random() - 0.5) * 800; + var ch = Math.random(); + if (ch < 0.33) { + cl[i3] = 0.5 + Math.random() * 0.3; + cl[i3 + 1] = 0.2 + Math.random() * 0.2; + cl[i3 + 2] = 0.7 + Math.random() * 0.3; + } else if (ch < 0.66) { + cl[i3] = 0.2 + Math.random() * 0.2; + cl[i3 + 1] = 0.3 + Math.random() * 0.3; + cl[i3 + 2] = 0.8 + Math.random() * 0.2; + } else { + cl[i3] = 0.7 + Math.random() * 0.3; + cl[i3 + 1] = 0.2 + Math.random() * 0.2; + cl[i3 + 2] = 0.5 + Math.random() * 0.3; + } + } + var g = new THREE.BufferGeometry(); + g.setAttribute("position", new THREE.BufferAttribute(p, 3)); + g.setAttribute("color", new THREE.BufferAttribute(cl, 3)); + var m = new THREE.PointsMaterial({ + size: 8, + vertexColors: true, + transparent: true, + opacity: 0.15, + sizeAttenuation: true, + blending: THREE.AdditiveBlending, + }); + var np = new THREE.Points(g, m); + starmapScene.add(np); + } + + // ===== Settings ===== + function pluginDisplayName(p) { + if (p === "core") return __("核心", "Core"); + var name = p.replace("plugin.", ""); + var meta = state.pluginMeta && state.pluginMeta[name]; + if (meta) + return state.lang === "en" + ? meta.name_en || name + : meta.name_zh || name; + return name; + } + + function renderSettingsSidebar() { + var el = document.querySelector(".settings-sidebar"); + if (!el) return; + el.innerHTML = ""; + state.settingsPlugins.forEach(function (p) { + var a = document.createElement("a"); + a.textContent = pluginDisplayName(p); + if (p === state.selectedSection) a.className = "active"; + a.onclick = function () { + state.selectedSection = p; + renderOneSettings(); + }; + el.appendChild(a); + }); + } + + function renderOneSettings() { + var prefix = state.selectedSection + "."; + var allKeys = Object.keys(state.settings || {}); + var filtered = allKeys.filter(function (k) { + return k === prefix.slice(0, -1) || k.startsWith(prefix); + }); + filtered.sort(); + var hideTopLlms = [ + "core.llm.base_url", + "core.llm.model", + "core.llm.api_key", + "core.llm.adapter", + "core.llm.adapter_path", + "core.llm.thinking_enabled", + ]; + var sourceKeys = filtered.filter(function (k) { + return k.startsWith("core.llm.sources."); + }); + var sourceMap = {}; + sourceKeys.forEach(function (k) { + var parts = k.split("."); + var srcName = parts[3]; + if (!sourceMap[srcName]) sourceMap[srcName] = {}; + sourceMap[srcName][k] = true; + }); + var mcpServerKeys = filtered.filter(function (k) { + return ( + k.startsWith("plugin.mcp.servers.") && k.split(".").length >= 5 + ); + }); + var mcpServerMap = {}; + mcpServerKeys.forEach(function (k) { + var parts = k.split("."); + var srvName = parts[3]; + if (!mcpServerMap[srvName]) mcpServerMap[srvName] = {}; + mcpServerMap[srvName][k] = true; + }); + var regularKeys = filtered.filter(function (k) { + return ( + !k.startsWith("core.llm.sources.") && + hideTopLlms.indexOf(k) === -1 && + !k.startsWith("plugin.mcp.servers.") && + k !== "plugin.mcp.servers" + ); + }); + var html = + '
'; + if ( + regularKeys.length === 0 && + Object.keys(sourceMap).length === 0 && + Object.keys(mcpServerMap).length === 0 && + state.selectedSection !== "plugin.mcp" + ) { + html += + '

' + + escHtml(state.selectedSection) + + '

' + + __("暂无设置项", "No settings") + + "

"; + } else { + regularKeys.forEach(function (k) { + var v = state.settings[k]; + var sv = typeof v === "object" ? JSON.stringify(v) : String(v); + var m = state.meta?.[k]; + var shortName = k.split(".").pop().replace(/_/g, " "); + var label = m?.display_name || shortName; + var desc = m?.description || ""; + var typ = m?.type || "string"; + var ph = m?.placeholder || ""; + var opts = m?.options || []; + var inpId = "inp-" + k.replace(/\./g, "_"); + var inp = ""; + if (typ === "bool") { + var chk = sv === "true" ? "checked" : ""; + inp = + '"; + } else if (typ === "select") { + var selOpts = ""; + opts.forEach(function (o) { + selOpts += + '"; + }); + inp = + ""; + } else if (typ === "text") { + inp = + ""; + } else { + inp = + ""; + } + var extra = ""; + if (m?.extra) { + m.extra.forEach(function (f) { + var fk = (k ? k + "." : "") + f.key; + var fv = state.settings?.[fk]; + var fph = f.placeholder || __("输入", "Enter ") + f.label; + extra += + '
"; + if (f.type === "select") { + var fopts = ""; + if (f.options) + f.options.forEach(function (o) { + fopts += + '"; + }); + extra += + ""; + } else { + extra += + ''; + } + extra += "
"; + }); + } + var descHtml = desc + ? '

' + + escHtml(desc) + + "

" + : ""; + html += + '
' + + escHtml(k) + + "
" + + inp + + descHtml + + extra + + '
"; + }); + // LLM Sources + Object.keys(sourceMap) + .sort() + .forEach(function (src) { + var baseKey = "core.llm.sources." + src; + var srcData = + state.settings?.[baseKey + ".adapter"] || + state.settings?.[baseKey + ".base_url"] || + ""; + var fields = [ + { + key: "adapter", + label: __("适配器", "Adapter"), + type: "text", + }, + { key: "base_url", label: "Base URL", type: "text" }, + { key: "model", label: __("模型", "Model"), type: "text" }, + { key: "api_key", label: "API Key", type: "text" }, + { + key: "thinking_enabled", + label: __("思考模式", "Thinking Mode"), + type: "select", + options: ["true", "false"], + }, + { + key: "adapter_path", + label: __("适配器路径", "Adapter Path"), + type: "text", + }, + ]; + var headerLabel = mL10n(src, "LLM Source: " + src); + html += '

' + escHtml(headerLabel) + "

"; + fields.forEach(function (f) { + var fk = baseKey + "." + f.key; + var fv = state.settings?.[fk] || ""; + var flabel = f.label; + var fieldId = "inp-" + fk.replace(/\./g, "_"); + if (f.type === "select") { + var fopts = ""; + f.options.forEach(function (o) { + fopts += + '"; + }); + html += + ""; + } else { + html += + "'; + } + }); + html += + '
' + + '" + + '
"; + }); + if ( + Object.keys(sourceMap).length > 0 || + state.selectedSection === "core.llm" + ) { + html += + '"; + } + // MCP Servers + if ( + state.selectedSection === "plugin.mcp" || + Object.keys(mcpServerMap).length > 0 + ) { + html += + '

' + + __("MCP 服务器", "MCP Servers") + + '

' + + __( + "配置 Model Context Protocol 服务端连接", + "Configure Model Context Protocol server connections", + ) + + "

"; + Object.keys(mcpServerMap) + .sort() + .forEach(function (srv) { + var baseKey = "plugin.mcp.servers." + srv; + var fields = [ + { + key: "command", + label: __("启动命令", "Command"), + type: "text", + }, + { key: "url", label: "SSE URL", type: "text" }, + { + key: "args", + label: __("参数(JSON数组)", "Args (JSON array)"), + type: "text", + }, + { + key: "env", + label: __("环境变量(JSON数组)", "Env (JSON array)"), + type: "text", + }, + ]; + html += '

' + escHtml(srv) + "

"; + fields.forEach(function (f) { + var fk = baseKey + "." + f.key; + var fv = state.settings?.[fk] || ""; + var fieldId = "inp-" + fk.replace(/\./g, "_"); + html += + "'; + }); + html += + '
' + + '" + + '
"; + }); + html += + '"; + } + } + html += "
"; + document.getElementById("tab-settings").innerHTML = html; + renderSettingsSidebar(); + } + + function markDirty(k) { + var inp = document.getElementById("inp-" + k.replace(/\./g, "_")); + if (inp) inp.style.borderColor = "var(--save-btn-border)"; + } + + async function saveSetting(k) { + var inp = document.getElementById("inp-" + k.replace(/\./g, "_")); + if (!inp) return; + var val; + var m = state.meta?.[k]; + if (m?.type === "bool") { + val = inp.checked ? "true" : "false"; + } else if (m?.type === "select") { + val = inp.value; + } else { + var raw = inp.value; + try { + val = JSON.parse(raw); + } catch (e) { + val = raw; + } + } + try { + var r = await api("/settings", { + method: "PUT", + body: JSON.stringify({ key: k, value: val }), + }); + if (r.status === "ok") { + inp.style.borderColor = ""; + state.settings[k] = val; + toast(__("已保存: ", "Saved: ") + k); + } else { + toast( + __("保存失败: ", "Save failed: ") + (r.error || "unknown"), + true, + ); + } + } catch (e) { + toast(__("保存失败: ", "Save failed: ") + e.message, true); + } + } + + function renderConfigDisabled() { + document.getElementById("tab-settings").innerHTML = + '

' + + __("设置", "Settings") + + '

' + + __("设置面板已加载", "Settings panel loaded") + + "

"; + renderOneSettings(); + } + + function mL10n(key, fallback) { + var meta = state.meta?.[key]; + if (meta?.display_name) return meta.display_name; + return fallback || key; + } + + function showAddSourceDialog() { + var name = prompt( + __( + "输入新 LLM 源名称(如 openai、anthropic):", + "Enter new LLM source name (e.g. openai, anthropic):", + ), + ); + if (!name || !name.trim()) return; + name = name + .trim() + .toLowerCase() + .replace(/[^a-z0-9_]/g, "_"); + if (!name) { + toast(__("名称无效", "Invalid name"), true); + return; + } + var keys = [ + "base_url", + "model", + "api_key", + "adapter", + "adapter_path", + "thinking_enabled", + ]; + var values = { + base_url: "https://api." + name + ".com", + model: "", + api_key: "", + adapter: name, + adapter_path: "", + thinking_enabled: "false", + }; + var promises = keys.map(function (f) { + return api("/settings", { + method: "PUT", + body: JSON.stringify({ + key: "core.llm.sources." + name + "." + f, + value: values[f], + }), + }); + }); + Promise.all(promises) + .then(function () { + toast( + __("源", "Source") + + ' "' + + name + + '" ' + + __( + "已创建,请配置各项参数", + "created, please configure parameters", + ), + ); + renderAll(); + }) + .catch(function (e) { + toast(__("创建失败: ", "Create failed: ") + e.message, true); + }); + } + + async function deleteSource(name) { + if ( + !confirm( + __("确认删除源", "Are you sure to delete source") + + ' "' + + name + + '"?', + ) + ) + return; + var base = "core.llm.sources." + name; + var fields = [ + "adapter", + "base_url", + "model", + "api_key", + "thinking_enabled", + "adapter_path", + ]; + try { + for (var f of fields) { + await api("/settings", { + method: "PUT", + body: JSON.stringify({ key: base + "." + f, value: null }), + }); + } + toast( + __("源", "Source") + ' "' + name + '" ' + __("已删除", "deleted"), + ); + renderAll(); + } catch (e) { + toast(__("删除失败: ", "Delete failed: ") + e.message, true); + } + } + + function addMCPSource() { + var name = prompt( + __("输入新 MCP 服务器名称:", "Enter new MCP server name:"), + ); + if (!name || !name.trim()) return; + name = name + .trim() + .toLowerCase() + .replace(/[^a-z0-9_]/g, "_"); + if (!name) { + toast(__("名称无效", "Invalid name"), true); + return; + } + var fields = ["command", "url", "args", "env"]; + var values = { command: "", url: "", args: "[]", env: "[]" }; + var promises = fields.map(function (f) { + return api("/settings", { + method: "PUT", + body: JSON.stringify({ + key: "plugin.mcp.servers." + name + "." + f, + value: values[f], + }), + }); + }); + Promise.all(promises) + .then(function () { + toast( + "MCP " + + __("服务器", "server") + + ' "' + + name + + '" ' + + __("已创建", "created"), + ); + renderAll(); + }) + .catch(function (e) { + toast(__("创建失败: ", "Create failed: ") + e.message, true); + }); + } + + async function deleteMCPServer(name) { + if ( + !confirm( + __("确认删除 MCP 服务器", "Are you sure to delete MCP server") + + ' "' + + name + + '"?', + ) + ) + return; + var fields = ["command", "url", "args", "env"]; + try { + for (var f of fields) { + await api("/settings", { + method: "PUT", + body: JSON.stringify({ + key: "plugin.mcp.servers." + name + "." + f, + value: null, + }), + }); + } + toast( + "MCP " + + __("服务器", "server") + + ' "' + + name + + '" ' + + __("已删除", "deleted"), + ); + renderAll(); + } catch (e) { + toast(__("删除失败: ", "Delete failed: ") + e.message, true); + } + } + + // ===== Adapters ===== + async function renderAdapters() { + var html = + '

' + + __("Lua 适配器管理", "Lua Adapter Management") + + "

" + + '

' + + __( + "上传自定义 Lua 适配器脚本以支持新的 LLM 提供商。脚本文件将保存到适配器目录并自动加载到 Lua VM。", + "Upload custom Lua adapter scripts to support new LLM providers. Scripts are saved to the adapter directory and auto-loaded into the Lua VM.", + ) + + "

"; + try { + var r = await api("/adapters"); + var adapters = r.adapters || []; + window._adapters = adapters; + html += + '

' + + __("已加载的适配器", "Loaded Adapters") + + " (" + + adapters.length + + ")

"; + if (adapters.length === 0) { + html += + '

' + + __("暂无适配器", "No adapters") + + "

"; + } else { + html += + ""; + adapters.forEach(function (a) { + html += + "" + + '"; + }); + html += "
" + + __("名称", "Name") + + "" + + __("版本", "Version") + + "" + + __("操作", "Actions") + + "
" + + escHtml(a.name) + + "" + + escHtml(a.version || "-") + + "
"; + } + html += "
"; + html += + '

' + + __("上传新适配器", "Upload New Adapter") + + "

" + + "" + + '' + + "" + + '' + + '
"; + } catch (e) { + html += + '

' + + __("加载适配器失败: ", "Failed to load adapters: ") + + escHtml(e.message) + + "

"; + } + document.getElementById("tab-adapters").innerHTML = html; + } + + async function uploadAdapter() { + var name = document.getElementById("adapter-name")?.value; + var code = document.getElementById("adapter-code")?.value; + if (!name || !code) { + toast( + __("名称和代码不能为空", "Name and code cannot be empty"), + true, + ); + return; + } + try { + var r = await api("/adapters", { + method: "POST", + body: JSON.stringify({ name: name, code: code }), + }); + if (r.status === "loaded") { + toast( + __("适配器", "Adapter") + + ' "' + + name + + '" ' + + __("已加载", "loaded"), + ); + renderAdapters(); + } else { + toast( + __("上传失败: ", "Upload failed: ") + (r.error || "unknown"), + true, + ); + } + } catch (e) { + toast(__("上传失败: ", "Upload failed: ") + e.message, true); + } + } + + async function deleteAdapter(name) { + if ( + !confirm( + __("确定删除适配器", "Are you sure to delete adapter") + + ' "' + + name + + '"?', + ) + ) + return; + try { + var r = await api("/adapters/" + encodeURIComponent(name), { + method: "DELETE", + }); + if (r.status === "deleted") { + toast( + __("适配器", "Adapter") + + ' "' + + name + + '" ' + + __("已删除", "deleted"), + ); + renderAdapters(); + } else { + toast(__("删除失败", "Delete failed"), true); + } + } catch (e) { + toast(__("删除失败: ", "Delete failed: ") + e.message, true); + } + } + + // ===== Init ===== + async function logout() { + try { + await fetch("/api/v1/logout", { + method: "POST", + credentials: "include", + }); + } catch (e) {} + location.href = "/login"; + } + + renderConfigDisabled(); + connectSSE(); + (async function () { + await loadChatHistory(); + renderAll(); + startUptimeTicker(); + })(); + setInterval(renderAll, 15000); + + diff --git a/internal/plugins/webui/handler.go b/internal/plugins/webui/handler.go index fbd8070..fcc91ff 100644 --- a/internal/plugins/webui/handler.go +++ b/internal/plugins/webui/handler.go @@ -1,6 +1,7 @@ package webui import ( + "context" "crypto/rand" "embed" "encoding/hex" @@ -15,6 +16,7 @@ import ( "sync" "time" + agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io" sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" "gitcode.com/JianFeeeee/HomeAgent/pkg/types" ) @@ -519,6 +521,7 @@ func (h *Handler) handleAgents(w http.ResponseWriter, r *http.Request) { if h.config != nil { kcfg := h.config.Get() kcfg.Agents = append(kcfg.Agents, cfg) + h.config.Put(kcfg) } writeJSON(w, http.StatusCreated, map[string]string{"id": string(cfg.ID)}) default: @@ -725,11 +728,19 @@ func (h *Handler) handleKnowledge(w http.ResponseWriter, r *http.Request) { } query := r.URL.Query().Get("q") if query != "" { - results, _ := h.knowledge.Search(query, 10) + results, err := h.knowledge.Search(query, 10) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } writeJSON(w, http.StatusOK, map[string]interface{}{"results": results}) return } - categories, _ := h.knowledge.List() + categories, err := h.knowledge.List() + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } writeJSON(w, http.StatusOK, map[string]interface{}{ "categories": categories, "stats": h.knowledge.Stats(), @@ -887,15 +898,23 @@ func (h *Handler) handleNetwork(w http.ResponseWriter, r *http.Request) { }) } +// chatSaveThrottle 控制写盘频率:最多每 3 秒写一次 +const chatSaveThrottle = 3 * time.Second + func (h *Handler) addChatMsg(msg ChatMsg) { h.chatMu.Lock() h.chatHistory = append(h.chatHistory, msg) if len(h.chatHistory) > maxChatHistory { h.chatHistory = h.chatHistory[len(h.chatHistory)-maxChatHistory:] } - // persist to webui config table as compact JSON + // 只持 latest 50 条做持久化(写放大防护),全量仍保留在内存 if h.settings != nil { - b, _ := json.Marshal(h.chatHistory) + persistLen := len(h.chatHistory) + if persistLen > 50 { + persistLen = 50 + } + toPersist := h.chatHistory[len(h.chatHistory)-persistLen:] + b, _ := json.Marshal(toPersist) _ = h.settings.Set("chathistory", string(b)) } h.chatMu.Unlock() @@ -949,7 +968,24 @@ func (h *Handler) handleChat(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "agent unavailable"}) return } - resp := h.sdk.InjectTextSync("webui", "webui", body.Message) + + // 带超时的上下文,防止 InjectTextSync 长时间阻塞 HTTP 请求 + ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second) + defer cancel() + + respCh := make(chan *agentIO.OutputEvent, 1) + go func() { + respCh <- h.sdk.InjectTextSync("webui", "webui", body.Message) + }() + + var resp *agentIO.OutputEvent + select { + case resp = <-respCh: + case <-ctx.Done(): + writeJSON(w, http.StatusGatewayTimeout, map[string]string{"error": "agent timeout (60s)"}) + return + } + if resp == nil { writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "agent unavailable"}) return @@ -1009,6 +1045,13 @@ func (h *Handler) handleChatEvents(w http.ResponseWriter, r *http.Request) { }() log.Printf("[SSE] handler started, subscribing to events") + + // 解析 Last-Event-ID(断线重连时客户端携带) + lastEventID := r.Header.Get("Last-Event-ID") + if lastEventID != "" { + log.Printf("[SSE] client reported Last-Event-ID: %s", lastEventID) + } + subTypes := []string{"agent_output", "reasoning", "agent_error", "tool_call", "stage", "agent_llm_chain"} var unsubs []func() for _, t := range subTypes { @@ -1236,7 +1279,23 @@ func (h *Handler) handleOpenAICompletions(w http.ResponseWriter, r *http.Request return } - response := h.sdk.InjectTextSync("http", "http", lastMsg.Content) + // 带超时的上下文 + ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second) + defer cancel() + + respCh := make(chan *agentIO.OutputEvent, 1) + go func() { + respCh <- h.sdk.InjectTextSync("http", "http", lastMsg.Content) + }() + + var response *agentIO.OutputEvent + select { + case response = <-respCh: + case <-ctx.Done(): + writeJSON(w, http.StatusGatewayTimeout, map[string]string{"error": "agent timeout (60s)"}) + return + } + if response == nil { writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "no response from agent"}) return