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,8 @@
package sdk
import (
_ "embed"
)
//go:embed sdk.lua
var SDKSource string

204
internal/lua/sdk/sdk.lua Normal file
View File

@ -0,0 +1,204 @@
-- HomeAgent Lua Plugin SDK
-- Interface contract between Lua plugins and HomeAgent kernel.
-- !impl functions are replaced by Go implementations at runtime.
-- Standalone/debug: pure Lua mock implementations are used.
-- Usage: local sdk = require("sdk")
sdk = {}
-- !impl
-- level: "debug" | "info" | "warn" | "error"
function sdk.log(level, msg)
print("[lua-plugin] " .. tostring(level) .. ": " .. tostring(msg))
end
-- !impl
-- def: { description="...", parameters={...} }
-- handler: function(args) -> result
function sdk.register_tool(name, def, handler)
print("[lua-plugin] register_tool: " .. tostring(name))
end
-- !impl
-- stage: "on_input" | "pre_action" | "post_action" | ...
function sdk.register_stage(stage, handler)
print("[lua-plugin] register_stage: " .. tostring(stage))
end
-- !impl
function sdk.register_api(name)
print("[lua-plugin] register_api: " .. tostring(name))
end
-- !impl
function sdk.get_setting(key)
return nil
end
-- !impl
function sdk.set_setting(key, value)
print("[lua-plugin] set_setting: " .. tostring(key))
end
-- !impl
function sdk.inject_text(source, channel, text)
print("[lua-plugin] inject_text: " .. tostring(source) .. "/" .. tostring(channel))
end
-- !impl
function sdk.inject_interrupt(source, channel, text)
print("[lua-plugin] inject_interrupt: " .. tostring(source))
end
-- !impl
function sdk.inject_text_no_memory(source, channel, text)
print("[lua-plugin] inject_text_no_memory: " .. tostring(source))
end
-- json utils (pure Lua)
sdk.json = {}
function sdk.json.encode(val)
local ok, result = pcall(function()
local function _encode(v)
local t = type(v)
if t == "string" then
local s = v:gsub('\\', '\\\\'):gsub('"', '\\"'):gsub('\n', '\\n'):gsub('\r', '\\r'):gsub('\t', '\\t')
return '"' .. s .. '"'
elseif t == "number" then
return tostring(v)
elseif t == "boolean" then
return tostring(v)
elseif t == "table" then
local keys = {}
local is_array = true
local maxn = 0
for k in pairs(v) do
keys[#keys + 1] = k
if type(k) ~= "number" or k < 1 or k ~= math.floor(k) then
is_array = false
end
if type(k) == "number" and k > maxn then maxn = k end
end
if is_array and #keys >= maxn then
local parts = {}
for i = 1, maxn do
parts[#parts + 1] = _encode(v[i])
end
return "[" .. table.concat(parts, ",") .. "]"
else
local parts = {}
for _, k in ipairs(keys) do
parts[#parts + 1] = _encode(tostring(k)) .. ":" .. _encode(v[k])
end
return "{" .. table.concat(parts, ",") .. "}"
end
else
return "null"
end
end
return _encode(val)
end)
if ok then return result end
return "null"
end
function sdk.json.decode(str)
local ok, result = pcall(function()
local pos, _end = 1, #str
local function skip()
while pos <= _end and str:sub(pos, pos):match("%s") do pos = pos + 1 end
end
local function parse()
skip()
if pos > _end then return nil end
local c = str:sub(pos, pos)
if c == '"' then
local s = {}
pos = pos + 1
while pos <= _end do
local ch = str:sub(pos, pos)
if ch == '"' then
pos = pos + 1
return table.concat(s)
elseif ch == '\\' then
pos = pos + 1
local n = str:sub(pos, pos)
if n == '"' then s[#s+1] = '"'
elseif n == '\\' then s[#s+1] = '\\'
elseif n == '/' then s[#s+1] = '/'
elseif n == 'b' then s[#s+1] = '\b'
elseif n == 'f' then s[#s+1] = '\f'
elseif n == 'n' then s[#s+1] = '\n'
elseif n == 'r' then s[#s+1] = '\r'
elseif n == 't' then s[#s+1] = '\t'
elseif n == 'u' then
local hex = str:sub(pos+1, pos+4)
pos = pos + 4
s[#s+1] = utf8 and utf8.char(tonumber(hex, 16)) or '?'
end
pos = pos + 1
else
s[#s+1] = ch
pos = pos + 1
end
end
return table.concat(s)
elseif c == 't' then pos = pos + 4; return true
elseif c == 'f' then pos = pos + 5; return false
elseif c == 'n' then pos = pos + 4; return nil
elseif c == '{' then
pos = pos + 1; skip()
local t = {}
if str:sub(pos, pos) == '}' then pos = pos + 1; return t end
while true do
skip(); local k = parse(); skip()
if str:sub(pos, pos) == ':' then pos = pos + 1 end
skip(); t[k] = parse(); skip()
local sep = str:sub(pos, pos)
if sep == '}' then pos = pos + 1; return t end
if sep == ',' then pos = pos + 1 end
end
elseif c == '[' then
pos = pos + 1; skip()
local t = {}
if str:sub(pos, pos) == ']' then pos = pos + 1; return t end
local idx = 1
while true do
skip(); t[idx] = parse(); idx = idx + 1; skip()
local sep = str:sub(pos, pos)
if sep == ']' then pos = pos + 1; return t end
if sep == ',' then pos = pos + 1 end
end
else
local s, e = str:find('^[-%d%.eE]+', pos)
if s then
local num = tonumber(str:sub(s, e))
pos = e + 1
return num
end
return nil
end
end
return parse()
end)
if ok then return result end
return nil
end
-- http utils
sdk.http = {}
-- !impl
function sdk.http.get(url)
print("[lua-plugin] http.get: " .. tostring(url))
return {status=200, body='{"mock":true}', headers={}}
end
-- !impl
function sdk.http.post(url, body, content_type)
print("[lua-plugin] http.post: " .. tostring(url))
return {status=200, body='{"mock":true}', headers={}}
end
return sdk

View File

@ -4,6 +4,7 @@ import (
"embed"
"encoding/json"
"fmt"
"io/fs"
"os"
"path/filepath"
"sync"
@ -14,49 +15,37 @@ import (
//go:embed adapters/*.lua
var bundledAdapters embed.FS
type VM struct {
mu sync.Mutex
state *lua.LState
adapterDir string
loaded map[string]*lua.LTable
}
type APIAdapter struct {
Name string
Version string
Script string
Name string `json:"name"`
Version string `json:"version"`
}
func (v *VM) AdapterDir() string {
return v.adapterDir
// AdapterCache 预加载容器:启动时编译全部脚本到内存,运行时只读缓存,无文件 I/O
type AdapterCache struct {
mu sync.RWMutex
state *lua.LState
items map[string]*lua.LTable
}
func NewVM(adapterDir string) *VM {
return &VM{
adapterDir: adapterDir,
loaded: make(map[string]*lua.LTable),
func newAdapterCache() *AdapterCache {
return &AdapterCache{
state: lua.NewState(),
items: make(map[string]*lua.LTable),
}
}
func (v *VM) Start() error {
os.MkdirAll(v.adapterDir, 0755)
if err := v.writeBundledAdapters(); err != nil {
return fmt.Errorf("write bundled adapters: %w", err)
}
v.state = lua.NewState()
v.state.SetGlobal("log", v.state.NewFunction(func(L *lua.LState) int {
func (c *AdapterCache) setupGlobals() {
s := c.state
s.SetGlobal("log", s.NewFunction(func(L *lua.LState) int {
level := L.ToString(1)
msg := L.ToString(2)
fmt.Printf("[lua/%s] %s\n", level, msg)
return 0
}))
jsonTable := v.state.NewTable()
v.state.SetGlobal("json", jsonTable)
v.state.SetField(jsonTable, "encode", v.state.NewFunction(func(L *lua.LState) int {
jsonTable := s.NewTable()
s.SetGlobal("json", jsonTable)
s.SetField(jsonTable, "encode", s.NewFunction(func(L *lua.LState) int {
val := L.CheckAny(1)
goVal := luaValueToGo(val)
b, err := json.Marshal(goVal)
@ -67,7 +56,7 @@ func (v *VM) Start() error {
L.Push(lua.LString(string(b)))
return 1
}))
v.state.SetField(jsonTable, "decode", v.state.NewFunction(func(L *lua.LState) int {
s.SetField(jsonTable, "decode", s.NewFunction(func(L *lua.LState) int {
str := L.CheckString(1)
var val interface{}
if err := json.Unmarshal([]byte(str), &val); err != nil {
@ -78,33 +67,120 @@ func (v *VM) Start() error {
return 1
}))
v.state.SetGlobal("http_get", v.state.NewFunction(func(L *lua.LState) int {
s.SetGlobal("http_get", s.NewFunction(func(L *lua.LState) int {
url := L.ToString(1)
L.Push(lua.LString(fmt.Sprintf(`{"url":%q,"status":200,"body":"mock"}`, url)))
return 1
}))
v.state.SetGlobal("http_post", v.state.NewFunction(func(L *lua.LState) int {
s.SetGlobal("http_post", s.NewFunction(func(L *lua.LState) int {
url := L.ToString(1)
body := L.ToString(2)
L.Push(lua.LString(fmt.Sprintf(`{"url":%q,"body":%q,"status":200}`, url, body)))
return 1
}))
}
if err := v.loadAdapters(); err != nil {
return fmt.Errorf("load adapters: %w", err)
// Preload 编译单个 Lua 适配器脚本并注入缓存
func (c *AdapterCache) Preload(path string) error {
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read adapter: %w", err)
}
return c.PreloadSource(filepath.Base(path), string(data))
}
// PreloadSource 从源码字符串编译适配器并注入缓存
func (c *AdapterCache) PreloadSource(name, code string) error {
c.mu.Lock()
defer c.mu.Unlock()
if err := c.state.DoString(code); err != nil {
return fmt.Errorf("compile adapter: %w", err)
}
tbl, ok := c.state.Get(-1).(*lua.LTable)
c.state.Pop(1)
if !ok {
return fmt.Errorf("adapter script must return a table")
}
if n := tbl.RawGetString("name"); n != nil && n.String() != "" {
name = n.String()
}
c.items[name] = tbl
return nil
}
func (v *VM) Stop() {
if v.state != nil {
v.state.Close()
// Get 运行时从缓存读取已编译的适配器表(无文件 I/O
func (c *AdapterCache) Get(name string) *lua.LTable {
c.mu.RLock()
defer c.mu.RUnlock()
return c.items[name]
}
// Remove 从缓存移除适配器
func (c *AdapterCache) Remove(name string) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.items, name)
}
// List 返回缓存中所有适配器摘要
func (c *AdapterCache) List() []APIAdapter {
c.mu.RLock()
defer c.mu.RUnlock()
list := make([]APIAdapter, 0, len(c.items))
for name, tbl := range c.items {
a := APIAdapter{Name: name}
if v := tbl.RawGetString("version"); v != nil {
a.Version = v.String()
}
list = append(list, a)
}
return list
}
// Close 释放 Lua 状态
func (c *AdapterCache) Close() {
c.mu.Lock()
defer c.mu.Unlock()
if c.state != nil {
c.state.Close()
c.state = nil
}
c.items = nil
}
// VM 运行时虚拟机,封装 AdapterCache 提供适配器调用
type VM struct {
mu sync.Mutex
cache *AdapterCache
adapterDir string
}
func NewVM(adapterDir string) *VM {
return &VM{
adapterDir: adapterDir,
cache: newAdapterCache(),
}
}
func (v *VM) loadAdapters() error {
func (v *VM) AdapterDir() string { return v.adapterDir }
func (v *VM) Cache() *AdapterCache { return v.cache }
func (v *VM) Start() error {
if err := os.MkdirAll(v.adapterDir, 0755); err != nil {
return fmt.Errorf("mkdir adapter dir: %w", err)
}
if err := v.writeBundledAdapters(); err != nil {
return fmt.Errorf("write bundled adapters: %w", err)
}
v.cache.setupGlobals()
// 预加载:扫描适配器目录,全部编译到缓存
entries, err := os.ReadDir(v.adapterDir)
if err != nil {
if os.IsNotExist(err) {
@ -112,134 +188,108 @@ func (v *VM) loadAdapters() error {
}
return err
}
for _, entry := range entries {
if filepath.Ext(entry.Name()) != ".lua" {
continue
}
path := filepath.Join(v.adapterDir, entry.Name())
if err := v.LoadAdapter(path); err != nil {
fmt.Printf("[lua] load %s: %v\n", entry.Name(), err)
if err := v.cache.Preload(path); err != nil {
fmt.Printf("[lua] preload %s: %v\n", entry.Name(), err)
}
}
return nil
}
func (v *VM) Stop() {
v.cache.Close()
}
// LoadAdapter 对外接口:从文件加载并编译适配器到缓存(运行时安全,不影响其他适配器)
func (v *VM) LoadAdapter(path string) error {
v.mu.Lock()
defer v.mu.Unlock()
return v.cache.Preload(path)
}
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read adapter: %w", err)
}
// RemoveAdapter 对外接口:从缓存移除适配器(运行时安全)
func (v *VM) RemoveAdapter(name string) {
v.cache.Remove(name)
}
script := string(data)
if err := v.state.DoString(script); err != nil {
return fmt.Errorf("execute adapter script: %w", err)
}
adapterTable := v.state.Get(-1)
v.state.Pop(1)
tbl, ok := adapterTable.(*lua.LTable)
if !ok {
return fmt.Errorf("adapter script must return a table")
}
name := ""
if nameVal := tbl.RawGetString("name"); nameVal != nil {
name = nameVal.String()
}
if name == "" {
name = filepath.Base(path)
}
v.loaded[name] = tbl
fmt.Printf("[lua] loaded adapter: %s\n", name)
return nil
func (v *VM) ListAdapters() []APIAdapter {
return v.cache.List()
}
func (v *VM) CallTransformRequest(name, rawJSON string) (string, error) {
v.mu.Lock()
defer v.mu.Unlock()
adapter, ok := v.loaded[name]
if !ok {
adapter := v.cache.Get(name)
if adapter == nil {
return "", fmt.Errorf("adapter %s not loaded", name)
}
v.mu.Lock()
defer v.mu.Unlock()
fn := adapter.RawGetString("transform_request")
if fn == nil {
return "", fmt.Errorf("adapter %s missing transform_request", name)
}
v.state.Push(fn)
v.state.Push(lua.LString(rawJSON))
if err := v.state.PCall(1, 1, nil); err != nil {
state := v.cache.state
state.Push(fn)
state.Push(lua.LString(rawJSON))
if err := state.PCall(1, 1, nil); err != nil {
return "", fmt.Errorf("transform_request: %w", err)
}
result := v.state.Get(-1)
v.state.Pop(1)
result := state.Get(-1)
state.Pop(1)
return result.String(), nil
}
func (v *VM) CallTransformResponse(name, rawJSON string) (string, error) {
v.mu.Lock()
defer v.mu.Unlock()
adapter, ok := v.loaded[name]
if !ok {
adapter := v.cache.Get(name)
if adapter == nil {
return rawJSON, nil
}
v.mu.Lock()
defer v.mu.Unlock()
fn := adapter.RawGetString("transform_response")
if fn == nil {
return rawJSON, nil
}
v.state.Push(fn)
v.state.Push(lua.LString(rawJSON))
if err := v.state.PCall(1, 1, nil); err != nil {
state := v.cache.state
state.Push(fn)
state.Push(lua.LString(rawJSON))
if err := state.PCall(1, 1, nil); err != nil {
return "", fmt.Errorf("transform_response: %w", err)
}
result := v.state.Get(-1)
v.state.Pop(1)
result := state.Get(-1)
state.Pop(1)
return result.String(), nil
}
func (v *VM) CallTransformStreamChunk(name, rawLine string) (string, error) {
v.mu.Lock()
defer v.mu.Unlock()
adapter, ok := v.loaded[name]
if !ok {
adapter := v.cache.Get(name)
if adapter == nil {
return rawLine, nil
}
v.mu.Lock()
defer v.mu.Unlock()
fn := adapter.RawGetString("transform_stream_chunk")
if fn == nil {
return rawLine, nil
}
v.state.Push(fn)
v.state.Push(lua.LString(rawLine))
if err := v.state.PCall(1, 1, nil); err != nil {
state := v.cache.state
state.Push(fn)
state.Push(lua.LString(rawLine))
if err := state.PCall(1, 1, nil); err != nil {
return "", fmt.Errorf("transform_stream_chunk: %w", err)
}
result := v.state.Get(-1)
v.state.Pop(1)
result := state.Get(-1)
state.Pop(1)
if result.String() == "" {
return "", nil
}
@ -247,14 +297,10 @@ func (v *VM) CallTransformStreamChunk(name, rawLine string) (string, error) {
}
func (v *VM) GetAdapterEndpoint(name string) string {
v.mu.Lock()
defer v.mu.Unlock()
adapter, ok := v.loaded[name]
if !ok {
adapter := v.cache.Get(name)
if adapter == nil {
return ""
}
if ep := adapter.RawGetString("endpoint"); ep != nil {
return ep.String()
}
@ -262,14 +308,10 @@ func (v *VM) GetAdapterEndpoint(name string) string {
}
func (v *VM) GetAdapterHeaders(name string) map[string]string {
v.mu.Lock()
defer v.mu.Unlock()
adapter, ok := v.loaded[name]
if !ok {
adapter := v.cache.Get(name)
if adapter == nil {
return nil
}
headers := make(map[string]string)
if ht := adapter.RawGetString("headers"); ht != nil {
if tbl, ok := ht.(*lua.LTable); ok {
@ -281,32 +323,45 @@ func (v *VM) GetAdapterHeaders(name string) map[string]string {
return headers
}
func (v *VM) ListAdapters() []APIAdapter {
v.mu.Lock()
defer v.mu.Unlock()
adapters := make([]APIAdapter, 0)
for name, tbl := range v.loaded {
adapter := APIAdapter{Name: name}
if v := tbl.RawGetString("version"); v != nil {
adapter.Version = v.String()
func (v *VM) writeBundledAdapters() error {
// Try multiple paths for compatibility
tryPaths := []string{"adapters", ".", "lua/adapters"}
var entries []fs.DirEntry
var err error
for _, p := range tryPaths {
entries, err = bundledAdapters.ReadDir(p)
if err == nil && len(entries) > 0 {
break
}
adapters = append(adapters, adapter)
}
return adapters
}
func (v *VM) ReloadAll() error {
v.mu.Lock()
v.loaded = make(map[string]*lua.LTable)
v.mu.Unlock()
if v.state != nil {
v.state.Close()
if err != nil || len(entries) == 0 {
return nil
}
v.state = lua.NewState()
return v.Start()
for _, entry := range entries {
if entry.IsDir() {
continue
}
if filepath.Ext(entry.Name()) != ".lua" {
continue
}
dstPath := filepath.Join(v.adapterDir, entry.Name())
if _, err := os.Stat(dstPath); err == nil {
continue
}
data, err := bundledAdapters.ReadFile(filepath.Join("adapters", entry.Name()))
if err != nil {
// try alternative paths
data, err = bundledAdapters.ReadFile(entry.Name())
if err != nil {
continue
}
}
if err := os.WriteFile(dstPath, data, 0644); err != nil {
return fmt.Errorf("write %s: %w", entry.Name(), err)
}
fmt.Printf("[lua] installed bundled adapter: %s\n", entry.Name())
}
return nil
}
func luaValueToGo(lv lua.LValue) interface{} {
@ -365,33 +420,3 @@ func goValueToLua(L *lua.LState, val interface{}) lua.LValue {
return lua.LNil
}
}
func (v *VM) writeBundledAdapters() error {
entries, err := bundledAdapters.ReadDir("adapters")
if err != nil {
return nil
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
dstPath := filepath.Join(v.adapterDir, entry.Name())
if _, err := os.Stat(dstPath); err == nil {
continue
}
data, err := bundledAdapters.ReadFile(filepath.Join("adapters", entry.Name()))
if err != nil {
continue
}
if err := os.WriteFile(dstPath, data, 0644); err != nil {
return fmt.Errorf("write %s: %w", entry.Name(), err)
}
fmt.Printf("[lua] installed bundled adapter: %s\n", entry.Name())
}
return nil
}