feat: restructure plugin system, add Lua plugin support, update docs

This commit is contained in:
JianFeeeee
2026-07-13 21:48:13 +08:00
parent e49bdca9ff
commit 950090959f
44 changed files with 3098 additions and 1097 deletions

View File

@ -0,0 +1,263 @@
//go:build windows
package plugin
import (
"encoding/json"
"os"
"path/filepath"
"runtime"
"syscall"
"testing"
"unsafe"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
func TestBridgeE2E_WebPlugin(t *testing.T) {
exeDir, _ := os.Executable()
// Find web example build relative to the homeagent repo root
haRoot := findHomeAgentRoot(t, exeDir)
dllPath := filepath.Join(haRoot, "..", "homeagentsdk", "example", "web", "build", "plugin.dll")
if _, err := os.Stat(dllPath); os.IsNotExist(err) {
t.Fatalf("web plugin DLL not found at %s\nRun: cd example/web && plugindev build --target windows/amd64", dllPath)
}
// Track captured tools and stages
var capturedTools []sdk.ToolDef
var capturedStages []sdk.Stage
regTool := func(name string, def sdk.ToolDef, handler sdk.ToolHandler) error {
capturedTools = append(capturedTools, def)
t.Logf(" registered tool: %s", name)
return nil
}
regStage := func(stage sdk.Stage, handler sdk.StageHandler) {
capturedStages = append(capturedStages, stage)
t.Logf(" registered stage: %s", stage)
}
regAPI := func(name string) error {
t.Logf(" registered API: %s", name)
return nil
}
sett := sdk.NewSettings("web", nil)
psdk := sdk.New("web", nil, nil, nil, nil, nil, nil, nil, sett, regTool, regStage, regAPI)
plg, err := newDLLPlugin(dllPath, "web", nil)
if err != nil {
t.Fatalf("newDLLPlugin failed: %v", err)
}
defer plg.Stop()
// Start — this calls NewPlugin + StartPlugin + registerTools + registerStages
if err := plg.Start(psdk); err != nil {
t.Fatalf("Start failed: %v", err)
}
// Verify tools were captured
if len(capturedTools) == 0 {
t.Fatal("no tools were registered by web plugin")
}
t.Logf("Captured %d tools:", len(capturedTools))
for _, d := range capturedTools {
t.Logf(" - %s: %s", d.Name, d.Description[:min(len(d.Description), 60)])
}
// Check specific expected tools
webSearch, webFetch := false, false
for _, d := range capturedTools {
if d.Name == "web_search" {
webSearch = true
if d.Description == "" {
t.Error("web_search has empty description")
}
params := d.Parameters
if params == nil {
t.Error("web_search has nil parameters")
} else {
if _, ok := params["properties"]; !ok {
t.Error("web_search parameters missing 'properties'")
}
}
}
if d.Name == "web_fetch" {
webFetch = true
}
}
if !webSearch {
t.Error("expected tool 'web_search' not registered")
}
if !webFetch {
t.Error("expected tool 'web_fetch' not registered")
}
// Verify bridge exports work via direct C ABI calls
t.Logf("Bridge exports: getTools=%x invokeTool=%x freeCStr=%x",
plg.getTools, plg.invokeTool, plg.freeCStr)
// GetToolDefsJSON
if plg.getTools != 0 {
toolDefsJSON := callGetToolDefsJSON(t, plg)
if len(toolDefsJSON) == 0 {
t.Error("GetToolDefsJSON returned empty array, expected tools")
}
for _, d := range toolDefsJSON {
t.Logf(" bridge tool: %s", d["name"])
}
}
// InvokeToolJSON — test with the search tool
if plg.invokeTool != 0 {
result := callInvokeToolJSON(t, plg, "web_search", map[string]interface{}{
"query": "test",
"count": 1,
})
t.Logf("InvokeToolJSON result keys: %v", keysOfMap(result))
// Should get a result map (might be error if no network, but should not crash)
if errStr, ok := result["error"]; ok {
t.Logf(" (expected — tool returned error: %v)", errStr)
}
}
}
func TestBridgeE2E_SanitizerStages(t *testing.T) {
exeDir, _ := os.Executable()
haRoot := findHomeAgentRoot(t, exeDir)
dllPath := filepath.Join(haRoot, "..", "homeagentsdk", "example", "sanitizer", "build", "plugin.dll")
if _, err := os.Stat(dllPath); os.IsNotExist(err) {
t.Skip("sanitizer DLL not built")
}
var capturedStages []sdk.Stage
regStage := func(stage sdk.Stage, handler sdk.StageHandler) {
capturedStages = append(capturedStages, stage)
t.Logf(" registered stage: %s", stage)
}
sett := sdk.NewSettings("sanitizer", nil)
psdk := sdk.New("sanitizer", nil, nil, nil, nil, nil, nil, nil, sett,
func(name string, def sdk.ToolDef, handler sdk.ToolHandler) error { return nil },
regStage,
func(name string) error { return nil },
)
plg, err := newDLLPlugin(dllPath, "sanitizer", nil)
if err != nil {
t.Fatalf("newDLLPlugin failed: %v", err)
}
defer plg.Stop()
if err := plg.Start(psdk); err != nil {
t.Fatalf("Start failed: %v", err)
}
if len(capturedStages) == 0 {
t.Fatal("no stages registered by sanitizer")
}
found := false
for _, s := range capturedStages {
if s == sdk.StagePostAction {
found = true
}
}
if !found {
t.Fatalf("expected post_action stage, got %v", capturedStages)
}
// Verify bridge GetStagesJSON
if plg.getStages != 0 {
ret, _, _ := syscall.SyscallN(plg.getStages, plg.handle)
if ret != 0 {
stagesJSON := cStringPtrToString(ret)
if plg.freeCStr != 0 {
syscall.SyscallN(plg.freeCStr, ret)
}
t.Logf("GetStagesJSON: %s", stagesJSON)
if !contains(t, stagesJSON, "post_action") {
t.Error("GetStagesJSON missing post_action")
}
}
}
}
// --- helpers ---
func findHomeAgentRoot(t *testing.T, exeDir string) string {
t.Helper()
// Walk up from test binary directory looking for homeagent/
dir := exeDir
for i := 0; i < 10; i++ {
if _, err := os.Stat(filepath.Join(dir, "internal", "plugin")); err == nil {
return dir
}
parent := filepath.Dir(dir)
if parent == dir {
break
}
dir = parent
}
t.Fatal("cannot find homeagent root")
return ""
}
func callGetToolDefsJSON(t *testing.T, plg *dllPlugin) []map[string]interface{} {
t.Helper()
ret, _, _ := syscall.SyscallN(plg.getTools, plg.handle)
if ret == 0 {
t.Fatal("GetToolDefsJSON returned nil")
}
jsonStr := cStringPtrToString(ret)
if plg.freeCStr != 0 {
syscall.SyscallN(plg.freeCStr, ret)
}
var defs []map[string]interface{}
if err := json.Unmarshal([]byte(jsonStr), &defs); err != nil {
t.Fatalf("GetToolDefsJSON parse error: %v", err)
}
return defs
}
func callInvokeToolJSON(t *testing.T, plg *dllPlugin, toolName string, args map[string]interface{}) map[string]interface{} {
t.Helper()
argsJSON, _ := json.Marshal(args)
cToolName := append([]byte(toolName), 0)
cArgs := append(argsJSON, 0)
ret, _, _ := syscall.SyscallN(
plg.invokeTool,
plg.handle,
uintptr(unsafe.Pointer(&cToolName[0])),
uintptr(unsafe.Pointer(&cArgs[0])),
)
if ret == 0 {
t.Fatal("InvokeToolJSON returned nil")
}
jsonStr := cStringPtrToString(ret)
if plg.freeCStr != 0 {
syscall.SyscallN(plg.freeCStr, ret)
}
var result map[string]interface{}
if err := json.Unmarshal([]byte(jsonStr), &result); err != nil {
t.Fatalf("InvokeToolJSON parse error: %v (json=%s)", err, jsonStr)
}
return result
}
func keysOfMap(m map[string]interface{}) []string {
var keys []string
for k := range m {
keys = append(keys, k)
}
return keys
}
func contains(t *testing.T, s, substr string) bool {
t.Helper()
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}

View File

@ -21,6 +21,7 @@ import (
// }
const (
soEntry = "plugin.so"
dllEntry = "plugin.dll"
luaEntry = "main.lua"
metaEntry = "plugin.json"
)
@ -112,15 +113,3 @@ func tryLoadSO(dir, name string, config map[string]interface{}) (sdk.Plugin, err
return &dynamicPlugin{name: name, impl: plg}, nil
}
// tryLoadLua 尝试从插件目录加载 main.luaLua 插件)。
// 返回 nil,nil 表示目录中没有 main.lua。
func tryLoadLua(dir, name string, config map[string]interface{}) (sdk.Plugin, error) {
luaPath := filepath.Join(dir, luaEntry)
if _, err := os.Stat(luaPath); os.IsNotExist(err) {
return nil, nil
}
// 预留Lua 插件需在 LuaVM 中注册一个 LuaPlugin 包装器
return nil, fmt.Errorf("lua plugin loading not yet implemented: %s", name)
}

View File

@ -0,0 +1,11 @@
//go:build !windows
package plugin
import (
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
func tryLoadDLL(dir, name string, config map[string]interface{}) (sdk.Plugin, error) {
return nil, nil
}

View File

@ -0,0 +1,32 @@
//go:build windows
package plugin
import (
"os"
"path/filepath"
"testing"
)
func TestTryLoadDLL_NoFile(t *testing.T) {
dir := t.TempDir()
plg, err := tryLoadDLL(dir, "nonexistent", nil)
if err != nil {
t.Fatalf("tryLoadDLL on empty dir should not error: %v", err)
}
if plg != nil {
t.Fatal("expected nil for non-existent plugin.dll")
}
}
func TestTryLoadDLL_Invalid(t *testing.T) {
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, "plugin.dll"), []byte("not a real dll"), 0644)
plg, err := tryLoadDLL(dir, "baddll", nil)
t.Logf("plg=%v err=%v", plg, err)
if err == nil && plg == nil {
t.Fatal("expected error or non-nil plugin for existing file")
}
}

View File

@ -0,0 +1,272 @@
//go:build windows
package plugin
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"syscall"
"unsafe"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
// dllPlugin wraps a Windows DLL compiled with -buildmode=c-shared.
//
// Required exports:
//
// NewPlugin(name *C.char, configJSON *C.char) unsafe.Pointer → plugin handle
// StartPlugin(handle unsafe.Pointer) C.int
// StopPlugin(handle unsafe.Pointer) C.int
// DestroyPlugin(handle unsafe.Pointer)
//
// Optional exports (tool registration):
//
// GetToolDefsJSON(handle unsafe.Pointer) *C.char → JSON array of tool defs
// InvokeToolJSON(handle unsafe.Pointer, toolName *C.char, argsJSON *C.char) *C.char
// FreeCString(s *C.char) → free C string from DLL
// GetStagesJSON(handle unsafe.Pointer) *C.char → JSON array of stage names
// InvokeStage(handle unsafe.Pointer, stage *C.char, contextJSON *C.char) C.int
type dllPlugin struct {
name string
dll syscall.Handle
handle uintptr
sdk *sdk.PluginSDK
// cached proc addresses
newPlugin uintptr
startPlugin uintptr
stopPlugin uintptr
destroyPlugin uintptr
getTools uintptr
invokeTool uintptr
freeCStr uintptr
getStages uintptr
invokeStage uintptr
}
func findProc(dll syscall.Handle, name string) uintptr {
addr, err := syscall.GetProcAddress(dll, name)
if err != nil {
return 0
}
return addr
}
func newDLLPlugin(dllPath, name string, config map[string]interface{}) (*dllPlugin, error) {
dll, err := syscall.LoadLibrary(dllPath)
if err != nil {
return nil, fmt.Errorf("LoadLibrary %s: %w", dllPath, err)
}
np := findProc(dll, "NewPlugin")
if np == 0 {
_ = syscall.FreeLibrary(dll)
return nil, fmt.Errorf("dll %s must export NewPlugin", name)
}
return &dllPlugin{
name: name,
dll: dll,
// required
newPlugin: np,
startPlugin: findProc(dll, "StartPlugin"),
stopPlugin: findProc(dll, "StopPlugin"),
destroyPlugin: findProc(dll, "DestroyPlugin"),
// optional tool/stage API
getTools: findProc(dll, "GetToolDefsJSON"),
invokeTool: findProc(dll, "InvokeToolJSON"),
freeCStr: findProc(dll, "FreeCString"),
getStages: findProc(dll, "GetStagesJSON"),
invokeStage: findProc(dll, "InvokeStage"),
}, nil
}
func (p *dllPlugin) Name() string { return p.name }
func (p *dllPlugin) Start(s *sdk.PluginSDK) error {
p.sdk = s
cfgJSON, _ := json.Marshal(map[string]interface{}{
"name": p.name,
"config": s.Settings().Dump(),
})
cName := append([]byte(p.name), 0)
cConfig := append(cfgJSON, 0)
ret, _, _ := syscall.SyscallN(
p.newPlugin,
uintptr(unsafe.Pointer(&cName[0])),
uintptr(unsafe.Pointer(&cConfig[0])),
)
if ret == 0 {
_ = syscall.FreeLibrary(p.dll)
return fmt.Errorf("dll NewPlugin %s returned nil", p.name)
}
p.handle = ret
if p.startPlugin != 0 {
syscall.SyscallN(p.startPlugin, p.handle)
}
// discover and register tools from DLL
if p.getTools != 0 {
if err := p.registerTools(s); err != nil {
return fmt.Errorf("dll %s register tools: %w", p.name, err)
}
}
if p.getStages != 0 {
p.registerStages(s)
}
return nil
}
func (p *dllPlugin) Stop() error {
if p.stopPlugin != 0 {
syscall.SyscallN(p.stopPlugin, p.handle)
}
if p.destroyPlugin != 0 {
syscall.SyscallN(p.destroyPlugin, p.handle)
}
_ = syscall.FreeLibrary(p.dll)
return nil
}
// --- tool registration via C ABI ---
type dllToolDef struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters map[string]interface{} `json:"parameters,omitempty"`
}
func (p *dllPlugin) registerTools(s *sdk.PluginSDK) error {
ret, _, _ := syscall.SyscallN(p.getTools, p.handle)
if ret == 0 {
return nil // no tools
}
defsJSON := cStringPtrToString(ret)
if p.freeCStr != 0 {
syscall.SyscallN(p.freeCStr, ret)
}
var defs []dllToolDef
if err := json.Unmarshal([]byte(defsJSON), &defs); err != nil {
return fmt.Errorf("parse tool defs: %w", err)
}
for _, d := range defs {
if d.Name == "" {
continue
}
toolName := d.Name
handler := p.makeToolHandler(toolName)
s.RegisterTool(toolName, sdk.ToolDef{
Name: toolName,
Description: d.Description,
Parameters: d.Parameters,
Plugin: p.name,
}, handler)
}
return nil
}
func (p *dllPlugin) makeToolHandler(toolName string) sdk.ToolHandler {
return func(args map[string]interface{}) (interface{}, error) {
if p.invokeTool == 0 {
return nil, fmt.Errorf("dll %s does not export InvokeToolJSON", p.name)
}
argsJSON, _ := json.Marshal(args)
cToolName := append([]byte(toolName), 0)
cArgs := append(argsJSON, 0)
ret, _, _ := syscall.SyscallN(
p.invokeTool,
p.handle,
uintptr(unsafe.Pointer(&cToolName[0])),
uintptr(unsafe.Pointer(&cArgs[0])),
)
if ret == 0 {
return nil, fmt.Errorf("dll InvokeToolJSON %s returned nil", toolName)
}
resultJSON := cStringPtrToString(ret)
if p.freeCStr != 0 {
syscall.SyscallN(p.freeCStr, ret)
}
var result map[string]interface{}
if err := json.Unmarshal([]byte(resultJSON), &result); err != nil {
return nil, fmt.Errorf("dll tool %s result parse: %w", toolName, err)
}
return result, nil
}
}
func (p *dllPlugin) registerStages(s *sdk.PluginSDK) {
ret, _, _ := syscall.SyscallN(p.getStages, p.handle)
if ret == 0 {
return
}
stagesJSON := cStringPtrToString(ret)
if p.freeCStr != 0 {
syscall.SyscallN(p.freeCStr, ret)
}
type stageEntry struct {
Stage string `json:"stage"`
}
var entries []stageEntry
if err := json.Unmarshal([]byte(stagesJSON), &entries); err != nil {
return
}
for _, e := range entries {
if e.Stage == "" {
continue
}
stageName := sdk.Stage(e.Stage)
stage := stageName
s.RegisterStage(stage, func(sc *sdk.StageContext) error {
if p.invokeStage == 0 {
return nil
}
ctxJSON, _ := json.Marshal(map[string]interface{}{
"raw_message": sc.RawMessage,
"user_id": sc.UserID,
"phase": string(sc.Phase),
})
cStage := append([]byte(stage), 0)
cCtx := append(ctxJSON, 0)
syscall.SyscallN(
p.invokeStage,
p.handle,
uintptr(unsafe.Pointer(&cStage[0])),
uintptr(unsafe.Pointer(&cCtx[0])),
)
return nil
})
}
}
func cStringPtrToString(ptr uintptr) string {
if ptr == 0 {
return ""
}
var buf []byte
for i := uintptr(0); ; i++ {
b := *(*byte)(unsafe.Pointer(ptr + i))
if b == 0 {
break
}
buf = append(buf, b)
}
return string(buf)
}
// tryLoadDLL 尝试从插件目录加载 plugin.dll。
// 返回 nil,nil 表示目录中没有 plugin.dll。
func tryLoadDLL(dir, name string, config map[string]interface{}) (sdk.Plugin, error) {
dllPath := filepath.Join(dir, dllEntry)
if _, err := os.Stat(dllPath); os.IsNotExist(err) {
return nil, nil
}
return newDLLPlugin(dllPath, name, config)
}

