mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-04 00:03:59 +00:00
feat: 路径配置化 + 裸二进制启动 + pluginmgr 内置插件
- types.go: 新增 PluginDirConfig 结构体嵌入 Config - config/registry.go: 新增7个路径配置项 (core.plugin.dir 等) + ConfigDef 元数据 - cmd/homed/main.go: -data 默认自动检测二进制同级目录,使用 cfg.Plugin.Dir - internal/plugins/pluginmgr/: 内置插件实现 (4工具 + HTTP API + 包校验) - all.go: 注册 pluginmgr - manifest.go: 扩展 PluginManifest 字段 - sdk/settings.go: RegisterDef / Defs 接口 - webui: 设置页自动发现 ConfigDef 元数据 - config/config.go, config/config.yaml: 清理 YAML 死代码 - sdk/plugin.go: IO 通道泛型化支持非文本类型 - waiter: CLI 支持 socket 发现和交互模式
This commit is contained in:
@ -7,6 +7,7 @@ import (
|
||||
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/healthcheck"
|
||||
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/mcp"
|
||||
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/openclaw"
|
||||
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/pluginmgr"
|
||||
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/timer"
|
||||
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/webui"
|
||||
)
|
||||
|
||||
@ -39,7 +39,15 @@ func New(name string) *Plugin {
|
||||
func (p *Plugin) Name() string { return p.name }
|
||||
|
||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
// 从插件配置读取 MCP 服务器列表
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||
Key: "servers",
|
||||
Default: "",
|
||||
Type: "text",
|
||||
DisplayName: "MCP 服务器配置",
|
||||
Description: "MCP 服务器列表,JSON 数组格式,包含 name、command/url、args、env 等字段",
|
||||
Category: "mcp",
|
||||
})
|
||||
|
||||
cfgs, err := p.loadConfig(s)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load mcp config: %w", err)
|
||||
|
||||
551
internal/plugins/pluginmgr/plugin.go
Normal file
551
internal/plugins/pluginmgr/plugin.go
Normal file
@ -0,0 +1,551 @@
|
||||
package pluginmgr
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
|
||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
)
|
||||
|
||||
var (
|
||||
PluginDir string // 由 main.go 设置
|
||||
Reg *plugin.Registry // 由 main.go 设置
|
||||
HTTPAddr = "127.0.0.1:0" // 监听地址,可被 main.go 覆写
|
||||
)
|
||||
|
||||
func init() {
|
||||
plugin.RegisterFactory("pluginmgr", func(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
return New(name), nil
|
||||
})
|
||||
}
|
||||
|
||||
type Plugin struct {
|
||||
name string
|
||||
mu sync.Mutex
|
||||
server *http.Server
|
||||
mux *http.ServeMux
|
||||
listen net.Listener
|
||||
httpURL string
|
||||
}
|
||||
|
||||
func New(name string) *Plugin {
|
||||
return &Plugin{name: name, mux: http.NewServeMux()}
|
||||
}
|
||||
|
||||
func (p *Plugin) Name() string { return p.name }
|
||||
|
||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||
Key: "http_addr",
|
||||
Default: HTTPAddr,
|
||||
Type: "string",
|
||||
DisplayName: "HTTP 监听地址",
|
||||
Description: "插件管理 API 的监听地址,设为空可禁用 HTTP 服务",
|
||||
Category: "pluginmgr",
|
||||
})
|
||||
|
||||
if v, _ := s.Settings().Get("http_addr"); v != nil {
|
||||
if addr, ok := v.(string); ok && addr != "" {
|
||||
HTTPAddr = addr
|
||||
}
|
||||
}
|
||||
|
||||
p.registerTools(s)
|
||||
|
||||
if HTTPAddr != "" {
|
||||
p.startHTTPServer()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) Stop() error {
|
||||
if p.server != nil {
|
||||
return p.server.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ======== Tools ========
|
||||
|
||||
func (p *Plugin) registerTools(s *sdk.PluginSDK) {
|
||||
s.RegisterTool("plugin_install", sdk.ToolDef{
|
||||
Name: "plugin_install",
|
||||
Description: "从 URL 安装 HomeAgent 插件包(.hmap 文件)。安装后需调用 plgreload 或重启生效。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"url": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "插件包的下载 URL",
|
||||
},
|
||||
},
|
||||
"required": []string{"url"},
|
||||
},
|
||||
}, func(args map[string]interface{}) (interface{}, error) {
|
||||
url, _ := args["url"].(string)
|
||||
if url == "" {
|
||||
return map[string]interface{}{"error": "url is required"}, nil
|
||||
}
|
||||
return p.installFromURL(url)
|
||||
})
|
||||
|
||||
s.RegisterTool("plugin_list", sdk.ToolDef{
|
||||
Name: "plugin_list",
|
||||
Description: "列出已安装的所有外部插件及其版本",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{},
|
||||
},
|
||||
}, func(args map[string]interface{}) (interface{}, error) {
|
||||
return p.listPlugins()
|
||||
})
|
||||
|
||||
s.RegisterTool("plugin_remove", sdk.ToolDef{
|
||||
Name: "plugin_remove",
|
||||
Description: "卸载一个已安装的外部插件",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"name": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "插件名称",
|
||||
},
|
||||
},
|
||||
"required": []string{"name"},
|
||||
},
|
||||
}, func(args map[string]interface{}) (interface{}, error) {
|
||||
name, _ := args["name"].(string)
|
||||
if name == "" {
|
||||
return map[string]interface{}{"error": "name is required"}, nil
|
||||
}
|
||||
return p.removePlugin(name)
|
||||
})
|
||||
|
||||
s.RegisterTool("plugin_info", sdk.ToolDef{
|
||||
Name: "plugin_info",
|
||||
Description: "查看指定已安装插件的详细信息(版本、作者、描述等)",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"name": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "插件名称",
|
||||
},
|
||||
},
|
||||
"required": []string{"name"},
|
||||
},
|
||||
}, func(args map[string]interface{}) (interface{}, error) {
|
||||
name, _ := args["name"].(string)
|
||||
if name == "" {
|
||||
return map[string]interface{}{"error": "name is required"}, nil
|
||||
}
|
||||
return p.pluginInfo(name)
|
||||
})
|
||||
}
|
||||
|
||||
// ======== HTTP API ========
|
||||
|
||||
func (p *Plugin) startHTTPServer() {
|
||||
p.mux.HandleFunc("/plugins", p.handlePlugins)
|
||||
p.mux.HandleFunc("/plugins/", p.handlePluginByID)
|
||||
|
||||
listen, err := net.Listen("tcp", HTTPAddr)
|
||||
if err != nil {
|
||||
log.Printf("[pluginmgr] HTTP listen: %v", err)
|
||||
return
|
||||
}
|
||||
p.listen = listen
|
||||
p.httpURL = "http://" + listen.Addr().String()
|
||||
|
||||
p.server = &http.Server{Handler: p.mux}
|
||||
go func() {
|
||||
log.Printf("[pluginmgr] HTTP API on %s", p.httpURL)
|
||||
if err := p.server.Serve(listen); err != nil && err != http.ErrServerClosed {
|
||||
log.Printf("[pluginmgr] HTTP serve: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (p *Plugin) handlePlugins(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
list, err := p.listPlugins()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, list)
|
||||
|
||||
case http.MethodPost:
|
||||
ct := r.Header.Get("Content-Type")
|
||||
if strings.HasPrefix(ct, "application/json") {
|
||||
var body struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if body.URL == "" {
|
||||
http.Error(w, "url is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
result, err := p.installFromURL(body.URL)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]interface{}{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
} else {
|
||||
data, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, "read body: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
result, err := p.installFromData(data)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]interface{}{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Plugin) handlePluginByID(w http.ResponseWriter, r *http.Request) {
|
||||
name := strings.TrimPrefix(r.URL.Path, "/plugins/")
|
||||
name = strings.TrimSuffix(name, "/")
|
||||
if name == "" {
|
||||
http.Error(w, "plugin name required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
info, err := p.pluginInfo(name)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, info)
|
||||
|
||||
case http.MethodDelete:
|
||||
result, err := p.removePlugin(name)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]interface{}{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
// ======== Core Logic ========
|
||||
|
||||
func (p *Plugin) installFromURL(url string) (interface{}, error) {
|
||||
log.Printf("[pluginmgr] downloading: %s", url)
|
||||
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("download failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("download returned %s", resp.Status)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
return p.installFromData(data)
|
||||
}
|
||||
|
||||
func (p *Plugin) installFromData(data []byte) (interface{}, error) {
|
||||
pkg, err := validatePackage(data)
|
||||
if err != nil {
|
||||
return map[string]interface{}{
|
||||
"error": "invalid package",
|
||||
"details": err.Error(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
dir := PluginDir
|
||||
if dir == "" {
|
||||
return map[string]interface{}{"error": "plugin dir not configured"}, nil
|
||||
}
|
||||
|
||||
target := filepath.Join(dir, pkg.Name)
|
||||
if _, err := os.Stat(target); err == nil {
|
||||
return map[string]interface{}{
|
||||
"error": "plugin already exists",
|
||||
"name": pkg.Name,
|
||||
"version": pkg.Version,
|
||||
"action": "remove_first",
|
||||
}, nil
|
||||
}
|
||||
|
||||
if err := extractPackage(data, dir); err != nil {
|
||||
return map[string]interface{}{
|
||||
"error": "extract failed",
|
||||
"details": err.Error(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
checksum := fmt.Sprintf("%x", sha256.Sum256(data))
|
||||
|
||||
return map[string]interface{}{
|
||||
"status": "installed",
|
||||
"name": pkg.Name,
|
||||
"version": pkg.Version,
|
||||
"entry": pkg.Entry,
|
||||
"checksum": checksum,
|
||||
"action": "reload_required",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) listPlugins() (interface{}, error) {
|
||||
dir := PluginDir
|
||||
if dir == "" {
|
||||
return []map[string]interface{}{}, nil
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return []map[string]interface{}{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var plugins []map[string]interface{}
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
m, err := plugin.ReadManifest(filepath.Join(dir, entry.Name()))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
plugins = append(plugins, map[string]interface{}{
|
||||
"name": m.Name,
|
||||
"version": m.Version,
|
||||
"description": m.Description,
|
||||
"author": m.Author,
|
||||
"entry": m.Entry,
|
||||
"deprecated": m.Deprecated,
|
||||
})
|
||||
}
|
||||
if plugins == nil {
|
||||
plugins = []map[string]interface{}{}
|
||||
}
|
||||
return plugins, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) removePlugin(name string) (interface{}, error) {
|
||||
dir := filepath.Join(PluginDir, name)
|
||||
if _, err := os.Stat(dir); os.IsNotExist(err) {
|
||||
return map[string]interface{}{"error": "plugin not found", "name": name}, nil
|
||||
}
|
||||
|
||||
if err := os.RemoveAll(dir); err != nil {
|
||||
return map[string]interface{}{"error": err.Error()}, nil
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"status": "removed",
|
||||
"name": name,
|
||||
"action": "reload_required",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) pluginInfo(name string) (interface{}, error) {
|
||||
dir := filepath.Join(PluginDir, name)
|
||||
m, err := plugin.ReadManifest(dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("plugin %q not found", name)
|
||||
}
|
||||
info := map[string]interface{}{
|
||||
"name": m.Name,
|
||||
"version": m.Version,
|
||||
"description": m.Description,
|
||||
"author": m.Author,
|
||||
"license": m.License,
|
||||
"homepage": m.Homepage,
|
||||
"repository": m.Repository,
|
||||
"entry": m.Entry,
|
||||
"min_version": m.MinVersion,
|
||||
"tags": m.Tags,
|
||||
"deprecated": m.Deprecated,
|
||||
}
|
||||
|
||||
entries, _ := os.ReadDir(dir)
|
||||
var files []string
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
files = append(files, e.Name())
|
||||
}
|
||||
}
|
||||
info["files"] = files
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// ======== Package Validation ========
|
||||
|
||||
type pluginPackage struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Entry string `json:"entry"`
|
||||
}
|
||||
|
||||
func validatePackage(data []byte) (*pluginPackage, error) {
|
||||
if len(data) > 100<<20 {
|
||||
return nil, fmt.Errorf("package too large (>100MB)")
|
||||
}
|
||||
|
||||
reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid zip: %w", err)
|
||||
}
|
||||
|
||||
var pkg pluginPackage
|
||||
hasManifest := false
|
||||
|
||||
for _, f := range reader.File {
|
||||
if strings.Contains(f.Name, "..") || strings.HasPrefix(f.Name, "/") {
|
||||
return nil, fmt.Errorf("invalid path: %s", f.Name)
|
||||
}
|
||||
if f.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
if f.Name != "plugin.json" {
|
||||
continue
|
||||
}
|
||||
hasManifest = true
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read manifest: %w", err)
|
||||
}
|
||||
mData, err := io.ReadAll(rc)
|
||||
rc.Close()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read manifest: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(mData, &pkg); err != nil {
|
||||
return nil, fmt.Errorf("parse manifest: %w", err)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
if !hasManifest {
|
||||
return nil, fmt.Errorf("missing plugin.json")
|
||||
}
|
||||
if pkg.Name == "" {
|
||||
return nil, fmt.Errorf("manifest: name required")
|
||||
}
|
||||
if pkg.Version == "" {
|
||||
return nil, fmt.Errorf("manifest: version required")
|
||||
}
|
||||
if pkg.Entry == "" {
|
||||
return nil, fmt.Errorf("manifest: entry required")
|
||||
}
|
||||
|
||||
hasEntry := false
|
||||
for _, f := range reader.File {
|
||||
if f.Name == pkg.Entry && !f.FileInfo().IsDir() {
|
||||
hasEntry = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasEntry {
|
||||
return nil, fmt.Errorf("entry %q not found in package", pkg.Entry)
|
||||
}
|
||||
|
||||
valid := map[string]bool{"plugin.so": true, "main.lua": true, "SKILL.md": true}
|
||||
if !valid[pkg.Entry] {
|
||||
return nil, fmt.Errorf("unsupported entry: %q", pkg.Entry)
|
||||
}
|
||||
|
||||
return &pkg, nil
|
||||
}
|
||||
|
||||
func extractPackage(data []byte, pluginDir string) error {
|
||||
reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 先读 manifest 确定插件名
|
||||
var pkgName string
|
||||
for _, f := range reader.File {
|
||||
if f.Name == "plugin.json" && !f.FileInfo().IsDir() {
|
||||
rc, _ := f.Open()
|
||||
mData, _ := io.ReadAll(rc)
|
||||
rc.Close()
|
||||
var m struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
json.Unmarshal(mData, &m)
|
||||
pkgName = m.Name
|
||||
break
|
||||
}
|
||||
}
|
||||
if pkgName == "" {
|
||||
return fmt.Errorf("cannot determine plugin name")
|
||||
}
|
||||
|
||||
target := filepath.Join(pluginDir, pkgName)
|
||||
os.MkdirAll(target, 0755)
|
||||
|
||||
for _, f := range reader.File {
|
||||
fpath := filepath.Join(target, f.Name)
|
||||
if !strings.HasPrefix(filepath.Clean(fpath), filepath.Clean(target)+string(os.PathSeparator)) {
|
||||
return fmt.Errorf("path traversal: %s", f.Name)
|
||||
}
|
||||
if f.FileInfo().IsDir() {
|
||||
os.MkdirAll(fpath, 0755)
|
||||
continue
|
||||
}
|
||||
os.MkdirAll(filepath.Dir(fpath), 0755)
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return fmt.Errorf("open %s: %w", f.Name, err)
|
||||
}
|
||||
out, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
|
||||
if err != nil {
|
||||
rc.Close()
|
||||
return fmt.Errorf("create %s: %w", f.Name, err)
|
||||
}
|
||||
io.Copy(out, rc)
|
||||
rc.Close()
|
||||
out.Close()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
@ -110,11 +110,11 @@ code{font-family:monospace;font-size:12px;color:#a5b4fc}
|
||||
</div>
|
||||
<div id="toast" class="toast"></div>
|
||||
<script>
|
||||
let state={status:{},kernel:null,settings:{},settingsPlugins:['core'],selectedSection:'core',messages:[],chatLoading:false,healthResult:null};
|
||||
let state={status:{},kernel:null,settings:{},meta:{},settingsPlugins:['core'],selectedSection:'core',messages:[],chatLoading:false,healthResult:null};
|
||||
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.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){}renderOverview();renderChat();renderPlugins();renderMemory();renderKnowledge();renderKernel()}
|
||||
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)+'小时前'}
|
||||
|
||||
@ -142,9 +142,9 @@ async function createKnowledge(){let name=document.getElementById('know-name')?.
|
||||
|
||||
// === Settings ===
|
||||
function renderSettingsSidebar(){let el=document.querySelector('.settings-sidebar');if(!el)return;el.innerHTML='';state.settingsPlugins.forEach(function(p){let a=document.createElement('a');a.textContent=p;if(p===state.selectedSection)a.className='active';a.onclick=function(){state.selectedSection=p;renderOneSettings()};el.appendChild(a)})}
|
||||
function renderOneSettings(){let prefix=state.selectedSection+'.';let filtered=Object.keys(state.settings||{}).filter(k=>k===prefix.slice(0,-1)||k.startsWith(prefix));filtered.sort();let html='<div class="settings-layout"><div class="settings-sidebar"></div><div class="settings-content">';if(filtered.length===0){html+='<div class="card"><h2>'+escHtml(state.selectedSection)+'</h2><p style="color:#94a3b8">暂无设置项</p></div>'}else{filtered.forEach(k=>{let v=state.settings[k];let sv=typeof v==='object'?JSON.stringify(v):String(v);html+='<div class="card"><button class="btn btn-primary btn-sm save-btn" onclick="saveSetting(\''+k+'\')" style="float:right;margin-top:-4px">保存</button><div class="settings-key">'+escHtml(k)+'</div><label>值</label><input id="inp-'+k.replace(/\./g,'_')+'" value="'+escHtml(sv)+'" onchange="markDirty(\''+k+'\')"></div>'})}html+='</div></div>';document.getElementById('tab-settings').innerHTML=html;renderSettingsSidebar()}
|
||||
function renderOneSettings(){let prefix=state.selectedSection+'.';let filtered=Object.keys(state.settings||{}).filter(k=>k===prefix.slice(0,-1)||k.startsWith(prefix));filtered.sort();let html='<div class="settings-layout"><div class="settings-sidebar"></div><div class="settings-content">';if(filtered.length===0){html+='<div class="card"><h2>'+escHtml(state.selectedSection)+'</h2><p style="color:#94a3b8">暂无设置项</p></div>'}else{filtered.forEach(k=>{let v=state.settings[k];let sv=typeof v==='object'?JSON.stringify(v):String(v);let m=state.meta?.[k];let label=m?.display_name||'值';let desc=m?.description||'';let typ=m?.type||'string';let ph=m?.placeholder||'';let opts=m?.options||[];let inpId='inp-'+k.replace(/\./g,'_');let inp='';if(typ==='bool'){let chk=sv==='true'?'checked':'';inp='<label style="display:flex;align-items:center;gap:8px;cursor:pointer"><input type="checkbox" id="'+inpId+'" '+chk+' onchange="markDirty(\''+k+'\')" style="width:auto;margin:0"> '+label+'</label>'}else if(typ==='password'){inp='<label>'+label+'</label><input type="password" id="'+inpId+'" value="'+escHtml(sv)+'" placeholder="'+escHtml(ph)+'" onchange="markDirty(\''+k+'\')">'}else if(typ==='text'){inp='<label>'+label+'</label><textarea id="'+inpId+'" onchange="markDirty(\''+k+'\')">'+escHtml(sv)+'</textarea>'}else if(typ==='select'&&opts.length>0){let sel='<label>'+label+'</label><select id="'+inpId+'" onchange="markDirty(\''+k+'\')">';opts.forEach(o=>{sel+='<option value="'+escHtml(o)+'"'+(sv===o?' selected':'')+'>'+escHtml(o)+'</option>'});sel+='</select>';inp=sel}else if(typ==='int'){inp='<label>'+label+'</label><input type="number" id="'+inpId+'" value="'+escHtml(sv)+'" placeholder="'+escHtml(ph)+'" onchange="markDirty(\''+k+'\')">'}else{inp='<label>'+label+'</label><input id="'+inpId+'" value="'+escHtml(sv)+'" placeholder="'+escHtml(ph)+'" onchange="markDirty(\''+k+'\')">'}html+='<div class="card"><button class="btn btn-primary btn-sm save-btn" onclick="saveSetting(\''+k+'\')" style="float:right;margin-top:-4px">保存</button><div class="settings-key">'+escHtml(k)+'</div>'+inp;if(desc){html+='<p style="color:#64748b;font-size:11px;margin-top:-6px">'+escHtml(desc)+'</p>'}html+='</div>'})}html+='</div></div>';document.getElementById('tab-settings').innerHTML=html;renderSettingsSidebar()}
|
||||
function markDirty(k){let inp=document.getElementById('inp-'+k.replace(/\./g,'_'));if(inp)inp.style.borderColor='#eab308'}
|
||||
async function saveSetting(k){let inp=document.getElementById('inp-'+k.replace(/\./g,'_'));if(!inp)return;let raw=inp.value;let val;try{val=JSON.parse(raw)}catch(e){val=raw}try{let r=await api('/settings',{method:'PUT',body:JSON.stringify({key:k,value:val})});if(r.status==='ok'){inp.style.borderColor='';state.settings[k]=val;toast('已保存: '+k)}else{toast('保存失败: '+(r.error||'unknown'),true)}}catch(e){toast('保存失败: '+e.message,true)}}
|
||||
async function saveSetting(k){let inp=document.getElementById('inp-'+k.replace(/\./g,'_'));if(!inp)return;let val;let m=state.meta?.[k];if(m?.type==='bool'){val=inp.checked?'true':'false'}else if(m?.type==='select'){val=inp.value}else{let raw=inp.value;try{val=JSON.parse(raw)}catch(e){val=raw}}try{let r=await api('/settings',{method:'PUT',body:JSON.stringify({key:k,value:val})});if(r.status==='ok'){inp.style.borderColor='';state.settings[k]=val;toast('已保存: '+k)}else{toast('保存失败: '+(r.error||'unknown'),true)}}catch(e){toast('保存失败: '+e.message,true)}}
|
||||
function renderConfigDisabled(){document.getElementById('tab-settings').innerHTML='<div class="card"><h2>设置</h2><p style="color:#94a3b8">设置面板已加载</p></div>';renderOneSettings()}
|
||||
|
||||
// === Kernel ===
|
||||
|
||||
@ -644,12 +644,35 @@ func (h *Handler) handleSettings(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
prefix := r.URL.Query().Get("prefix")
|
||||
keys := h.cfgReg.List(prefix)
|
||||
values := make(map[string]interface{})
|
||||
for _, k := range keys {
|
||||
v, _ := h.cfgReg.Get(k)
|
||||
values[k] = v
|
||||
meta := make(map[string]*internalConfig.ConfigDef)
|
||||
|
||||
if strings.HasPrefix(prefix, "plugin.") {
|
||||
// 插件配置:从插件自身 config_<name> 表读取
|
||||
pluginName := prefix[7:]
|
||||
ps := h.cfgReg.PluginConfig(pluginName)
|
||||
keys, _ := ps.List("")
|
||||
for _, k := range keys {
|
||||
v, _ := ps.Get(k)
|
||||
fullKey := prefix + "." + k
|
||||
values[fullKey] = v
|
||||
if def := h.cfgReg.GetDef(fullKey); def != nil {
|
||||
meta[fullKey] = def
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 核心配置:从 core config 表读取
|
||||
keys := h.cfgReg.List(prefix)
|
||||
for _, k := range keys {
|
||||
v, _ := h.cfgReg.Get(k)
|
||||
values[k] = v
|
||||
}
|
||||
defs := h.cfgReg.ListDefs(prefix)
|
||||
for _, d := range defs {
|
||||
meta[d.Key] = d
|
||||
}
|
||||
}
|
||||
|
||||
plugins := []string{"core"}
|
||||
if h.pluginReg != nil {
|
||||
for _, p := range h.pluginReg.List() {
|
||||
@ -659,6 +682,7 @@ func (h *Handler) handleSettings(w http.ResponseWriter, r *http.Request) {
|
||||
sort.Strings(plugins)
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"settings": values,
|
||||
"meta": meta,
|
||||
"plugins": plugins,
|
||||
})
|
||||
case http.MethodPut:
|
||||
@ -670,9 +694,20 @@ func (h *Handler) handleSettings(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
if err := h.cfgReg.Set(body.Key, body.Value); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
if strings.HasPrefix(body.Key, "plugin.") {
|
||||
parts := strings.SplitN(body.Key, ".", 3)
|
||||
if len(parts) >= 3 {
|
||||
ps := h.cfgReg.PluginConfig(parts[1])
|
||||
if err := ps.Set(parts[2], body.Value); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if err := h.cfgReg.Set(body.Key, body.Value); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
default:
|
||||
|
||||
Reference in New Issue
Block a user