mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 17:38:10 +00:00
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:
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
@ -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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"')}
|
function escHtml(s){return String(s).replace(/&/g,'&').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)+'小时前'}
|
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}
|
||||||
|
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user