View File

@ -0,0 +1,24 @@
package plugin
import (
"fmt"
"os"
"path/filepath"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
// tryLoadLua 从插件目录加载 main.luaLua 插件)。
// 返回 nil,nil 表示目录中没有 main.lua。
func tryLoadLua(dir, name string, config map[string]interface{}) (sdk.Plugin, error) {
luaPath := filepath.Join(dir, luaEntry)
if _, err := os.Stat(luaPath); os.IsNotExist(err) {
return nil, nil
}
plg, err := newLuaPlugin(luaPath, name)
if err != nil {
return nil, fmt.Errorf("lua plugin %s: %w", name, err)
}
return plg, nil
}

View File

@ -0,0 +1,347 @@
package plugin
import (
"fmt"
"io"
"net/http"
"strings"
"sync"
lua "github.com/yuin/gopher-lua"
luaSDK "gitcode.com/JianFeeeee/HomeAgent/internal/lua/sdk"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
type toolReg struct {
def sdk.ToolDef
handler *lua.LFunction
}
// luaPlugin wraps a Lua script as an sdk.Plugin.
type luaPlugin struct {
name string
L *lua.LState
tbl *lua.LTable
tools map[string]*toolReg
stages map[sdk.Stage]*lua.LFunction
mu sync.Mutex
}
func newLuaPlugin(luaPath, name string) (*luaPlugin, error) {
L := lua.NewState()
// 1) 加载嵌入式 sdk.lua接口定义 + pure Lua mock 实现)
if err := L.DoString(luaSDK.SDKSource); err != nil {
L.Close()
return nil, fmt.Errorf("load sdk.lua: %w", err)
}
sdkTbl := L.GetGlobal("sdk")
sdkTable, ok := sdkTbl.(*lua.LTable)
if !ok {
L.Close()
return nil, fmt.Errorf("sdk.lua must set global 'sdk' table")
}
// 清除 DoString 留在栈上的返回值,栈顶归零
L.SetTop(0)
plg := &luaPlugin{
name: name,
L: L,
tools: make(map[string]*toolReg),
stages: make(map[sdk.Stage]*lua.LFunction),
}
// 2) 替换 !impl 函数为 Go stub暂存 handler等 Start 时注册到真实 SDK
replaceSDKStubs(L, sdkTable, plg)
// 3) 加载插件主脚本(此时 sdk.* 全局已就绪,带 stub 实现)
if err := L.DoFile(luaPath); err != nil {
L.Close()
return nil, fmt.Errorf("load %s: %w", luaPath, err)
}
// 4) 如果脚本返回了 table保存
if L.GetTop() > 0 {
if tbl, ok := L.Get(-1).(*lua.LTable); ok {
plg.tbl = tbl
L.Pop(1)
}
}
return plg, nil
}
// replaceSDKStubs 替换 sdk 表中的 !impl 函数为 Go stub。
// stub 暂存 handler等 Start 时才注册到真实 SDK。
func replaceSDKStubs(L *lua.LState, t *lua.LTable, plg *luaPlugin) {
t.RawSetString("log", L.NewFunction(func(L *lua.LState) int {
level := L.ToString(1)
msg := L.ToString(2)
fmt.Printf("[lua-plugin/%s] %s: %s\n", plg.name, level, msg)
return 0
}))
t.RawSetString("register_tool", L.NewFunction(func(L *lua.LState) int {
toolName := L.CheckString(1)
defTbl := L.CheckTable(2)
handler := L.CheckFunction(3)
goDef := sdk.ToolDef{Name: toolName, Plugin: plg.name}
goDef.Description = defTbl.RawGetString("description").String()
if params := defTbl.RawGetString("parameters"); params != nil {
if pt, ok := params.(*lua.LTable); ok {
goDef.Parameters = make(map[string]interface{})
pt.ForEach(func(k, v lua.LValue) {
goDef.Parameters[k.String()] = luaValueToGo(v)
})
}
}
plg.mu.Lock()
plg.tools[toolName] = &toolReg{def: goDef, handler: handler}
plg.mu.Unlock()
return 0
}))
t.RawSetString("register_stage", L.NewFunction(func(L *lua.LState) int {
stage := sdk.Stage(L.CheckString(1))
handler := L.CheckFunction(2)
plg.mu.Lock()
plg.stages[stage] = handler
plg.mu.Unlock()
return 0
}))
t.RawSetString("register_api", L.NewFunction(func(L *lua.LState) int {
return 0
}))
t.RawSetString("get_setting", L.NewFunction(func(L *lua.LState) int {
L.Push(lua.LNil)
return 1
}))
t.RawSetString("set_setting", L.NewFunction(func(L *lua.LState) int {
return 0
}))
t.RawSetString("inject_text", L.NewFunction(func(L *lua.LState) int { return 0 }))
t.RawSetString("inject_interrupt", L.NewFunction(func(L *lua.LState) int { return 0 }))
t.RawSetString("inject_text_no_memory", L.NewFunction(func(L *lua.LState) int { return 0 }))
// http 子表
if httpTable, ok := t.RawGetString("http").(*lua.LTable); ok {
httpTable.RawSetString("get", L.NewFunction(func(L *lua.LState) int {
url := L.CheckString(1)
resp, err := http.Get(url)
if err != nil {
L.Push(lua.LNil)
L.Push(lua.LString(err.Error()))
return 2
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
result := L.NewTable()
result.RawSetString("status", lua.LNumber(resp.StatusCode))
result.RawSetString("body", lua.LString(string(body)))
headers := L.NewTable()
for k, v := range resp.Header {
headers.RawSetString(k, lua.LString(strings.Join(v, ", ")))
}
result.RawSetString("headers", headers)
L.Push(result)
L.Push(lua.LNil)
return 2
}))
httpTable.RawSetString("post", L.NewFunction(func(L *lua.LState) int {
url := L.CheckString(1)
body := L.CheckString(2)
contentType := L.OptString(3, "application/json")
resp, err := http.Post(url, contentType, strings.NewReader(body))
if err != nil {
L.Push(lua.LNil)
L.Push(lua.LString(err.Error()))
return 2
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
result := L.NewTable()
result.RawSetString("status", lua.LNumber(resp.StatusCode))
result.RawSetString("body", lua.LString(string(respBody)))
L.Push(result)
L.Push(lua.LNil)
return 2
}))
}
}
// replaceSDKReal 用真实 SDK 实现替换 sdk 表。
// 此时 plg.handlers/stages 已存有加载期间注册的 handler。
func replaceSDKReal(L *lua.LState, t *lua.LTable, plg *luaPlugin, s *sdk.PluginSDK) {
t.RawSetString("register_tool", L.NewFunction(func(L *lua.LState) int {
toolName := L.CheckString(1)
defTbl := L.CheckTable(2)
handler := L.CheckFunction(3)
goDef := sdk.ToolDef{Name: toolName, Plugin: plg.name}
goDef.Description = defTbl.RawGetString("description").String()
if params := defTbl.RawGetString("parameters"); params != nil {
if pt, ok := params.(*lua.LTable); ok {
goDef.Parameters = make(map[string]interface{})
pt.ForEach(func(k, v lua.LValue) {
goDef.Parameters[k.String()] = luaValueToGo(v)
})
}
}
h := makeToolHandler(plg, toolName, handler)
if err := s.RegisterTool(toolName, goDef, h); err != nil {
L.RaiseError("register_tool: %v", err)
}
return 0
}))
t.RawSetString("register_stage", L.NewFunction(func(L *lua.LState) int {
stage := sdk.Stage(L.CheckString(1))
handler := L.CheckFunction(2)
h := makeStageHandler(plg, stage, handler)
s.RegisterStage(stage, h)
return 0
}))
t.RawSetString("register_api", L.NewFunction(func(L *lua.LState) int {
apiName := L.CheckString(1)
s.RegisterPluginAPI(apiName)
return 0
}))
t.RawSetString("get_setting", L.NewFunction(func(L *lua.LState) int {
key := L.CheckString(1)
val, _ := s.Settings().Get(key)
L.Push(goValueToLua(L, val))
return 1
}))
t.RawSetString("set_setting", L.NewFunction(func(L *lua.LState) int {
key := L.CheckString(1)
val := luaValueToGo(L.CheckAny(2))
s.Settings().Set(key, val)
return 0
}))
t.RawSetString("inject_text", L.NewFunction(func(L *lua.LState) int {
s.InjectText(L.CheckString(1), L.CheckString(2), L.CheckString(3))
return 0
}))
t.RawSetString("inject_interrupt", L.NewFunction(func(L *lua.LState) int {
s.InjectInterruptText(L.CheckString(1), L.CheckString(2), L.CheckString(3))
return 0
}))
t.RawSetString("inject_text_no_memory", L.NewFunction(func(L *lua.LState) int {
s.InjectTextNoMemory(L.CheckString(1), L.CheckString(2), L.CheckString(3))
return 0
}))
}
func makeToolHandler(plg *luaPlugin, name string, fn *lua.LFunction) sdk.ToolHandler {
return func(args map[string]interface{}) (interface{}, error) {
plg.mu.Lock()
defer plg.mu.Unlock()
L := plg.L
L.Push(fn)
L.Push(goValueToLua(L, args))
if err := L.PCall(1, 1, nil); err != nil {
return nil, fmt.Errorf("lua tool %s: %w", name, err)
}
result := L.Get(-1)
L.Pop(1)
return luaValueToGo(result), nil
}
}
func makeStageHandler(plg *luaPlugin, stage sdk.Stage, fn *lua.LFunction) sdk.StageHandler {
return func(sc *sdk.StageContext) error {
plg.mu.Lock()
defer plg.mu.Unlock()
L := plg.L
L.Push(fn)
L.Push(goValueToLua(L, map[string]interface{}{
"raw_message": sc.RawMessage,
"user_id": sc.UserID,
"phase": string(sc.Phase),
}))
if err := L.PCall(1, 0, nil); err != nil {
return fmt.Errorf("lua stage %s: %w", stage, err)
}
return nil
}
}
func (p *luaPlugin) Name() string { return p.name }
func (p *luaPlugin) Start(s *sdk.PluginSDK) error {
// 1) 用真实 SDK 实现替换 sdk 表函数
sdkTbl := p.L.GetGlobal("sdk")
if sdkTable, ok := sdkTbl.(*lua.LTable); ok {
replaceSDKReal(p.L, sdkTable, p, s)
}
// 2) 批量注册加载期已暂存的 tool handler
p.mu.Lock()
tools := make(map[string]*toolReg, len(p.tools))
for k, v := range p.tools {
tools[k] = v
}
stages := make(map[sdk.Stage]*lua.LFunction, len(p.stages))
for k, v := range p.stages {
stages[k] = v
}
p.mu.Unlock()
for toolName, reg := range tools {
h := makeToolHandler(p, toolName, reg.handler)
s.RegisterTool(toolName, reg.def, h)
}
for stage, fn := range stages {
h := makeStageHandler(p, stage, fn)
s.RegisterStage(stage, h)
}
// 3) 调用插件的 start(sdk) 回调
if p.tbl != nil {
fn := p.tbl.RawGetString("start")
if fn != nil && fn != lua.LNil {
p.mu.Lock()
L := p.L
L.Push(fn)
L.Push(sdkTbl)
err := L.PCall(1, 0, nil)
p.mu.Unlock()
if err != nil {
return fmt.Errorf("lua start %s: %w", p.name, err)
}
}
}
return nil
}
func (p *luaPlugin) Stop() error {
p.mu.Lock()
defer p.mu.Unlock()
if p.tbl != nil {
fn := p.tbl.RawGetString("stop")
if fn != nil && fn != lua.LNil {
L := p.L
L.Push(fn)
if err := L.PCall(0, 0, nil); err != nil {
p.L.Close()
return fmt.Errorf("lua stop %s: %w", p.name, err)
}
}
}
p.L.Close()
return nil
}

View File

@ -0,0 +1,116 @@
package plugin
import (
"os"
"path/filepath"
"testing"
)
func TestTryLoadLua_Basic(t *testing.T) {
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, "plugin.json"), []byte(`{
"name": "testlua",
"name_zh": "测试Lua",
"name_en": "Test Lua",
"version": "1.0.0",
"entry": "main.lua"
}`), 0644)
os.WriteFile(filepath.Join(dir, "main.lua"), []byte(`
local plugin = {
name = "testlua"
}
function plugin.start(sdk)
sdk.log("info", "testlua started")
sdk.register_tool("testlua_hello", {
description = "Hello tool",
parameters = {type = "object", properties = {}}
}, function(args)
return {content = "hello from lua"}
end)
end
function plugin.stop()
sdk.log("info", "testlua stopped")
end
return plugin
`), 0644)
plg, err := tryLoadLua(dir, "testlua", nil)
if err != nil {
t.Fatalf("tryLoadLua failed: %v", err)
}
if plg == nil {
t.Fatal("tryLoadLua returned nil")
}
if plg.Name() != "testlua" {
t.Fatalf("unexpected name: %s", plg.Name())
}
t.Logf("plugin loaded: %s", plg.Name())
}
func TestTryLoadLua_NoFile(t *testing.T) {
dir := t.TempDir()
plg, err := tryLoadLua(dir, "nonexistent", nil)
if err != nil {
t.Fatalf("tryLoadLua on empty dir should not error: %v", err)
}
if plg != nil {
t.Fatal("expected nil for non-existent main.lua")
}
}
func TestTryLoadLua_NoReturnTable(t *testing.T) {
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, "plugin.json"), []byte(`{"name":"bad","entry":"main.lua"}`), 0644)
os.WriteFile(filepath.Join(dir, "main.lua"), []byte(`
-- just code, no return table
local x = 1
sdk.log("info", "no return table test")
`), 0644)
plg, err := tryLoadLua(dir, "bad", nil)
if err != nil {
t.Fatalf("tryLoadLua failed: %v", err)
}
if plg == nil {
t.Fatal("tryLoadLua returned nil")
}
t.Logf("loaded plugin without return table: %s", plg.Name())
}
func TestTryLoadLua_GlobalSDK(t *testing.T) {
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, "plugin.json"), []byte(`{"name":"globalsdk","entry":"main.lua"}`), 0644)
os.WriteFile(filepath.Join(dir, "main.lua"), []byte(`
-- sdk is a global, should work without return table
sdk.log("info", "sdk is available as global")
sdk.register_tool("direct_tool", {
description = "registered directly in top-level code"
}, function(args)
return {result = "ok"}
end)
`), 0644)
plg, err := tryLoadLua(dir, "globalsdk", nil)
if err != nil {
t.Fatalf("tryLoadLua failed: %v", err)
}
if plg == nil {
t.Fatal("tryLoadLua returned nil")
}
lp := plg.(*luaPlugin)
lp.mu.Lock()
toolCount := len(lp.tools)
lp.mu.Unlock()
if toolCount != 1 {
t.Fatalf("expected 1 tool registration, got %d", toolCount)
}
t.Logf("tool registered during load phase: OK")
}

