From eb55a8fb989e90302a4f444407b846f4533319d1 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 6 Jul 2026 20:32:04 +0800 Subject: [PATCH] feat: expand local operator controls across CLI and WebUI - Add structured CLI commands for status/kernel/settings/plugins/memory/knowledge/agents - Inject core dependencies directly into CLI plugin for non-HTTP operator workflows - Add plugin management panel and API proxy endpoints to WebUI - Let cmd_run inherit default workdir from core.agent.workdir - Use fixed loopback address for pluginmgr API - Remove stale MaxToolTurns config usage from homed wiring --- cmd/homed/main.go | 4 +- cmd/waiter/main.go | 35 ++- internal/plugins/cli/plugin.go | 312 ++++++++++++++++++++++++- internal/plugins/cmd/plugin.go | 11 +- internal/plugins/pluginmgr/plugin.go | 2 +- internal/plugins/webui/dashboard.html | 19 +- internal/plugins/webui/handler.go | 87 +++++++ internal/plugins/webui/handler_test.go | 1 - 8 files changed, 455 insertions(+), 16 deletions(-) diff --git a/cmd/homed/main.go b/cmd/homed/main.go index 218ee2a..af8bee1 100644 --- a/cmd/homed/main.go +++ b/cmd/homed/main.go @@ -368,7 +368,6 @@ func main() { Indexer: memIdx, Skills: skMgr, Tracker: trk, - MaxToolTurns: 10, DocStore: docStore, Knowledge: ks, SocialStore: socialStore, @@ -395,6 +394,9 @@ func main() { pluginmgr.PluginDir = cfg.Plugin.Dir pluginmgr.Reg = pluginReg + // CLI 插件结构化命令 — 直接注入内核依赖,不依赖 HTTP + cli.Configure(pluginReg, cfgReg, agent, cfg.Plugin.Dir) + // Auto-create plugins directory (without hardcoding plugin names) os.MkdirAll(cfg.Plugin.Dir, 0755) diff --git a/cmd/waiter/main.go b/cmd/waiter/main.go index 674cc0b..a8aeadb 100644 --- a/cmd/waiter/main.go +++ b/cmd/waiter/main.go @@ -378,15 +378,29 @@ func handleBuiltin(cmd string, mode, addr *string, reconnect func()) bool { switch { case cmd == "/help": fmt.Println(`Built-in commands: - /help show this help - /exit, /quit exit waiter - /clear clear screen - /reconnect force reconnection - /connect switch to a different unix socket - /remote switch to remote HTTP mode - /local switch back to local socket mode + /help show this help + /exit, /quit exit waiter + /clear clear screen + /reconnect force reconnection + /connect switch to a different unix socket + /remote switch to remote HTTP mode + /local switch back to local socket mode -Any other text is sent as a message to the agent.`) +Structured commands (processed server-side): + /status system status + /kernel kernel status + /settings [prefix] list settings + /settings set set a setting + /plugin list list installed plugins + /plugin install install plugin + /plugin remove remove plugin + /plugin info plugin details + /memory query query graph memory + /knowledge list knowledge base + /agents list agents + /chat send to agent + +Any other text is sent to the agent.`) return true case cmd == "/exit" || cmd == "/quit": @@ -580,7 +594,10 @@ func (e *LineEditor) historyNext() { } func (e *LineEditor) doCompletion() { - cmds := []string{"/help", "/exit", "/quit", "/clear", "/reconnect", "/connect ", "/remote ", "/local"} + cmds := []string{"/help", "/exit", "/quit", "/clear", "/reconnect", "/connect ", "/remote ", "/local", + "/status", "/kernel", "/settings ", "/settings set ", "/chat ", + "/plugin ", "/plugin list", "/plugin install ", "/plugin remove ", "/plugin info ", + "/memory ", "/memory query ", "/knowledge", "/agents"} prefix := string(e.buf) for _, c := range cmds { if strings.HasPrefix(c, prefix) && c != prefix { diff --git a/internal/plugins/cli/plugin.go b/internal/plugins/cli/plugin.go index 09f7472..e919462 100644 --- a/internal/plugins/cli/plugin.go +++ b/internal/plugins/cli/plugin.go @@ -8,16 +8,35 @@ import ( "net" "os" "path/filepath" + "sort" + "strings" "sync" + agentCore "gitcode.com/JianFeeeee/HomeAgent/internal/agent/core" + internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config" "gitcode.com/JianFeeeee/HomeAgent/internal/plugin" sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" ) // DefaultSocket 由 main.go 在 Load() 前设置,覆盖默认 socket 路径。 -// 若为空,factory 使用 "/cli.sock"。 var DefaultSocket string +// 以下通过 Configure() 注入内核依赖 +var ( + pluginReg *plugin.Registry + cfgReg *internalConfig.ConfigRegistry + statusProv agentCore.StatusProvider + pluginDir string +) + +// Configure 由 main.go 在 Load() 前调用,注入内核依赖供结构化命令使用。 +func Configure(pr *plugin.Registry, cr *internalConfig.ConfigRegistry, sp agentCore.StatusProvider, pDir string) { + pluginReg = pr + cfgReg = cr + statusProv = sp + pluginDir = pDir +} + func init() { plugin.RegisterFactory("cli", func(name string, config map[string]interface{}) (sdk.Plugin, error) { sock := DefaultSocket @@ -95,6 +114,12 @@ func (p *Plugin) handleConn(conn net.Conn, s *sdk.PluginSDK) { continue } + if line[0] == '/' { + if p.handleBuiltin(conn, line, s) { + continue + } + } + resp := s.InjectTextSync("cli", "cli", line) if resp != nil { content, _ := resp.Payload["content"].(string) @@ -111,6 +136,291 @@ func (p *Plugin) handleConn(conn net.Conn, s *sdk.PluginSDK) { } } +func (p *Plugin) handleBuiltin(conn net.Conn, line string, s *sdk.PluginSDK) bool { + parts := strings.Fields(line) + if len(parts) == 0 { + return false + } + + switch parts[0] { + case "/help": + p.cmdHelp(conn) + case "/status": + p.cmdStatus(conn) + case "/kernel": + p.cmdKernel(conn) + case "/settings": + p.cmdSettings(conn, parts) + case "/plugin": + p.cmdPlugin(conn, parts) + case "/memory": + p.cmdMemory(conn, parts, s) + case "/knowledge": + p.cmdKnowledge(conn, s) + case "/agents": + p.cmdAgents(conn) + default: + return false + } + return true +} + +func (p *Plugin) cmdHelp(conn net.Conn) { + writeLine(conn, map[string]interface{}{ + "type": "response", + "content": `内置命令(直接对话内核,不依赖网络): + /help 显示此帮助 + /status 系统运行状态 + /kernel 内核状态(插件、工具、LLM、记忆) + /settings 列出所有配置 + /settings set 修改配置项 + /settings core.llm 按前缀筛选 + /plugin list 列出已安装插件 + /plugin install 安装插件(需回环网络) + /plugin remove 卸载插件 + /plugin info 查看插件详情 + /memory query <关键词> 查询图记忆 + /knowledge 列出知识库 + /agents 列出 Agent + +其他文本直接发送给 Agent 处理。`, + }) +} + +// ======== /status ======== + +func (p *Plugin) cmdStatus(conn net.Conn) { + if statusProv == nil { + writeLine(conn, map[string]interface{}{"type": "error", "error": "status provider not available"}) + return + } + ks := statusProv.GetKernelStatus() + + llmStatus := "不可用" + if ks.LLM.Available { + llmStatus = fmt.Sprintf("%s (%d sources)", ks.LLM.Provider, ks.LLM.Sources) + } + memInfo := "未初始化" + if ks.Memory.Available { + memInfo = fmt.Sprintf("%d entities, %d relations", ks.Memory.EntityCount, ks.Memory.RelationCount) + } + runtime := fmt.Sprintf("goroutines=%d mem=%dMB", ks.Runtime.Goroutines, ks.Runtime.MemoryMB) + uptime := ks.Uptime + + writeLine(conn, map[string]interface{}{ + "type": "response", + "content": fmt.Sprintf(`HomeAgent 内核状态 + 状态: running + 运行: %s + LLM: %s + 记忆: %s + 运行时: %s + 插件: %d loaded + 工具: %d registered`, uptime, llmStatus, memInfo, runtime, + len(ks.Plugins), len(ks.Tools)), + }) +} + +// ======== /kernel ======== + +func (p *Plugin) cmdKernel(conn net.Conn) { + if statusProv == nil { + writeLine(conn, map[string]interface{}{"type": "error", "error": "status provider not available"}) + return + } + data, _ := json.MarshalIndent(statusProv.GetKernelStatus(), "", " ") + writeLine(conn, map[string]interface{}{"type": "response", "content": string(data)}) +} + +// ======== /settings ======== + +func (p *Plugin) cmdSettings(conn net.Conn, parts []string) { + if cfgReg == nil { + writeLine(conn, map[string]interface{}{"type": "error", "error": "config registry not available"}) + return + } + + if len(parts) >= 2 && parts[1] == "set" { + if len(parts) < 4 { + writeLine(conn, map[string]interface{}{"type": "response", "content": "用法: /settings set "}) + return + } + key := parts[2] + val := strings.Join(parts[3:], " ") + if err := cfgReg.Set(key, val); err != nil { + writeLine(conn, map[string]interface{}{"type": "error", "error": err.Error()}) + return + } + writeLine(conn, map[string]interface{}{"type": "response", "content": fmt.Sprintf("已设置: %s = %s", key, val)}) + return + } + + prefix := "" + if len(parts) >= 2 { + prefix = parts[1] + } + keys := cfgReg.List(prefix) + sort.Strings(keys) + if len(keys) == 0 { + writeLine(conn, map[string]interface{}{"type": "response", "content": "无匹配配置项"}) + return + } + var lines []string + for _, k := range keys { + v, _ := cfgReg.Get(k) + lines = append(lines, fmt.Sprintf(" %s = %v", k, v)) + } + writeLine(conn, map[string]interface{}{ + "type": "response", + "content": fmt.Sprintf("配置 (%d 项):\n%s", len(keys), strings.Join(lines, "\n")), + }) +} + +// ======== /plugin ======== + +func (p *Plugin) cmdPlugin(conn net.Conn, parts []string) { + if len(parts) < 2 { + writeLine(conn, map[string]interface{}{"type": "response", "content": "用法: /plugin list|install |remove |info "}) + return + } + + switch parts[1] { + case "list": + if pluginReg == nil { + writeLine(conn, map[string]interface{}{"type": "error", "error": "plugin registry not available"}) + return + } + names := pluginReg.List() + if len(names) == 0 { + writeLine(conn, map[string]interface{}{"type": "response", "content": "无已加载插件"}) + return + } + writeLine(conn, map[string]interface{}{ + "type": "response", + "content": fmt.Sprintf("已加载插件 (%d):\n %s", len(names), strings.Join(names, "\n ")), + }) + + case "install": + if len(parts) < 3 { + writeLine(conn, map[string]interface{}{"type": "response", "content": "用法: /plugin install "}) + return + } + writeLine(conn, map[string]interface{}{ + "type": "response", + "content": "安装插件需要网络,本环境可能受限。请通过 WebUI 或使用 agent 对话安装。", + }) + + case "remove": + if len(parts) < 3 { + writeLine(conn, map[string]interface{}{"type": "response", "content": "用法: /plugin remove "}) + return + } + name := parts[2] + if pluginDir == "" { + writeLine(conn, map[string]interface{}{"type": "error", "error": "plugin dir not configured"}) + return + } + dir := filepath.Join(pluginDir, name) + if err := os.RemoveAll(dir); err != nil { + writeLine(conn, map[string]interface{}{"type": "error", "error": err.Error()}) + return + } + writeLine(conn, map[string]interface{}{"type": "response", "content": fmt.Sprintf("插件 %s 已删除,执行 /plugin reload 生效", name)}) + + case "info": + if len(parts) < 3 { + writeLine(conn, map[string]interface{}{"type": "response", "content": "用法: /plugin info "}) + return + } + if pluginReg == nil { + writeLine(conn, map[string]interface{}{"type": "error", "error": "plugin registry not available"}) + return + } + plg := pluginReg.Get(parts[2]) + if plg == nil { + writeLine(conn, map[string]interface{}{"type": "response", "content": fmt.Sprintf("插件 %q 未加载", parts[2])}) + return + } + writeLine(conn, map[string]interface{}{"type": "response", "content": fmt.Sprintf("名称: %s\n状态: 已加载", plg.Name())}) + + case "reload": + if pluginReg == nil { + writeLine(conn, map[string]interface{}{"type": "error", "error": "plugin registry not available"}) + return + } + if _, err := pluginReg.Reload(pluginDir); err != nil { + writeLine(conn, map[string]interface{}{"type": "error", "error": err.Error()}) + return + } + writeLine(conn, map[string]interface{}{"type": "response", "content": "插件已重载"}) + + default: + writeLine(conn, map[string]interface{}{"type": "response", "content": "未知: /plugin " + parts[1] + "。支持: list, install, remove, info, reload"}) + } +} + +// ======== /memory ======== + +func (p *Plugin) cmdMemory(conn net.Conn, parts []string, s *sdk.PluginSDK) { + if len(parts) < 3 || parts[1] != "query" { + writeLine(conn, map[string]interface{}{"type": "response", "content": "用法: /memory query <关键词>"}) + return + } + q := strings.Join(parts[2:], " ") + + mem := s.Memory() + if mem == nil { + writeLine(conn, map[string]interface{}{"type": "error", "error": "memory not available"}) + return + } + entities, relations, err := mem.Recall([]string{q}, 2) + if err != nil { + writeLine(conn, map[string]interface{}{"type": "error", "error": err.Error()}) + return + } + result := map[string]interface{}{ + "entities": entities, + "relations": relations, + } + data, _ := json.MarshalIndent(result, "", " ") + writeLine(conn, map[string]interface{}{"type": "response", "content": string(data)}) +} + +// ======== /knowledge ======== + +func (p *Plugin) cmdKnowledge(conn net.Conn, s *sdk.PluginSDK) { + ks := s.Knowledge() + if ks == nil { + writeLine(conn, map[string]interface{}{"type": "error", "error": "knowledge not available"}) + return + } + items, err := ks.List() + if err != nil { + writeLine(conn, map[string]interface{}{"type": "error", "error": err.Error()}) + return + } + if len(items) == 0 { + writeLine(conn, map[string]interface{}{"type": "response", "content": "知识库为空"}) + return + } + data, _ := json.MarshalIndent(items, "", " ") + writeLine(conn, map[string]interface{}{"type": "response", "content": string(data)}) +} + +// ======== /agents ======== + +func (p *Plugin) cmdAgents(conn net.Conn) { + if statusProv == nil { + writeLine(conn, map[string]interface{}{"type": "error", "error": "status provider not available"}) + return + } + ks := statusProv.GetKernelStatus() + data, _ := json.MarshalIndent(map[string]string{"agent_id": ks.AgentID}, "", " ") + writeLine(conn, map[string]interface{}{"type": "response", "content": string(data)}) +} + +// ======== helpers ======== + func writeLine(conn net.Conn, v interface{}) { data, err := json.Marshal(v) if err != nil { diff --git a/internal/plugins/cmd/plugin.go b/internal/plugins/cmd/plugin.go index cc35a4f..746a905 100644 --- a/internal/plugins/cmd/plugin.go +++ b/internal/plugins/cmd/plugin.go @@ -45,7 +45,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { }, "workdir": map[string]interface{}{ "type": "string", - "description": "工作目录(可选,默认当前目录)", + "description": "工作目录(可选,默认由 core.agent.workdir 配置决定)", }, }, "required": []string{"command"}, @@ -66,6 +66,15 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { } workdir, _ := args["workdir"].(string) + if workdir == "" { + if sett := s.Settings(); sett != nil { + if v, _ := sett.GetCore("core.agent.workdir"); v != nil { + if str, ok := v.(string); ok && str != "" { + workdir = str + } + } + } + } ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() diff --git a/internal/plugins/pluginmgr/plugin.go b/internal/plugins/pluginmgr/plugin.go index 367a92c..6eae794 100644 --- a/internal/plugins/pluginmgr/plugin.go +++ b/internal/plugins/pluginmgr/plugin.go @@ -22,7 +22,7 @@ import ( var ( PluginDir string // 由 main.go 设置 Reg *plugin.Registry // 由 main.go 设置 - HTTPAddr = "127.0.0.1:0" // 监听地址,可被 main.go 覆写 + HTTPAddr = "127.0.0.1:9876" // 监听地址,可被 main.go 覆写或 settings 配置 ) func init() { diff --git a/internal/plugins/webui/dashboard.html b/internal/plugins/webui/dashboard.html index d6e803c..6d8f984 100644 --- a/internal/plugins/webui/dashboard.html +++ b/internal/plugins/webui/dashboard.html @@ -114,7 +114,7 @@ let state={status:{},kernel:null,settings:{},meta:{},settingsPlugins:['core'],se async function api(p,o){let opts={headers:{'Content-Type':'application/json',...o?.headers},...o};let r=await fetch('/api/v1'+p,opts);if(opts.raw)return r;let ct=r.headers.get('content-type')||'';if(ct.includes('json'))return r.json();return r.text()} function switchTab(n){document.querySelectorAll('.tab-content').forEach(e=>e.classList.remove('active'));let el=document.getElementById('tab-'+n);if(el)el.classList.add('active');document.querySelectorAll('nav a').forEach(e=>e.classList.remove('active'));document.querySelector('nav a[onclick*="\'+n+\'"]')||document.querySelector(`nav a[onclick*="${n}"]`)?.classList.add('active');renderAll()} function toast(m,isError){let t=document.getElementById('toast');t.textContent=m;t.className='toast'+(isError?' error':'');t.style.display='block';setTimeout(()=>t.style.display='none',3000)} -async function renderAll(){try{let s=await api('/status');state.status=s}catch(e){}try{state.kernel=await api('/kernel')}catch(e){}try{let s=await api('/settings');state.settings=s.settings||{};state.meta=s.meta||{};state.settingsPlugins=s.plugins||['core']}catch(e){}renderOverview();renderChat();renderPlugins();renderMemory();renderKnowledge();renderKernel()} +async function renderAll(){try{let s=await api('/status');state.status=s}catch(e){}try{state.kernel=await api('/kernel')}catch(e){}try{let s=await api('/settings');state.settings=s.settings||{};state.meta=s.meta||{};state.settingsPlugins=s.plugins||['core']}catch(e){}try{state.installedPlugins=await api('/plugins')}catch(e){}renderOverview();renderChat();renderPlugins();renderMemory();renderKnowledge();renderKernel()} function escHtml(s){return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"')} function timeAgo(t){let s=Math.floor((Date.now()-new Date(t).getTime())/1000);if(s<60)return s+'秒前';let m=Math.floor(s/60);if(m<60)return m+'分钟前';return Math.floor(m/60)+'小时前'} @@ -127,7 +127,22 @@ function renderChat(){let msgs=state.messages;let html='

async function sendChat(){let inp=document.getElementById('chat-input');let btn=document.getElementById('chat-send-btn');let text=inp.value.trim();if(!text||state.chatLoading)return;state.messages.push({role:'user',content:text});inp.value='';renderChat();state.chatLoading=true;btn.disabled=true;btn.textContent='...';try{let r=await api('/chat',{method:'POST',body:JSON.stringify({message:text})});state.messages.push({role:'assistant',content:r.response||'(无响应)',reasoning_content:r.reasoning_content});renderChat()}catch(e){state.messages.push({role:'assistant',content:'错误: '+e.message});renderChat();toast('请求失败: '+e.message,true)}finally{state.chatLoading=false;btn.disabled=false;btn.textContent='发送'}} // === Plugins === -function renderPlugins(){let k=state.kernel;let plugins=k?.plugins||[];let tools=k?.tools||[];let html='

已加载插件 ('+plugins.length+')

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

暂无已加载插件

'}else{html+='';plugins.forEach(p=>{html+=''});html+='
名称状态
'+escHtml(p.name)+'已加载
'}html+='
';if(tools.length>0){html+='

已注册工具 ('+tools.length+')

';tools.forEach(t=>{html+=''+escHtml(t.name)+''});html+='
'}html+='

健康检查

';if(state.healthResult){html+=renderHealthResult(state.healthResult)}else{html+=''}html+='
';document.getElementById('tab-plugins').innerHTML=html} +function renderPlugins(){let k=state.kernel;let plugins=k?.plugins||[];let tools=k?.tools||[];let installed=state.installedPlugins||[] +let html='

安装插件

' +html+='

已加载插件 ('+plugins.length+')

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

暂无已加载插件

'}else{html+='';plugins.forEach(p=>{html+=''});html+='
名称状态
'+escHtml(p.name)+'已加载
'}html+='
' +if(installed.length>0){html+='

已安装外部插件 ('+installed.length+')

';installed.forEach(p=>{html+=''});html+='
名称版本描述操作
'+escHtml(p.name)+''+escHtml(p.version||'-')+''+escHtml((p.description||'').substring(0,50))+'
'} +if(state.pluginInfo){html+='

插件详情: '+escHtml(state.pluginInfo.name)+'

'+escHtml(JSON.stringify(state.pluginInfo,null,2))+'
'} +if(tools.length>0){html+='

已注册工具 ('+tools.length+')

';tools.forEach(t=>{html+=''+escHtml(t.name)+''});html+='
'} +html+='

系统操作

' +html+='

健康检查

';if(state.healthResult){html+=renderHealthResult(state.healthResult)}else{html+='

点击上方按钮运行

'}html+='
' +document.getElementById('tab-plugins').innerHTML=html} +async function loadInstalledPlugins(){try{state.installedPlugins=await api('/plugins')}catch(e){state.installedPlugins=[]}} +async function installPlugin(){let inp=document.getElementById('plugin-url');let url=inp?.value.trim();if(!url){toast('请输入插件包 URL',true);return}try{let r=await api('/plugins',{method:'POST',body:JSON.stringify({url})});toast('安装结果: '+(r.status||JSON.stringify(r)));if(r.action==='reload_required'){toast('已安装,请点击「重载插件」加载',false)}loadInstalledPlugins();renderPlugins()}catch(e){toast('安装失败: '+e.message,true)}} +async function installPluginFile(file){if(!file)return;let form=new FormData();form.append('file',file);try{let r=await fetch('/api/v1/plugins',{method:'POST',body:file,headers:{'Content-Type':'application/octet-stream'}});let data=await r.json();toast('上传安装: '+(data.status||JSON.stringify(data)));if(data.action==='reload_required'){toast('已安装,请点击「重载插件」加载',false)}loadInstalledPlugins();renderPlugins()}catch(e){toast('上传失败: '+e.message,true)}} +async function showPluginInfo(name){try{state.pluginInfo=await api('/plugins/'+encodeURIComponent(name));renderPlugins()}catch(e){toast('获取详情失败: '+e.message,true)}} +function closePluginInfo(){state.pluginInfo=null;renderPlugins()} +async function removePlugin(name){if(!confirm('确定卸载插件「'+name+'」?'))return;try{let r=await api('/plugins/'+encodeURIComponent(name),{method:'DELETE'});toast('已卸载: '+(r.status||r.name));if(r.action==='reload_required'){toast('已卸载,请点击「重载插件」生效',false)}loadInstalledPlugins();renderPlugins()}catch(e){toast('卸载失败: '+e.message,true)}} +async function reloadPlugins(){try{let r=await api('/plugins/reload',{method:'POST'});toast('插件已重载');state.kernel=await api('/kernel');renderPlugins()}catch(e){toast('重载失败: '+e.message,true)}} async function runHealthcheck(){let panel=document.getElementById('health-panel');if(!panel)return;panel.innerHTML='

运行中...

';try{let r=await api('/kernel');let tools=r?.tools||[];let healthTool=tools.find(t=>t.name==='healthcheck');if(!healthTool){panel.innerHTML='

healthcheck 工具未注册

';return}panel.innerHTML='

通过 Agent 对话触发 healthcheck...

';let chatR=await api('/chat',{method:'POST',body:JSON.stringify({message:'请运行 healthcheck 工具进行全面健康检查并报告结果'})});panel.innerHTML='
'+escHtml(JSON.stringify(chatR,null,2))+'
'}catch(e){panel.innerHTML='

错误: '+escHtml(e.message)+'

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

暂无健康检查数据

';let checks=r.checks||[];let passed=checks.filter(c=>c.pass).length;let failed=checks.filter(c=>!c.pass).length;let html='
通过: '+passed+'失败: '+failed+'总计: '+checks.length+'
';checks.forEach(c=>{let 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} diff --git a/internal/plugins/webui/handler.go b/internal/plugins/webui/handler.go index 73b18d0..97c4381 100644 --- a/internal/plugins/webui/handler.go +++ b/internal/plugins/webui/handler.go @@ -4,6 +4,7 @@ import ( "embed" "encoding/json" "fmt" + "io" "net/http" "os" "sort" @@ -103,6 +104,8 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("/api/v1/chat", h.handleChat) mux.HandleFunc("/api/v1/chat/events", h.handleChatEvents) mux.HandleFunc("/api/v1/kernel", h.handleKernel) + mux.HandleFunc("/api/v1/plugins", h.handlePlugins) + mux.HandleFunc("/api/v1/plugins/", h.handlePluginByID) mux.HandleFunc("/v1/chat/completions", h.handleOpenAICompletions) mux.HandleFunc("/", h.handleStatic) } @@ -908,6 +911,90 @@ func (h *Handler) handleTracker(w http.ResponseWriter, r *http.Request) { } } +// ======== Plugin Management (proxied to pluginmgr HTTP API) ======== + +func (h *Handler) pluginmgrAddr() string { + addr := "127.0.0.1:9876" + if h.cfgReg == nil { + return addr + } + ps := h.cfgReg.PluginConfig("pluginmgr") + if v, err := ps.Get("http_addr"); err == nil { + if s, ok := v.(string); ok && s != "" { + addr = s + } + } + return addr +} + +func (h *Handler) proxyToPluginmgr(w http.ResponseWriter, r *http.Request, path string) { + addr := h.pluginmgrAddr() + url := "http://" + addr + path + 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() + + resp, err := http.DefaultClient.Do(req) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]string{"error": 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) +} + +func (h *Handler) handlePlugins(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + h.proxyToPluginmgr(w, r, "/plugins") + case http.MethodPost: + h.proxyToPluginmgr(w, r, "/plugins") + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func (h *Handler) handlePluginByID(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/api/v1/plugins/") + path = strings.TrimSuffix(path, "/") + + if path == "reload" && r.Method == http.MethodPost { + if h.pluginReg == nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "plugin registry not available"}) + return + } + dir := "" + if h.cfgReg != nil { + if v, _ := h.cfgReg.Get("core.plugin.dir"); v != nil { + dir, _ = v.(string) + } + } + if _, err := h.pluginReg.Reload(dir); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "reloaded"}) + return + } + + switch r.Method { + case http.MethodGet: + h.proxyToPluginmgr(w, r, "/plugins/"+path) + case http.MethodDelete: + h.proxyToPluginmgr(w, r, "/plugins/"+path) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + func (h *Handler) handleStatic(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/" { w.Header().Set("Content-Type", "text/html; charset=utf-8") diff --git a/internal/plugins/webui/handler_test.go b/internal/plugins/webui/handler_test.go index 7642db6..f615f21 100644 --- a/internal/plugins/webui/handler_test.go +++ b/internal/plugins/webui/handler_test.go @@ -606,7 +606,6 @@ func TestHandleCompletionsEndToEnd(t *testing.T) { IO: iom, Memory: memDB, Indexer: nil, - MaxToolTurns: 0, ContextSavePath: "", }) agent.Start()