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
This commit is contained in:
root
2026-07-06 20:32:04 +08:00
parent b55f1dcf0e
commit eb55a8fb98
8 changed files with 455 additions and 16 deletions

View File

@ -368,7 +368,6 @@ func main() {
Indexer: memIdx, Indexer: memIdx,
Skills: skMgr, Skills: skMgr,
Tracker: trk, Tracker: trk,
MaxToolTurns: 10,
DocStore: docStore, DocStore: docStore,
Knowledge: ks, Knowledge: ks,
SocialStore: socialStore, SocialStore: socialStore,
@ -395,6 +394,9 @@ func main() {
pluginmgr.PluginDir = cfg.Plugin.Dir pluginmgr.PluginDir = cfg.Plugin.Dir
pluginmgr.Reg = pluginReg pluginmgr.Reg = pluginReg
// CLI 插件结构化命令 — 直接注入内核依赖,不依赖 HTTP
cli.Configure(pluginReg, cfgReg, agent, cfg.Plugin.Dir)
// Auto-create plugins directory (without hardcoding plugin names) // Auto-create plugins directory (without hardcoding plugin names)
os.MkdirAll(cfg.Plugin.Dir, 0755) os.MkdirAll(cfg.Plugin.Dir, 0755)

View File

@ -378,15 +378,29 @@ func handleBuiltin(cmd string, mode, addr *string, reconnect func()) bool {
switch { switch {
case cmd == "/help": case cmd == "/help":
fmt.Println(`Built-in commands: fmt.Println(`Built-in commands:
/help show this help /help show this help
/exit, /quit exit waiter /exit, /quit exit waiter
/clear clear screen /clear clear screen
/reconnect force reconnection /reconnect force reconnection
/connect <path> switch to a different unix socket /connect <path> switch to a different unix socket
/remote <url> switch to remote HTTP mode /remote <url> switch to remote HTTP mode
/local switch back to local socket 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 <k> <v> set a setting
/plugin list list installed plugins
/plugin install <url> install plugin
/plugin remove <name> remove plugin
/plugin info <name> plugin details
/memory query <text> query graph memory
/knowledge list knowledge base
/agents list agents
/chat <text> send to agent
Any other text is sent to the agent.`)
return true return true
case cmd == "/exit" || cmd == "/quit": case cmd == "/exit" || cmd == "/quit":
@ -580,7 +594,10 @@ func (e *LineEditor) historyNext() {
} }
func (e *LineEditor) doCompletion() { 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) prefix := string(e.buf)
for _, c := range cmds { for _, c := range cmds {
if strings.HasPrefix(c, prefix) && c != prefix { if strings.HasPrefix(c, prefix) && c != prefix {

View File

@ -8,16 +8,35 @@ import (
"net" "net"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strings"
"sync" "sync"
agentCore "gitcode.com/JianFeeeee/HomeAgent/internal/agent/core"
internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config"
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin" "gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
) )
// DefaultSocket 由 main.go 在 Load() 前设置,覆盖默认 socket 路径。 // DefaultSocket 由 main.go 在 Load() 前设置,覆盖默认 socket 路径。
// 若为空factory 使用 "<dataDir>/cli.sock"。
var DefaultSocket string 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() { func init() {
plugin.RegisterFactory("cli", func(name string, config map[string]interface{}) (sdk.Plugin, error) { plugin.RegisterFactory("cli", func(name string, config map[string]interface{}) (sdk.Plugin, error) {
sock := DefaultSocket sock := DefaultSocket
@ -95,6 +114,12 @@ func (p *Plugin) handleConn(conn net.Conn, s *sdk.PluginSDK) {
continue continue
} }
if line[0] == '/' {
if p.handleBuiltin(conn, line, s) {
continue
}
}
resp := s.InjectTextSync("cli", "cli", line) resp := s.InjectTextSync("cli", "cli", line)
if resp != nil { if resp != nil {
content, _ := resp.Payload["content"].(string) 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 <key> <val> 修改配置项
/settings core.llm 按前缀筛选
/plugin list 列出已安装插件
/plugin install <url> 安装插件(需回环网络)
/plugin remove <name> 卸载插件
/plugin info <name> 查看插件详情
/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 <key> <value>"})
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 <url>|remove <name>|info <name>"})
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 <url>"})
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 <name>"})
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 <name>"})
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{}) { func writeLine(conn net.Conn, v interface{}) {
data, err := json.Marshal(v) data, err := json.Marshal(v)
if err != nil { if err != nil {

View File

@ -45,7 +45,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
}, },
"workdir": map[string]interface{}{ "workdir": map[string]interface{}{
"type": "string", "type": "string",
"description": "工作目录(可选,默认当前目录", "description": "工作目录(可选,默认由 core.agent.workdir 配置决定",
}, },
}, },
"required": []string{"command"}, "required": []string{"command"},
@ -66,6 +66,15 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
} }
workdir, _ := args["workdir"].(string) 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) ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel() defer cancel()

View File

@ -22,7 +22,7 @@ import (
var ( var (
PluginDir string // 由 main.go 设置 PluginDir string // 由 main.go 设置
Reg *plugin.Registry // 由 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() { func init() {

View File

@ -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()} 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 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)} 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;')} function escHtml(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;')}
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)+'小时前'} 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='<div class="card"><h2>
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='发送'}} 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 === // === Plugins ===
function renderPlugins(){let k=state.kernel;let plugins=k?.plugins||[];let tools=k?.tools||[];let html='<div class="card"><h2>已加载插件 ('+plugins.length+')</h2>';if(plugins.length===0){html+='<div class="empty-state"><p>暂无已加载插件</p></div>'}else{html+='<table><tr><th>名称</th><th>状态</th></tr>';plugins.forEach(p=>{html+='<tr><td>'+escHtml(p.name)+'</td><td><span class="badge badge-green">已加载</span></td></tr>'});html+='</table>'}html+='</div>';if(tools.length>0){html+='<div class="card"><h2>已注册工具 ('+tools.length+')</h2><div style="display:flex;flex-wrap:wrap;gap:4px">';tools.forEach(t=>{html+='<span class="tool-badge" title="'+escHtml(t.description||'')+'">'+escHtml(t.name)+'</span>'});html+='</div></div>'}html+='<div class="card"><h2>健康检查</h2><div id="health-panel">';if(state.healthResult){html+=renderHealthResult(state.healthResult)}else{html+='<button class="btn btn-primary" onclick="runHealthcheck()">运行健康检查</button>'}html+='</div></div>';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='<div class="card"><h2>安装插件</h2><div style="display:flex;gap:8px;margin-bottom:8px"><input id="plugin-url" placeholder=".hmap 包下载 URL" style="flex:1" onkeydown="if(event.key==\'Enter\')installPlugin()"><button class="btn btn-primary" onclick="installPlugin()">安装</button></div><div><input type="file" id="plugin-file" accept=".hmap" style="display:inline;width:auto" onchange="installPluginFile(this.files[0])"><label for="plugin-file" class="btn btn-ghost" style="cursor:pointer">选择 .hmap 文件上传</label></div></div>'
html+='<div class="card"><h2>已加载插件 ('+plugins.length+')</h2>';if(plugins.length===0){html+='<div class="empty-state"><p>暂无已加载插件</p></div>'}else{html+='<table><tr><th>名称</th><th>状态</th></tr>';plugins.forEach(p=>{html+='<tr><td>'+escHtml(p.name)+'</td><td><span class="badge badge-green">已加载</span></td></tr>'});html+='</table>'}html+='</div>'
if(installed.length>0){html+='<div class="card"><h2>已安装外部插件 ('+installed.length+')</h2><table><tr><th>名称</th><th>版本</th><th>描述</th><th>操作</th></tr>';installed.forEach(p=>{html+='<tr><td>'+escHtml(p.name)+'</td><td>'+escHtml(p.version||'-')+'</td><td>'+escHtml((p.description||'').substring(0,50))+'</td><td><button class="btn btn-sm btn-ghost" onclick="showPluginInfo(\''+escHtml(p.name)+'\')">详情</button> <button class="btn btn-sm btn-danger" onclick="removePlugin(\''+escHtml(p.name)+'\')">卸载</button></td></tr>'});html+='</table></div>'}
if(state.pluginInfo){html+='<div class="card"><h2>插件详情: '+escHtml(state.pluginInfo.name)+'</h2><pre>'+escHtml(JSON.stringify(state.pluginInfo,null,2))+'</pre><button class="btn btn-ghost" onclick="closePluginInfo()">关闭</button></div>'}
if(tools.length>0){html+='<div class="card"><h2>已注册工具 ('+tools.length+')</h2><div style="display:flex;flex-wrap:wrap;gap:4px">';tools.forEach(t=>{html+='<span class="tool-badge" title="'+escHtml(t.description||'')+'">'+escHtml(t.name)+'</span>'});html+='</div></div>'}
html+='<div class="card"><h2>系统操作</h2><button class="btn btn-primary" onclick="reloadPlugins()" style="margin-right:8px">重载插件</button><button class="btn btn-ghost" onclick="runHealthcheck()" style="margin-right:8px">健康检查</button></div>'
html+='<div class="card"><h2>健康检查</h2><div id="health-panel">';if(state.healthResult){html+=renderHealthResult(state.healthResult)}else{html+='<p style="color:#64748b;font-size:13px">点击上方按钮运行</p>'}html+='</div></div>'
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='<div class="loading" style="margin:12px auto"></div><p style="text-align:center;color:#64748b">运行中...</p>';try{let r=await api('/kernel');let tools=r?.tools||[];let healthTool=tools.find(t=>t.name==='healthcheck');if(!healthTool){panel.innerHTML='<p style="color:#94a3b8">healthcheck 工具未注册</p>';return}panel.innerHTML='<p style="color:#94a3b8">通过 Agent 对话触发 healthcheck...</p>';let chatR=await api('/chat',{method:'POST',body:JSON.stringify({message:'请运行 healthcheck 工具进行全面健康检查并报告结果'})});panel.innerHTML='<pre>'+escHtml(JSON.stringify(chatR,null,2))+'</pre>'}catch(e){panel.innerHTML='<p style="color:#fca5a5">错误: '+escHtml(e.message)+'</p>';toast('健康检查失败: '+e.message,true)}} async function runHealthcheck(){let panel=document.getElementById('health-panel');if(!panel)return;panel.innerHTML='<div class="loading" style="margin:12px auto"></div><p style="text-align:center;color:#64748b">运行中...</p>';try{let r=await api('/kernel');let tools=r?.tools||[];let healthTool=tools.find(t=>t.name==='healthcheck');if(!healthTool){panel.innerHTML='<p style="color:#94a3b8">healthcheck 工具未注册</p>';return}panel.innerHTML='<p style="color:#94a3b8">通过 Agent 对话触发 healthcheck...</p>';let chatR=await api('/chat',{method:'POST',body:JSON.stringify({message:'请运行 healthcheck 工具进行全面健康检查并报告结果'})});panel.innerHTML='<pre>'+escHtml(JSON.stringify(chatR,null,2))+'</pre>'}catch(e){panel.innerHTML='<p style="color:#fca5a5">错误: '+escHtml(e.message)+'</p>';toast('健康检查失败: '+e.message,true)}}
function renderHealthResult(r){if(!r||!r.checks)return '<p style="color:#94a3b8">暂无健康检查数据</p>';let checks=r.checks||[];let passed=checks.filter(c=>c.pass).length;let failed=checks.filter(c=>!c.pass).length;let html='<div style="margin-bottom:12px;display:flex;gap:16px;align-items:center"><span class="badge badge-green">通过: '+passed+'</span><span class="badge '+(failed>0?'badge-red':'badge-green')+'">失败: '+failed+'</span><span class="badge badge-blue">总计: '+checks.length+'</span></div>';checks.forEach(c=>{let passClass=c.pass?'check-pass':'check-fail';if(c.status==='skip')passClass='check-skip';html+='<div class="health-item"><span class="check-name">'+escHtml(c.name)+'</span><span class="check-status '+passClass+'">'+(c.status||'unknown')+'</span><span style="color:#64748b;font-size:11px">'+escHtml(c.detail||'')+'</span></div>'});return html} function renderHealthResult(r){if(!r||!r.checks)return '<p style="color:#94a3b8">暂无健康检查数据</p>';let checks=r.checks||[];let passed=checks.filter(c=>c.pass).length;let failed=checks.filter(c=>!c.pass).length;let html='<div style="margin-bottom:12px;display:flex;gap:16px;align-items:center"><span class="badge badge-green">通过: '+passed+'</span><span class="badge '+(failed>0?'badge-red':'badge-green')+'">失败: '+failed+'</span><span class="badge badge-blue">总计: '+checks.length+'</span></div>';checks.forEach(c=>{let passClass=c.pass?'check-pass':'check-fail';if(c.status==='skip')passClass='check-skip';html+='<div class="health-item"><span class="check-name">'+escHtml(c.name)+'</span><span class="check-status '+passClass+'">'+(c.status||'unknown')+'</span><span style="color:#64748b;font-size:11px">'+escHtml(c.detail||'')+'</span></div>'});return html}

View File

@ -4,6 +4,7 @@ import (
"embed" "embed"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"net/http" "net/http"
"os" "os"
"sort" "sort"
@ -103,6 +104,8 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/chat", h.handleChat) mux.HandleFunc("/api/v1/chat", h.handleChat)
mux.HandleFunc("/api/v1/chat/events", h.handleChatEvents) mux.HandleFunc("/api/v1/chat/events", h.handleChatEvents)
mux.HandleFunc("/api/v1/kernel", h.handleKernel) 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("/v1/chat/completions", h.handleOpenAICompletions)
mux.HandleFunc("/", h.handleStatic) 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) { func (h *Handler) handleStatic(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" { if r.URL.Path == "/" {
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")

View File

@ -606,7 +606,6 @@ func TestHandleCompletionsEndToEnd(t *testing.T) {
IO: iom, IO: iom,
Memory: memDB, Memory: memDB,
Indexer: nil, Indexer: nil,
MaxToolTurns: 0,
ContextSavePath: "", ContextSavePath: "",
}) })
agent.Start() agent.Start()