View File

@ -0,0 +1,60 @@
package plugin
import lua "github.com/yuin/gopher-lua"
func luaValueToGo(lv lua.LValue) interface{} {
switch v := lv.(type) {
case lua.LString:
return string(v)
case lua.LNumber:
return float64(v)
case lua.LBool:
return bool(v)
case *lua.LTable:
if v.MaxN() > 0 {
arr := make([]interface{}, 0, v.MaxN())
v.ForEach(func(_, val lua.LValue) {
arr = append(arr, luaValueToGo(val))
})
return arr
}
m := make(map[string]interface{})
v.ForEach(func(key, val lua.LValue) {
m[key.String()] = luaValueToGo(val)
})
return m
default:
return nil
}
}
func goValueToLua(L *lua.LState, val interface{}) lua.LValue {
switch v := val.(type) {
case string:
return lua.LString(v)
case float64:
return lua.LNumber(v)
case int:
return lua.LNumber(v)
case int64:
return lua.LNumber(v)
case bool:
return lua.LBool(v)
case nil:
return lua.LNil
case []interface{}:
tbl := L.NewTable()
for i, item := range v {
tbl.RawSetInt(i+1, goValueToLua(L, item))
}
return tbl
case map[string]interface{}:
tbl := L.NewTable()
for k, item := range v {
tbl.RawSetString(k, goValueToLua(L, item))
}
return tbl
default:
return lua.LNil
}
}

View File

@ -11,6 +11,8 @@ const PackageExt = ".hmap"
// PluginManifest 每个插件目录中的 plugin.json 元数据。
type PluginManifest struct {
Name string `json:"name"`
NameZh string `json:"name_zh,omitempty"`
NameEn string `json:"name_en,omitempty"`
Version string `json:"version"`
Description string `json:"description,omitempty"`
Author string `json:"author,omitempty"`

View File

@ -22,6 +22,28 @@ import (
type NativeFactory func(name string, config map[string]interface{}) (sdk.Plugin, error)
// PluginMeta 插件显示名称元数据。
type PluginMeta struct {
NameZh string `json:"name_zh"`
NameEn string `json:"name_en"`
}
var globalPluginMeta sync.Map // name -> PluginMeta
// RegisterPluginMeta 供插件包在 init() 中调用,注册显示名称。
func RegisterPluginMeta(name, nameZh, nameEn string) {
globalPluginMeta.Store(name, PluginMeta{NameZh: nameZh, NameEn: nameEn})
}
// GetPluginMeta 查询插件的显示名称。
func GetPluginMeta(name string) (PluginMeta, bool) {
v, ok := globalPluginMeta.Load(name)
if !ok {
return PluginMeta{}, false
}
return v.(PluginMeta), true
}
// globalFactories 是插件通过 init() 自注册的全局工厂表。
// Registry.RegisterNative() 写入此表Registry.Load() 从中查找。
var globalFactories sync.Map
@ -202,6 +224,13 @@ func (r *Registry) loadOne(plgDir, name string) bool {
var plg sdk.Plugin
// 读取 plugin.json 以获取插件显示名称元数据(主要用于外部插件)
if mft := readManifest(plgDir); mft != nil {
if mft.NameZh != "" || mft.NameEn != "" {
RegisterPluginMeta(name, mft.NameZh, mft.NameEn)
}
}
if hasFactory {
cfg := r.readConfig(plgDir)
p, err := factory(name, cfg)
@ -277,16 +306,34 @@ func (r *Registry) Get(name string) sdk.Plugin {
return r.plugins[name]
}
func (r *Registry) PluginMetas() map[string]PluginMeta {
metas := make(map[string]PluginMeta)
globalPluginMeta.Range(func(key, val interface{}) bool {
metas[key.(string)] = val.(PluginMeta)
return true
})
return metas
}
func (r *Registry) tryDynamic(plgDir, name string, config map[string]interface{}) (sdk.Plugin, error) {
// 优先尝试 .soGo plugin),其次 .luaLua 脚本)
plg, err := tryLoadSO(plgDir, name, config)
if err != nil {
return nil, err
// 尝试顺序:.so (Go plugin on Linux) → .dll (Windows) → .lua (跨平台)
for _, try := range []struct {
name string
fn func(string, string, map[string]interface{}) (sdk.Plugin, error)
}{
{"so", tryLoadSO},
{"dll", tryLoadDLL},
{"lua", tryLoadLua},
} {
plg, err := try.fn(plgDir, name, config)
if err != nil {
return nil, err
}
if plg != nil {
return plg, nil
}
}
if plg != nil {
return plg, nil
}
return tryLoadLua(plgDir, name, config)
return nil, nil
}
func (r *Registry) readConfig(plgDir string) map[string]interface{} {