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

@ -609,6 +609,15 @@ func NewProviderManager() *ProviderManager {
}
}
func (m *ProviderManager) Reset() {
m.mu.Lock()
defer m.mu.Unlock()
m.providers = make(map[string]Provider)
m.order = nil
m.default_ = ""
m.status = make(map[string]*providerStatus)
}
func (m *ProviderManager) Register(name string, p Provider) {
m.mu.Lock()
defer m.mu.Unlock()

View File

@ -2677,6 +2677,35 @@ func (a *Agent) mediaDataURL(defaultMime string) string {
return url
}
// mediaRequest 构造多模态请求并调用 LLM统一处理 pendingMedia 检查和 data URL 转换。
func (a *Agent) mediaRequest(p agentAPI.Provider, mime, emptyPendingMsg, emptyDataMsg, prompt, resultPrefix string, maxTokens int, blockType string, detail string) string {
if a.pendingMedia == nil {
return emptyPendingMsg
}
url := a.mediaDataURL(mime)
if url == "" {
return emptyDataMsg
}
msg := agentAPI.Message{
Role: "user",
Blocks: []agentAPI.ContentBlock{
{Type: "text", Text: prompt},
},
}
if blockType == "image_url" {
msg.Blocks = append(msg.Blocks, agentAPI.ContentBlock{
Type: "image_url",
ImageURL: &agentAPI.ImageURL{URL: url, Detail: detail},
})
} else {
msg.Blocks = append(msg.Blocks, agentAPI.ContentBlock{
Type: "audio_url",
AudioURL: &agentAPI.AudioURL{URL: url},
})
}
return a.mediaChat(p, msg, resultPrefix, maxTokens)
}
// mediaChat 调用指定 provider 的多模态 Chat统一处理超时和错误。
func (a *Agent) mediaChat(p agentAPI.Provider, msg agentAPI.Message, resultPrefix string, maxTokens int) string {
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
@ -2693,89 +2722,34 @@ func (a *Agent) mediaChat(p agentAPI.Provider, msg agentAPI.Message, resultPrefi
// executeDescribeImage 调用多模态模型描述当前图片。
func (a *Agent) executeDescribeImage(tc agentAPI.ToolCall) string {
if a.pendingMedia == nil {
return "没有待处理的图片数据"
}
imgURL := a.mediaDataURL("image/png")
if imgURL == "" {
return "图片数据为空"
}
providerName, _ := tc.Arguments["provider"].(string)
p := a.providerManager.Get(providerName)
if p == nil {
p = a.provider
}
prompt := a.inputCfg.Image.DescribePrompt
if prompt == "" {
prompt = "请详细描述这张图片的内容,包括其中的文字、物体、人物、场景等信息。"
}
detail, _ := tc.Arguments["detail"].(string)
if detail == "" {
detail = "high"
}
msg := agentAPI.Message{
Role: "user",
Blocks: []agentAPI.ContentBlock{
{Type: "text", Text: prompt},
{Type: "image_url", ImageURL: &agentAPI.ImageURL{URL: imgURL, Detail: detail}},
},
}
return a.mediaChat(p, msg, "图片描述", 2048)
return a.mediaRequest(p, "image/png", "没有待处理的图片数据", "图片数据为空",
a.inputCfg.Image.DescribePrompt, "图片描述", 2048, "image_url", detail)
}
// executeTranscribeAudio 调用多模态模型转写/描述当前音频。
func (a *Agent) executeTranscribeAudio(tc agentAPI.ToolCall) string {
if a.pendingMedia == nil {
return "没有待处理的音频数据"
}
audURL := a.mediaDataURL("audio/wav")
if audURL == "" {
return "音频数据为空"
}
providerName, _ := tc.Arguments["provider"].(string)
p := a.providerManager.Get(providerName)
if p == nil {
p = a.provider
}
prompt := a.inputCfg.Audio.DescribePrompt
if prompt == "" {
prompt = "请转写这段音频的内容。"
}
msg := agentAPI.Message{
Role: "user",
Blocks: []agentAPI.ContentBlock{
{Type: "text", Text: prompt},
{Type: "audio_url", AudioURL: &agentAPI.AudioURL{URL: audURL}},
},
}
return a.mediaChat(p, msg, "音频转写", 2048)
return a.mediaRequest(p, "audio/wav", "没有待处理的音频数据", "音频数据为空",
a.inputCfg.Audio.DescribePrompt, "音频转写", 2048, "audio_url", "")
}
// executeOCRImage 对图片执行 OCR 文字识别(通过多模态模型实现)。
func (a *Agent) executeOCRImage(tc agentAPI.ToolCall) string {
if a.pendingMedia == nil {
return "没有待处理的图片数据"
}
imgURL := a.mediaDataURL("image/png")
if imgURL == "" {
return "图片数据为空"
}
msg := agentAPI.Message{
Role: "user",
Blocks: []agentAPI.ContentBlock{
{Type: "text", Text: "请识别这张图片中的所有文字内容,按原文输出。仅输出文字本身,不要添加额外描述。"},
{Type: "image_url", ImageURL: &agentAPI.ImageURL{URL: imgURL, Detail: "high"}},
},
}
return a.mediaChat(a.provider, msg, "OCR 结果", 4096)
return a.mediaRequest(a.provider, "image/png", "没有待处理的图片数据", "图片数据为空",
a.inputCfg.Image.OCRPrompt, "OCR 结果", 4096, "image_url", "high")
}
// runStage — 运行阶段管道,若插件 Response 被设置则返回 true短路

View File

@ -11,7 +11,7 @@ import (
"time"
"gitcode.com/JianFeeeee/HomeAgent/pkg/types"
_ "github.com/mattn/go-sqlite3"
_ "modernc.org/sqlite"
)
type ConfigDef struct {
@ -37,7 +37,7 @@ func NewConfigRegistry(dbPath string) *ConfigRegistry {
if dbPath == "" {
dbPath = ":memory:"
}
db, err := sql.Open("sqlite3", dbPath)
db, err := sql.Open("sqlite", dbPath)
if err != nil {
panic(fmt.Sprintf("open config db: %v", err))
}
@ -95,6 +95,75 @@ func (r *ConfigRegistry) GetDef(key string) *ConfigDef {
return r.defs[key]
}
// sourceFieldDefs 定义 source 类型配置的字段元数据
var sourceFieldDefs = []struct {
Field string
Type string
DisplayName string
}{
{"base_url", "string", "API 地址"},
{"model", "string", "模型"},
{"api_key", "password", "API 密钥"},
{"thinking_enabled", "bool", "深度思考"},
{"adapter", "string", "适配器"},
{"adapter_path", "string", "适配器路径"},
}
// registerSourceDefs 注册 core.llm.sources.<name>.* 的 ConfigDef
func (r *ConfigRegistry) registerSourceDefs(name string) {
for _, fd := range sourceFieldDefs {
key := "core.llm.sources." + name + "." + fd.Field
if _, exists := r.defs[key]; exists {
continue
}
r.defs[key] = &ConfigDef{
Key: key,
Default: "",
Type: fd.Type,
DisplayName: name + " " + fd.DisplayName,
Category: "sources",
}
}
}
// scanAndRegisterSourceDefsLocked 扫描 config DB 中已有的 core.llm.sources.<name>.* 键并注册 defs调用方已持锁
func (r *ConfigRegistry) scanAndRegisterSourceDefsLocked() {
seen := make(map[string]bool)
rows, err := r.db.Query(`SELECT key FROM config WHERE key LIKE 'core.llm.sources.%.base_url'`)
if err != nil {
return
}
defer rows.Close()
for rows.Next() {
var k string
if err := rows.Scan(&k); err != nil {
continue
}
rest := strings.TrimPrefix(k, "core.llm.sources.")
name := strings.TrimSuffix(rest, ".base_url")
if name == "" || seen[name] {
continue
}
seen[name] = true
r.defsLockedRegisterSource(name)
}
}
func (r *ConfigRegistry) defsLockedRegisterSource(name string) {
for _, fd := range sourceFieldDefs {
key := "core.llm.sources." + name + "." + fd.Field
if _, exists := r.defs[key]; exists {
continue
}
r.defs[key] = &ConfigDef{
Key: key,
Default: "",
Type: fd.Type,
DisplayName: name + " " + fd.DisplayName,
Category: "sources",
}
}
}
func (r *ConfigRegistry) ListDefs(prefix string) []*ConfigDef {
r.mu.RLock()
defer r.mu.RUnlock()
@ -126,6 +195,13 @@ func (r *ConfigRegistry) Set(key string, value interface{}) error {
r.mu.Lock()
defer r.mu.Unlock()
_, err := r.db.Exec(`INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)`, key, fmt.Sprint(value))
if err == nil && strings.HasPrefix(key, "core.llm.sources.") {
rest := strings.TrimPrefix(key, "core.llm.sources.")
parts := strings.SplitN(rest, ".", 2)
if len(parts) == 2 && parts[1] != "" {
r.defsLockedRegisterSource(parts[0])
}
}
return err
}
@ -197,6 +273,8 @@ func (r *ConfigRegistry) SeedDefaults(dataDir string) {
defer r.mu.Unlock()
r.seedDBValues(dataDir)
r.seedCoreDefs(dataDir)
// 扫描 config DB 中已有的 core.llm.sources.<name> 并注册 defs
r.scanAndRegisterSourceDefsLocked()
}
func (r *ConfigRegistry) seedDBValues(dataDir string) {
@ -270,14 +348,38 @@ func (r *ConfigRegistry) seedDBValues(dataDir string) {
set("core.agent.max_context_size", "30")
set("core.agent.distill_interval", "30m")
set("core.agent.workdir", "")
set("core.agent.system_prompt", `你是 HomeAgent一个持续运行的个人管家。
你的每次回复会自动发送到当前输出通道(默认=输入源),无需额外工具。
如需切换回复通道,使用 output_set_channel。
如需异步发送消息或通知,使用 output_send 指定通道和内容。
使用 output_list_channels 查看可用通道及其能力。
可用工具列表会由系统自动传入,按需使用即可。以下是你尤其需要关注的几类工具:
- memory_* — 图记忆(长期记忆,记录和查询个人信息/事实)
- knowledge_* — 知识库(查阅预设知识文档)
- doc_* — 文档记忆(近期对话的存档,查询后自动清除)
- person_* — 人物特质与社交关系网
- llm_* — LLM 源管理(列出/切换模型提供商)
- output_* — 输出通道管理(切换/发送消息)
- timer_set — 设置定时提醒
- plgreload — 热重载插件
- spawn_child — 生成子 Agent 执行独立任务
- describe_image — 描述用户上传的图片
- transcribe_audio — 转写用户上传的音频
- ocr_image — 识别图片中的文字
当用户上传图片或音频时,系统会自动附着媒体内容。如果模型不支持直接处理多媒体,请使用上述工具。
回复你的真实想法,用自然语言与用户交流。`)
set("core.input_processing.image.fallback_provider", "")
set("core.input_processing.image.fallback_model", "")
set("core.input_processing.image.describe_prompt", "请详细描述这张图片的内容")
set("core.input_processing.image.describe_prompt", "请详细描述这张图片的内容,包括其中的文字、物体、人物、场景等信息。")
set("core.input_processing.image.ocr_enabled", "true")
set("core.input_processing.image.ocr_prompt", "请识别这张图片中的所有文字内容,按原文输出。仅输出文字本身,不要添加额外描述。")
set("core.input_processing.audio.fallback_provider", "")
set("core.input_processing.audio.fallback_model", "")
set("core.input_processing.audio.describe_prompt", "请描述这段音频的内容")
set("core.input_processing.audio.describe_prompt", "请转写这段音频的内容")
tx.Commit()
}
@ -294,7 +396,7 @@ func (r *ConfigRegistry) seedCoreDefs(dataDir string) {
reg(ConfigDef{Key: "core.llm.provider", Default: "deepseek", Type: "string", DisplayName: "默认提供商", Description: "默认 LLM 提供商名称,需匹配 sources 中的定义", Category: "llm"})
reg(ConfigDef{Key: "core.llm.model", Default: "deepseek-v4-flash", Type: "string", DisplayName: "默认模型", Description: "默认 LLM 模型名称", Category: "llm"})
reg(ConfigDef{Key: "core.llm.base_url", Default: "https://api.deepseek.com", Type: "string", DisplayName: "默认 API 地址", Description: "默认 LLM API 基础地址", Category: "llm"})
reg(ConfigDef{Key: "core.llm.api_key", Default: "", Type: "password", DisplayName: "默认 API 密钥", Description: "默认 LLM API 密钥(空则从环境变量读取)", Placeholder: "留空则使用 DEEPSEEK_API_KEY", Category: "llm"})
reg(ConfigDef{Key: "core.llm.api_key", Default: "", Type: "password", DisplayName: "默认 API 密钥", Description: "默认 LLM API 密钥(空则从环境变量读取)", Placeholder: "留空则使用 LLM_API_KEY 或 DEEPSEEK_API_KEY", Category: "llm"})
reg(ConfigDef{Key: "core.llm.adapter", Default: "deepseek", Type: "string", DisplayName: "默认适配器", Description: "协议适配器名称(对应 adapters/ 下的 Lua 脚本)", Category: "llm"})
reg(ConfigDef{Key: "core.llm.temperature", Default: "0.7", Type: "string", DisplayName: "生成温度", Description: "LLM 生成温度 (0.0-2.0)", Category: "llm"})
reg(ConfigDef{Key: "core.llm.max_tokens", Default: "4096", Type: "int", DisplayName: "最大 Token", Description: "每次生成的最大 Token 数", Category: "llm"})
@ -338,14 +440,16 @@ func (r *ConfigRegistry) seedCoreDefs(dataDir string) {
reg(ConfigDef{Key: "core.agent.max_context_size", Default: "30", Type: "int", DisplayName: "最大上下文", Description: "上下文窗口中保留的最大消息条数", Category: "agent"})
reg(ConfigDef{Key: "core.agent.distill_interval", Default: "30m", Type: "duration", DisplayName: "蒸馏间隔", Description: "记忆蒸馏的执行间隔", Category: "agent"})
reg(ConfigDef{Key: "core.agent.workdir", Default: "", Type: "string", DisplayName: "工作目录", Description: "Agent 命令执行的默认工作目录(如 cmd_run 工具的 fallback留空使用内核所在目录", Category: "agent"})
reg(ConfigDef{Key: "core.agent.system_prompt", Default: "", Type: "text", DisplayName: "系统身份提示词", Description: "Agent 的系统提示词,定义身份和行为规则。留空则使用编译时内置默认值。修改后需重启生效。", Category: "agent"})
reg(ConfigDef{Key: "core.input_processing.image.fallback_provider", Default: "", Type: "string", DisplayName: "图片回退提供商", Description: "当主 LLM 不支持图片处理时使用的提供商(留空则自动降级为文字描述)", Category: "input"})
reg(ConfigDef{Key: "core.input_processing.image.fallback_model", Default: "", Type: "string", DisplayName: "图片回退模型", Description: "图片回退提供商使用的模型名", Category: "input"})
reg(ConfigDef{Key: "core.input_processing.image.describe_prompt", Default: "请详细描述这张图片的内容", Type: "text", DisplayName: "图片描述提示词", Description: "生成图片文字描述时的系统提示词", Category: "input"})
reg(ConfigDef{Key: "core.input_processing.image.describe_prompt", Default: "请详细描述这张图片的内容,包括其中的文字、物体、人物、场景等信息。", Type: "text", DisplayName: "图片描述提示词", Description: "生成图片文字描述时的系统提示词", Category: "input"})
reg(ConfigDef{Key: "core.input_processing.image.ocr_enabled", Default: "true", Type: "bool", DisplayName: "启用 OCR", Description: "是否启用图片文字识别工具", Category: "input"})
reg(ConfigDef{Key: "core.input_processing.image.ocr_prompt", Default: "请识别这张图片中的所有文字内容,按原文输出。仅输出文字本身,不要添加额外描述。", Type: "text", DisplayName: "OCR 提示词", Description: "OCR 文字识别时的系统提示词", Category: "input"})
reg(ConfigDef{Key: "core.input_processing.audio.fallback_provider", Default: "", Type: "string", DisplayName: "音频回退提供商", Description: "当主 LLM 不支持音频处理时使用的提供商", Category: "input"})
reg(ConfigDef{Key: "core.input_processing.audio.fallback_model", Default: "", Type: "string", DisplayName: "音频回退模型", Description: "音频回退提供商使用的模型名", Category: "input"})
reg(ConfigDef{Key: "core.input_processing.audio.describe_prompt", Default: "请描述这段音频的内容", Type: "text", DisplayName: "音频描述提示词", Description: "生成音频文字描述时的系统提示词", Category: "input"})
reg(ConfigDef{Key: "core.input_processing.audio.describe_prompt", Default: "请转写这段音频的内容", Type: "text", DisplayName: "音频描述提示词", Description: "生成音频文字描述时的系统提示词", Category: "input"})
}
// helpers
@ -522,6 +626,7 @@ func (r *ConfigRegistry) ToConfig() *types.Config {
cfg.InputProcessing.Image.FallbackModel = read("core.input_processing.image.fallback_model", cfg.InputProcessing.Image.FallbackModel)
cfg.InputProcessing.Image.DescribePrompt = read("core.input_processing.image.describe_prompt", cfg.InputProcessing.Image.DescribePrompt)
cfg.InputProcessing.Image.OCREnabled = readBool("core.input_processing.image.ocr_enabled", cfg.InputProcessing.Image.OCREnabled)
cfg.InputProcessing.Image.OCRPrompt = read("core.input_processing.image.ocr_prompt", cfg.InputProcessing.Image.OCRPrompt)
cfg.InputProcessing.Audio.FallbackProvider = read("core.input_processing.audio.fallback_provider", cfg.InputProcessing.Audio.FallbackProvider)
cfg.InputProcessing.Audio.FallbackModel = read("core.input_processing.audio.fallback_model", cfg.InputProcessing.Audio.FallbackModel)
cfg.InputProcessing.Audio.DescribePrompt = read("core.input_processing.audio.describe_prompt", cfg.InputProcessing.Audio.DescribePrompt)
@ -587,6 +692,7 @@ func (p *PluginSettings) List(prefix string) ([]string, error) {
func (p *PluginSettings) RegisterDef(def ConfigDef) {
p.registry.mu.Lock()
defer p.registry.mu.Unlock()
p.registry.db.Exec(fmt.Sprintf(`INSERT OR IGNORE INTO %s (key, value) VALUES (?, ?)`, p.table), def.Key, def.Default)
qualified := "plugin." + p.name + "." + def.Key
def.Key = qualified
p.registry.defs[def.Key] = &def

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
}

View File

@ -6,7 +6,7 @@ import (
"sync"
"time"
_ "github.com/mattn/go-sqlite3"
_ "modernc.org/sqlite"
)
type Entity struct {
@ -49,7 +49,7 @@ type GraphDB struct {
}
func NewGraphDB(dbPath string) (*GraphDB, error) {
db, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_foreign_keys=on")
db, err := sql.Open("sqlite", dbPath+"?_journal_mode=WAL&_foreign_keys=on")
if err != nil {
return nil, fmt.Errorf("open graph db: %w", err)
}
@ -491,6 +491,69 @@ func (g *GraphDB) Purge(criteria map[string]string, mode string) (int, error) {
return int(n), nil
}
func (g *GraphDB) GraphData() (map[string]interface{}, error) {
g.mu.RLock()
defer g.mu.RUnlock()
rows, err := g.db.Query(`SELECT id, name, type, mention_count, created_at, updated_at FROM entities ORDER BY mention_count DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
type graphEntity struct {
ID int64 `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
MentionCount int `json:"mention_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
var entities []graphEntity
for rows.Next() {
var e graphEntity
if err := rows.Scan(&e.ID, &e.Name, &e.Type, &e.MentionCount, &e.CreatedAt, &e.UpdatedAt); err != nil {
return nil, err
}
entities = append(entities, e)
}
if err := rows.Err(); err != nil {
return nil, err
}
rrows, err := g.db.Query(`SELECT id, source_id, target_id, relation_type, confidence, status, created_at FROM relations WHERE status = 'active' ORDER BY created_at DESC`)
if err != nil {
return nil, err
}
defer rrows.Close()
type graphRelation struct {
ID int64 `json:"id"`
SourceID int64 `json:"source_id"`
TargetID int64 `json:"target_id"`
RelationType string `json:"relation_type"`
Confidence float64 `json:"confidence"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
}
var relations []graphRelation
for rrows.Next() {
var r graphRelation
if err := rrows.Scan(&r.ID, &r.SourceID, &r.TargetID, &r.RelationType, &r.Confidence, &r.Status, &r.CreatedAt); err != nil {
return nil, err
}
relations = append(relations, r)
}
if err := rrows.Err(); err != nil {
return nil, err
}
return map[string]interface{}{
"nodes": entities,
"edges": relations,
}, nil
}
func (g *GraphDB) Introspect() (map[string]interface{}, error) {
g.mu.RLock()
defer g.mu.RUnlock()

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{} {

View File

@ -1,3 +1,5 @@
//go:build linux
package agentcli
import (
@ -155,18 +157,13 @@ func (t *TerminalSession) IsExpired() bool {
}
type Plugin struct {
name string
mu sync.Mutex
wg sync.WaitGroup
stopCh chan struct{}
sessions map[string]*TerminalSession
nextID int
}
func init() {
plugin.RegisterFactory("agentcli", func(name string, config map[string]interface{}) (sdk.Plugin, error) {
return New(name), nil
})
name string
mu sync.Mutex
wg sync.WaitGroup
stopCh chan struct{}
sessions map[string]*TerminalSession
nextID int
defaultTimeout time.Duration
}
func New(name string) *Plugin {
@ -180,6 +177,22 @@ func New(name string) *Plugin {
func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "default_timeout", Type: "string", DisplayName: "默认终端超时",
Description: "终端自动关闭的默认时间,例如 5m, 10m, 30m, 1h默认 5m",
Default: "5m",
})
if v, _ := s.Settings().Get("default_timeout"); v != nil {
if s, ok := v.(string); ok && s != "" {
if d, err := time.ParseDuration(s); err == nil {
p.defaultTimeout = d
}
}
}
if p.defaultTimeout <= 0 {
p.defaultTimeout = DefaultTimeout
}
s.RegisterTool("terminal_create", sdk.ToolDef{
Name: "terminal_create",
Description: "创建一个新的交互式终端会话。返回终端 ID后续通过此 ID 进行读写操作。适用于运行交互式程序如 vim、ssh、top、nano 等。终端默认 5 分钟后自动关闭,可通过 timeout 参数调整。",
@ -339,7 +352,7 @@ func (p *Plugin) handleCreate(s *sdk.PluginSDK, args map[string]interface{}) (in
}
timeoutStr, _ := args["timeout"].(string)
timeout := DefaultTimeout
timeout := p.defaultTimeout
if timeoutStr != "" {
if d, err := time.ParseDuration(timeoutStr); err == nil {
timeout = d
@ -795,6 +808,13 @@ func isTimeoutError(err error) bool {
return strings.Contains(err.Error(), "timeout") || strings.Contains(err.Error(), "would block")
}
func init() {
plugin.RegisterFactory("agentcli", func(name string, config map[string]interface{}) (sdk.Plugin, error) {
return New(name), nil
})
plugin.RegisterPluginMeta("agentcli", "终端交互", "Agent CLI")
}
func sanitizePreview(s string) string {
var buf bytes.Buffer
for _, r := range s {

View File

@ -0,0 +1,21 @@
//go:build !linux
package agentcli
import (
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
func init() {
plugin.RegisterFactory("agentcli", func(name string, config map[string]interface{}) (sdk.Plugin, error) {
return &stubPlugin{}, nil
})
plugin.RegisterPluginMeta("agentcli", "终端交互", "Agent CLI")
}
type stubPlugin struct{}
func (p *stubPlugin) Name() string { return "agentcli" }
func (p *stubPlugin) Start(sdk *sdk.PluginSDK) error { return nil }
func (p *stubPlugin) Stop() error { return nil }

View File

@ -38,6 +38,7 @@ func Configure(pr *plugin.Registry, cr *internalConfig.ConfigRegistry, sp agentC
}
func init() {
plugin.RegisterPluginMeta("cli", "CLI", "CLI")
plugin.RegisterFactory("cli", func(name string, config map[string]interface{}) (sdk.Plugin, error) {
sock := DefaultSocket
if sock == "" {
@ -69,6 +70,20 @@ func New(name, socketPath string) *Plugin {
func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "api_key", Type: "password", DisplayName: "CLI API 密钥",
Description: "CLI 客户端连接时需提供的认证密钥(留空则使用 WebUI 密钥)",
})
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "socket_path", Type: "string", DisplayName: "Socket 管道路径",
Description: "CLI Unix 域套接字监听路径(留空则使用默认路径)",
})
if v, _ := s.Settings().Get("socket_path"); v != nil {
if s, ok := v.(string); ok && s != "" {
p.socket = s
}
}
dir := filepath.Dir(p.socket)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("create socket dir: %w", err)
@ -109,7 +124,7 @@ func (p *Plugin) handleConn(conn net.Conn, s *sdk.PluginSDK) {
scanner := bufio.NewScanner(conn)
apiKey := p.webuiAPIKey()
apiKey := p.cliAPIKey(s)
if apiKey != "" {
if !scanner.Scan() {
return
@ -156,6 +171,17 @@ func (p *Plugin) handleConn(conn net.Conn, s *sdk.PluginSDK) {
}
}
func (p *Plugin) cliAPIKey(s *sdk.PluginSDK) string {
if s != nil {
if v, _ := s.Settings().Get("api_key"); v != nil {
if k, ok := v.(string); ok && k != "" {
return k
}
}
}
return p.webuiAPIKey()
}
func (p *Plugin) webuiAPIKey() string {
if cfgReg == nil {
return ""

View File

@ -6,6 +6,7 @@ import (
"fmt"
"os/exec"
"strings"
"sync"
"time"
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
@ -41,13 +42,32 @@ func shellUnquote(s string) []string {
}
func init() {
plugin.RegisterPluginMeta("cmd", "命令执行", "Command")
plugin.RegisterFactory("cmd", func(name string, config map[string]interface{}) (sdk.Plugin, error) {
return New(name), nil
})
}
type cmdRecord struct {
Timestamp time.Time `json:"timestamp"`
Command string `json:"command"`
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
ExitCode int `json:"exit_code"`
Workdir string `json:"workdir"`
Timeout string `json:"timeout"`
Duration string `json:"duration"`
Status string `json:"status"`
}
const maxHistory = 100
type Plugin struct {
name string
name string
defaultTimeout string
maxOutput int
mu sync.Mutex
history []cmdRecord
}
func New(name string) *Plugin {
@ -57,6 +77,30 @@ func New(name string) *Plugin {
func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "default_timeout", Type: "string", DisplayName: "默认命令超时",
Description: "命令执行的默认超时时间,例如 30s, 1m, 5m默认 30s",
Default: "30s",
})
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "max_output_bytes", Type: "int", DisplayName: "最大输出字节数",
Description: "命令输出的最大字节数,超出部分将被截断(默认 32000",
Default: "32000",
})
p.maxOutput = 32000
if v, _ := s.Settings().Get("max_output_bytes"); v != nil {
if s, ok := v.(string); ok && s != "" {
if n, err := fmt.Sscanf(s, "%d", &p.maxOutput); err == nil && n > 0 {
}
}
}
p.defaultTimeout = "30s"
if v, _ := s.Settings().Get("default_timeout"); v != nil {
if s, ok := v.(string); ok && s != "" {
p.defaultTimeout = s
}
}
s.RegisterTool("cmd_run", sdk.ToolDef{
Name: "cmd_run",
Description: "执行一条系统命令并返回输出。适用于查询系统信息、运行脚本、操作文件等单次命令场景。命令在临时 shell 中执行,不支持交互。如需交互式终端(如 vim、ssh、top请使用 terminal_create 相关工具。",
@ -86,7 +130,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
timeoutStr, _ := args["timeout"].(string)
if timeoutStr == "" {
timeoutStr = "30s"
timeoutStr = p.defaultTimeout
}
timeout, err := time.ParseDuration(timeoutStr)
if err != nil {
@ -104,6 +148,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
}
}
tStart := time.Now()
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
@ -120,22 +165,40 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
cmd.Stdout = &stdout
cmd.Stderr = &stderr
rec := cmdRecord{Timestamp: tStart, Command: command, Workdir: workdir, Timeout: timeoutStr}
exitCode := -1
if err := cmd.Run(); err != nil {
if ctx.Err() != nil {
rec.Status = "timeout"
rec.Stdout = p.truncateOutput(stdout.String())
rec.Stderr = p.truncateOutput(stderr.String())
rec.Duration = time.Since(tStart).Round(time.Millisecond).String()
p.recordCmd(rec)
return map[string]interface{}{
"status": "timeout",
"stdout": truncateOutput(stdout.String()),
"stderr": truncateOutput(stderr.String()),
"stdout": rec.Stdout,
"stderr": rec.Stderr,
"error": fmt.Sprintf("命令执行超时(%s", timeoutStr),
}, nil
}
}
if cmd.ProcessState != nil {
exitCode = cmd.ProcessState.ExitCode()
}
rec.Status = "ok"
rec.Stdout = p.truncateOutput(stdout.String())
rec.Stderr = p.truncateOutput(stderr.String())
rec.ExitCode = exitCode
rec.Duration = time.Since(tStart).Round(time.Millisecond).String()
p.recordCmd(rec)
return map[string]interface{}{
"status": "ok",
"stdout": truncateOutput(stdout.String()),
"stderr": truncateOutput(stderr.String()),
"exit_code": cmd.ProcessState.ExitCode(),
"stdout": rec.Stdout,
"stderr": rec.Stderr,
"exit_code": exitCode,
"command": command,
}, nil
})
@ -147,8 +210,20 @@ func (p *Plugin) Stop() error {
return nil
}
func truncateOutput(s string) string {
const maxLen = 32000
func (p *Plugin) recordCmd(r cmdRecord) {
p.mu.Lock()
defer p.mu.Unlock()
p.history = append(p.history, r)
if len(p.history) > maxHistory {
p.history = p.history[len(p.history)-maxHistory:]
}
}
func (p *Plugin) truncateOutput(s string) string {
maxLen := p.maxOutput
if maxLen <= 0 {
maxLen = 32000
}
if len(s) > maxLen {
return s[:maxLen] + fmt.Sprintf("\n... [输出被截断,共 %d 字节]", len(s))
}

View File

@ -14,6 +14,7 @@ import (
)
func init() {
plugin.RegisterPluginMeta("files", "文件系统", "Files")
plugin.RegisterFactory("files", func(name string, config map[string]interface{}) (sdk.Plugin, error) {
return New(name), nil
})

View File

@ -63,6 +63,7 @@ func Configure(sh *agentCore.StageHost, iom *agentIO.IOManager, pr *plugin.Regis
}
func init() {
plugin.RegisterPluginMeta("healthcheck", "健康检查", "Health Check")
plugin.RegisterFactory("healthcheck", func(name string, config map[string]interface{}) (sdk.Plugin, error) {
if hcStageHost == nil {
return nil, nil
@ -81,6 +82,12 @@ type Plugin struct {
stopCh chan struct{}
stopOnce sync.Once
perfData PerfData
autoInterval time.Duration
llmTimeout time.Duration
llmMaxTurns int
llmMaxTokens int
perfHistory int
}
type PerfData struct {
@ -106,6 +113,74 @@ func New(name string) *Plugin {
func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error {
p.autoInterval = 30 * time.Minute
p.llmTimeout = 120 * time.Second
p.llmMaxTurns = 20
p.llmMaxTokens = 4096
p.perfHistory = 100
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "auto_interval", Type: "string", DisplayName: "自动检查间隔",
Description: "自动健康检查的执行间隔,例如 30m, 1h, 10m设为 0 禁用)",
Default: "30m",
})
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "llm_timeout", Type: "string", DisplayName: "LLM 检查超时",
Description: "LLM 驱动检查的超时时间,例如 120s, 3m, 5m默认 120s",
Default: "120s",
})
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "llm_max_turns", Type: "int", DisplayName: "LLM 最大对话轮数",
Description: "LLM 工具发现的最大对话轮数(默认 20",
Default: "20",
})
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "llm_max_tokens", Type: "int", DisplayName: "LLM 最大 Token",
Description: "LLM 调用时的最大 Token 数(默认 4096",
Default: "4096",
})
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "perf_history", Type: "int", DisplayName: "性能历史保留数",
Description: "保留的历史检查记录条数(默认 100",
Default: "100",
})
if v, _ := s.Settings().Get("auto_interval"); v != nil {
if s, ok := v.(string); ok && s != "" {
if d, err := time.ParseDuration(s); err == nil && d > 0 {
p.autoInterval = d
}
}
}
if v, _ := s.Settings().Get("llm_timeout"); v != nil {
if s, ok := v.(string); ok && s != "" {
if d, err := time.ParseDuration(s); err == nil && d > 0 {
p.llmTimeout = d
}
}
}
if v, _ := s.Settings().Get("llm_max_turns"); v != nil {
if s, ok := v.(string); ok && s != "" {
if n, err := fmt.Sscanf(s, "%d", &p.llmMaxTurns); err != nil || n < 1 {
p.llmMaxTurns = 20
}
}
}
if v, _ := s.Settings().Get("llm_max_tokens"); v != nil {
if s, ok := v.(string); ok && s != "" {
if n, err := fmt.Sscanf(s, "%d", &p.llmMaxTokens); err != nil || n < 1 {
p.llmMaxTokens = 4096
}
}
}
if v, _ := s.Settings().Get("perf_history"); v != nil {
if s, ok := v.(string); ok && s != "" {
if n, err := fmt.Sscanf(s, "%d", &p.perfHistory); err != nil || n < 1 {
p.perfHistory = 100
}
}
}
p.selfToolNames["healthcheck"] = true
s.RegisterTool("healthcheck", sdk.ToolDef{
Name: "healthcheck",
@ -220,7 +295,9 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
}, nil
})
p.startAutoCheck(s, 30*time.Minute)
if p.autoInterval > 0 {
p.startAutoCheck(s, p.autoInterval)
}
log.Printf("[healthcheck] ready (stageHost=%v iom=%v reg=%v mem=%v ks=%v ds=%v pm=%v sp=%v)",
hcStageHost != nil, hcIOMgr != nil, hcPluginReg != nil,
@ -278,8 +355,8 @@ func (p *Plugin) runAutoCheck(s *sdk.PluginSDK) {
p.mu.Lock()
p.perfData.LastCheck = pt.Time
p.perfData.Checks = append(p.perfData.Checks, pt)
if len(p.perfData.Checks) > 100 {
p.perfData.Checks = p.perfData.Checks[len(p.perfData.Checks)-100:]
if len(p.perfData.Checks) > p.perfHistory {
p.perfData.Checks = p.perfData.Checks[len(p.perfData.Checks)-p.perfHistory:]
}
p.mu.Unlock()
@ -511,7 +588,7 @@ func (p *Plugin) testLLMDriven() checkResult {
}
start := time.Now()
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), p.llmTimeout)
defer cancel()
// 收集所有工具定义(排除健康检查自身的工具以避免循环测试)
@ -537,10 +614,10 @@ func (p *Plugin) testLLMDriven() checkResult {
turnCount := 0
toolCallCount := 0
for turn := 0; turn < 20; turn++ {
for turn := 0; turn < p.llmMaxTurns; turn++ {
resp, err := provider.Chat(ctx, &agentAPI.CompletionRequest{
Messages: msgs,
MaxTokens: 4096,
MaxTokens: p.llmMaxTokens,
Tools: tools,
ToolChoice: "auto",
})

View File

@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"log"
"strings"
"sync"
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
@ -20,6 +21,7 @@ type serverConfig struct {
}
func init() {
plugin.RegisterPluginMeta("mcp", "MCP 服务器", "MCP")
plugin.RegisterFactory("mcp", func(name string, config map[string]interface{}) (sdk.Plugin, error) {
return New(name), nil
})
@ -39,15 +41,6 @@ func New(name string) *Plugin {
func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error {
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)
@ -89,7 +82,49 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
}
func (p *Plugin) loadConfig(s *sdk.PluginSDK) ([]serverConfig, error) {
// 优先从 skill.jsonconfig map读取
// 优先从独立服务器配置键读取servers.<name>.<field>
keys, _ := s.Settings().List("servers.")
if len(keys) > 0 {
serverNames := make(map[string]bool)
for _, k := range keys {
parts := strings.SplitN(k, ".", 3)
if len(parts) >= 2 {
serverNames[parts[1]] = true
}
}
var cfgs []serverConfig
for name := range serverNames {
cfg := serverConfig{Name: name}
if v, _ := s.Settings().Get("servers." + name + ".command"); v != nil {
if s, ok := v.(string); ok {
cfg.Command = s
}
}
if v, _ := s.Settings().Get("servers." + name + ".url"); v != nil {
if s, ok := v.(string); ok {
cfg.URL = s
}
}
if v, _ := s.Settings().Get("servers." + name + ".args"); v != nil {
if s, ok := v.(string); ok && s != "" {
json.Unmarshal([]byte(s), &cfg.Args)
}
}
if v, _ := s.Settings().Get("servers." + name + ".env"); v != nil {
if s, ok := v.(string); ok && s != "" {
json.Unmarshal([]byte(s), &cfg.Env)
}
}
if cfg.Command != "" || cfg.URL != "" {
cfgs = append(cfgs, cfg)
}
}
if len(cfgs) > 0 {
return cfgs, nil
}
}
// 回退:从旧版 JSON blob 读取
raw, err := s.Settings().Get("servers")
if err == nil {
switch v := raw.(type) {
@ -107,8 +142,6 @@ func (p *Plugin) loadConfig(s *sdk.PluginSDK) ([]serverConfig, error) {
}
}
// 备用:从 JSON 文件读取
// 没有配置时不报错,只返回空
return nil, nil
}

View File

@ -27,6 +27,7 @@ var SkillsDir string
var SimulatorDir string
func init() {
plugin.RegisterPluginMeta("openclaw", "开放式交互", "OpenClaw")
plugin.RegisterFactory("openclaw", func(name string, config map[string]interface{}) (sdk.Plugin, error) {
dir := SkillsDir
if dir == "" {
@ -69,6 +70,25 @@ func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error {
p.sdk = s
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "skills_dir", Type: "string", DisplayName: "Skill 加载目录",
Description: "OpenClaw 技能加载目录路径(留空则使用默认路径)",
})
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "simulator_dir", Type: "string", DisplayName: "模拟器工作目录",
Description: "OpenClaw 模拟器工作目录路径(留空则使用默认路径)",
})
if v, _ := s.Settings().Get("skills_dir"); v != nil {
if s, ok := v.(string); ok && s != "" {
p.skillsDir = s
}
}
if v, _ := s.Settings().Get("simulator_dir"); v != nil {
if s, ok := v.(string); ok && s != "" {
p.simulatorDir = s
}
}
// Launch OC plugin manager first (handles OC-format plugin installation and lifecycle)
os.MkdirAll(p.skillsDir, 0755)
if err := p.launchManager(s); err != nil {

View File

@ -41,6 +41,7 @@ var (
)
func init() {
plugin.RegisterPluginMeta("pluginmgr", "插件管理", "Plugin Manager")
plugin.RegisterFactory("pluginmgr", func(name string, config map[string]interface{}) (sdk.Plugin, error) {
return New(name), nil
})
@ -504,7 +505,7 @@ func validatePackage(data []byte) (*pluginPackage, error) {
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}
valid := map[string]bool{"plugin.so": true, "plugin.dll": true, "main.lua": true, "SKILL.md": true}
if !valid[pkg.Entry] {
return nil, fmt.Errorf("unsupported entry: %q", pkg.Entry)
}

View File

@ -11,6 +11,7 @@ import (
)
func init() {
plugin.RegisterPluginMeta("timer", "定时任务", "Timer")
plugin.RegisterFactory("timer", func(name string, config map[string]interface{}) (sdk.Plugin, error) {
return New(name), nil
})
@ -21,6 +22,7 @@ type Plugin struct {
mu sync.Mutex
wg sync.WaitGroup
stopCh chan struct{}
maxDur time.Duration
}
type timerTask struct {
@ -38,6 +40,20 @@ func New(name string) *Plugin {
func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error {
p.maxDur = 24 * time.Hour
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "max_duration", Type: "string", DisplayName: "最大定时时长",
Description: "允许设置的最大定时时长,例如 24h, 7d, 1h默认 24h",
Default: "24h",
})
if v, _ := s.Settings().Get("max_duration"); v != nil {
if s, ok := v.(string); ok && s != "" {
if d, err := time.ParseDuration(s); err == nil && d > 0 {
p.maxDur = d
}
}
}
s.RegisterTool("timer_set", sdk.ToolDef{
Name: "timer_set",
Description: "设置一个定时提醒。倒计时结束后通过中断通道通知 agent。",
@ -69,6 +85,9 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
if err != nil {
return map[string]interface{}{"error": fmt.Sprintf("invalid duration %q: %v", durStr, err)}, nil
}
if dur > p.maxDur {
return map[string]interface{}{"error": fmt.Sprintf("duration %v exceeds max %v", dur, p.maxDur)}, nil
}
p.mu.Lock()
p.wg.Add(1)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -15,6 +15,7 @@ import (
"sync"
"time"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
agentCore "gitcode.com/JianFeeeee/HomeAgent/internal/agent/core"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config"
@ -61,16 +62,55 @@ type Handler struct {
pluginReg *plugin.Registry
eventBus *events.Bus
statusProvider agentCore.StatusProvider
providerMgr *agentAPI.ProviderManager
baseAPIKey string
sessionMu sync.Mutex
sessions map[string]time.Time
chatMu sync.Mutex
chatHistory []ChatMsg
cmdMu sync.Mutex
cmdHistory []CmdExec
termMu sync.Mutex
termStates map[string]*termState
}
func NewHandler(sup *supervisor.Daemon, mem *memory.GraphDB, sk *skill.Manager, lua *luaVM.VM, cfg *types.Config, iom *agentIO.IOManager, tm *text.Memory, ks *knowledge.Store, tr *tracker.Tracker, cr *internalConfig.ConfigRegistry, pr *plugin.Registry, evBus *events.Bus, sp agentCore.StatusProvider) *Handler {
type ChatMsg struct {
Role string `json:"role"`
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content,omitempty"`
Time string `json:"time"`
}
type CmdExec struct {
Command string `json:"command"`
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
ExitCode int `json:"exit_code"`
Status string `json:"status"`
Time string `json:"time"`
}
type termState struct {
ID string `json:"id"`
Command string `json:"command"`
Running bool `json:"running"`
Output string `json:"output"`
CreatedAt string `json:"created_at"`
Uptime string `json:"uptime"`
created time.Time
}
const maxChatHistory = 200
const maxCmdHistory = 100
const maxTerminals = 50
func NewHandler(sup *supervisor.Daemon, mem *memory.GraphDB, sk *skill.Manager, lua *luaVM.VM, cfg *types.Config, iom *agentIO.IOManager, tm *text.Memory, ks *knowledge.Store, tr *tracker.Tracker, cr *internalConfig.ConfigRegistry, pr *plugin.Registry, evBus *events.Bus, sp agentCore.StatusProvider, pm *agentAPI.ProviderManager, baseKey string) *Handler {
var idx *memory.Indexer
if mem != nil {
idx = memory.NewIndexer(mem)
}
return &Handler{
h := &Handler{
supervisor: sup,
memory: mem,
indexer: idx,
@ -86,8 +126,83 @@ func NewHandler(sup *supervisor.Daemon, mem *memory.GraphDB, sk *skill.Manager,
pluginReg: pr,
eventBus: evBus,
statusProvider: sp,
providerMgr: pm,
baseAPIKey: baseKey,
sessions: make(map[string]time.Time),
termStates: make(map[string]*termState),
}
if evBus != nil {
go h.trackToolEvents()
}
return h
}
func (h *Handler) trackToolEvents() {
h.eventBus.Subscribe(events.EventToolCall, func(ev *events.Event) {
h.handleToolEvent(ev)
})
}
func (h *Handler) handleToolEvent(ev *events.Event) {
payload := ev.Payload
tool, _ := payload["tool"].(string)
args, _ := payload["args"].(map[string]interface{})
status, _ := payload["status"].(string)
ts := time.Now()
switch tool {
case "cmd_run":
exec := CmdExec{
Command: getStr(args, "command"),
Status: status,
Time: ts.Format(time.RFC3339),
}
h.cmdMu.Lock()
h.cmdHistory = append(h.cmdHistory, exec)
if len(h.cmdHistory) > maxCmdHistory {
h.cmdHistory = h.cmdHistory[len(h.cmdHistory)-maxCmdHistory:]
}
h.cmdMu.Unlock()
case "terminal_create":
id := getStr(args, "id")
cmd := getStr(args, "command")
now := time.Now()
term := &termState{
ID: id,
Command: cmd,
Running: true,
CreatedAt: now.Format(time.RFC3339),
created: now,
}
h.termMu.Lock()
h.termStates[id] = term
if len(h.termStates) > maxTerminals {
for k := range h.termStates {
delete(h.termStates, k)
break
}
}
h.termMu.Unlock()
case "terminal_close":
id := getStr(args, "id")
if id != "" {
h.termMu.Lock()
if t, ok := h.termStates[id]; ok {
t.Running = false
}
h.termMu.Unlock()
}
}
}
func getStr(m map[string]interface{}, key string) string {
if m == nil {
return ""
}
v, _ := m[key].(string)
return v
}
func (h *Handler) getWebUIConfig() (apiKey, username, password string, ttl time.Duration) {
@ -204,6 +319,7 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/skills", h.requireAPI(h.handleSkills))
mux.HandleFunc("/api/v1/memory", h.requireAPI(h.handleMemory))
mux.HandleFunc("/api/v1/memory/", h.requireAPI(h.handleMemory))
mux.HandleFunc("/api/v1/memory/graph", h.requireAPI(h.handleMemoryGraph))
mux.HandleFunc("/api/v1/memory/context", h.requireAPI(h.handleMemoryContext))
mux.HandleFunc("/api/v1/memory/tools", h.requireAPI(h.handleMemoryTools))
mux.HandleFunc("/api/v1/memory/text", h.requireAPI(h.handleTextMemory))
@ -218,7 +334,10 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/tracker", h.requireAPI(h.handleTracker))
mux.HandleFunc("/api/v1/tracker/", h.requireAPI(h.handleTracker))
mux.HandleFunc("/api/v1/chat", h.requireAPI(h.handleChat))
mux.HandleFunc("/api/v1/chat/history", h.requireAPI(h.handleChatHistory))
mux.HandleFunc("/api/v1/chat/events", h.requireAPI(h.handleChatEvents))
mux.HandleFunc("/api/v1/terminals", h.requireAPI(h.handleTerminals))
mux.HandleFunc("/api/v1/cmd/history", h.requireAPI(h.handleCmdHistory))
mux.HandleFunc("/api/v1/kernel", h.requireAPI(h.handleKernel))
mux.HandleFunc("/api/v1/plugins", h.requireAPI(h.handlePlugins))
mux.HandleFunc("/api/v1/plugins/", h.requireAPI(h.handlePluginByID))
@ -528,6 +647,23 @@ func (h *Handler) handleMemoryTools(w http.ResponseWriter, r *http.Request) {
})
}
func (h *Handler) handleMemoryGraph(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if h.memory == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "memory system not available"})
return
}
data, err := h.memory.GraphData()
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"success": true, "data": data})
}
func (h *Handler) handleKnowledge(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
@ -652,7 +788,7 @@ func (h *Handler) handleAdapters(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
if err := h.lua.ReloadAll(); err != nil {
if err := h.lua.LoadAdapter(path); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
@ -687,7 +823,7 @@ func (h *Handler) handleAdapterByID(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "adapter not found"})
return
}
h.lua.ReloadAll()
h.lua.RemoveAdapter(name)
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted", "name": name})
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
@ -705,6 +841,42 @@ func (h *Handler) handleNetwork(w http.ResponseWriter, r *http.Request) {
})
}
func (h *Handler) addChatMsg(msg ChatMsg) {
h.chatMu.Lock()
defer h.chatMu.Unlock()
h.chatHistory = append(h.chatHistory, msg)
if len(h.chatHistory) > maxChatHistory {
h.chatHistory = h.chatHistory[len(h.chatHistory)-maxChatHistory:]
}
}
func (h *Handler) handleChatHistory(w http.ResponseWriter, r *http.Request) {
h.chatMu.Lock()
result := make([]ChatMsg, len(h.chatHistory))
copy(result, h.chatHistory)
h.chatMu.Unlock()
writeJSON(w, http.StatusOK, map[string]interface{}{"messages": result})
}
func (h *Handler) handleTerminals(w http.ResponseWriter, r *http.Request) {
h.termMu.Lock()
terms := make([]*termState, 0, len(h.termStates))
for _, ts := range h.termStates {
ts.Uptime = time.Since(ts.created).Round(time.Second).String()
terms = append(terms, ts)
}
h.termMu.Unlock()
writeJSON(w, http.StatusOK, map[string]interface{}{"terminals": terms})
}
func (h *Handler) handleCmdHistory(w http.ResponseWriter, r *http.Request) {
h.cmdMu.Lock()
result := make([]CmdExec, len(h.cmdHistory))
copy(result, h.cmdHistory)
h.cmdMu.Unlock()
writeJSON(w, http.StatusOK, map[string]interface{}{"history": result})
}
func (h *Handler) handleChat(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
@ -722,15 +894,22 @@ func (h *Handler) handleChat(w http.ResponseWriter, r *http.Request) {
return
}
h.addChatMsg(ChatMsg{Role: "user", Content: body.Message, Time: time.Now().Format(time.RFC3339)})
resp := h.iom.InjectTextSync("cli", body.Message)
if resp == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "agent unavailable"})
return
}
content, _ := resp.Payload["content"].(string)
writeJSON(w, http.StatusOK, map[string]interface{}{
reasoning, _ := resp.Payload["reasoning_content"].(string)
result := map[string]interface{}{
"response": content,
})
}
if reasoning != "" {
result["reasoning_content"] = reasoning
}
h.addChatMsg(ChatMsg{Role: "assistant", Content: content, ReasoningContent: reasoning, Time: time.Now().Format(time.RFC3339)})
writeJSON(w, http.StatusOK, result)
}
func (h *Handler) handleChatEvents(w http.ResponseWriter, r *http.Request) {
@ -771,15 +950,17 @@ func (h *Handler) handleChatEvents(w http.ResponseWriter, r *http.Request) {
}
}()
unsub := h.eventBus.Subscribe(events.EventAll, func(evt *events.Event) {
data, _ := json.Marshal(evt)
select {
case writeCh <- fmt.Sprintf("event: %s\ndata: %s", evt.Type, string(data)):
default:
}
})
defer unsub()
subTypes := []string{"agent_output", "reasoning", "agent_error"}
for _, t := range subTypes {
t2 := t
_ = h.eventBus.Subscribe(events.EventType(t2), func(evt *events.Event) {
data, _ := json.Marshal(evt)
select {
case writeCh <- fmt.Sprintf("event: %s\ndata: %s", evt.Type, string(data)):
default:
}
})
}
for {
select {
case <-done:
@ -844,10 +1025,30 @@ func (h *Handler) handleSettings(w http.ResponseWriter, r *http.Request) {
defs := h.cfgReg.ListDefs(prefix)
for _, d := range defs {
meta[d.Key] = d
// 有 def 但 DB 中尚无值的 key用 default 填充以便在 WebUI 中显示和编辑
if _, exists := values[d.Key]; !exists {
values[d.Key] = d.Default
}
}
// 无前缀时同时加载所有插件配置
if prefix == "" && h.pluginReg != nil {
for _, p := range h.pluginReg.List() {
ps := h.cfgReg.PluginConfig(p)
pkeys, _ := ps.List("")
for _, k := range pkeys {
v, _ := ps.Get(k)
fullKey := "plugin." + p + "." + k
values[fullKey] = v
if def := h.cfgReg.GetDef(fullKey); def != nil {
meta[fullKey] = def
}
}
}
}
}
plugins := []string{"core"}
pm := h.pluginReg.PluginMetas()
if h.pluginReg != nil {
for _, p := range h.pluginReg.List() {
plugins = append(plugins, "plugin."+p)
@ -855,9 +1056,10 @@ 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,
"settings": values,
"meta": meta,
"plugins": plugins,
"plugin_meta": pm,
})
case http.MethodPut:
var body struct {
@ -883,12 +1085,37 @@ func (h *Handler) handleSettings(w http.ResponseWriter, r *http.Request) {
return
}
}
if strings.HasPrefix(body.Key, "core.llm.") && h.providerMgr != nil && h.lua != nil {
h.reloadLLMProviders()
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (h *Handler) reloadLLMProviders() {
cfg := h.cfgReg.ToConfig()
h.providerMgr.Reset()
for _, src := range cfg.LLM.Sources {
key := src.APIKey
if key == "" {
key = h.baseAPIKey
}
provider := agentAPI.NewLuaAdaptedProvider(agentAPI.BaseConfig{
Model: src.Model,
BaseURL: src.BaseURL,
APIKey: key,
Temperature: cfg.LLM.Temperature,
MaxTokens: cfg.LLM.MaxTokens,
}, h.lua, src.Adapter)
h.providerMgr.Register(src.Name, provider)
}
if cfg.LLM.Provider != "" {
_ = h.providerMgr.SetDefault(cfg.LLM.Provider)
}
}
func (h *Handler) handleOpenAICompletions(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
@ -1169,6 +1396,9 @@ func (h *Handler) handlePluginByID(w http.ResponseWriter, r *http.Request) {
func (h *Handler) handleStatic(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expires", "0")
w.Write([]byte(dashboardHTML))
return
}

View File

@ -7,6 +7,7 @@ import (
"log"
"net/http"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
agentCore "gitcode.com/JianFeeeee/HomeAgent/internal/agent/core"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config"
@ -39,6 +40,8 @@ var (
webuiPR *plugin.Registry
webuiEvBus *events.Bus
webuiStatusProvider agentCore.StatusProvider
webuiProviderMgr *agentAPI.ProviderManager
webuiBaseAPIKey string
)
// Configure 注入 WebUI 插件需要的内核依赖。必须在 Load() 之前调用。
@ -47,16 +50,19 @@ func Configure(addr string,
lua *luaVM.VM, cfg *types.Config, iom *agentIO.IOManager,
tm *text.Memory, ks *knowledge.Store, tr *tracker.Tracker,
cr *internalConfig.ConfigRegistry, pr *plugin.Registry, evBus *events.Bus,
sp agentCore.StatusProvider,
sp agentCore.StatusProvider, pm *agentAPI.ProviderManager, baseKey string,
) {
webuiAddr = addr
webuiSup, webuiMem, webuiSK, webuiLua = sup, mem, sk, lua
webuiCfg, webuiIOM, webuiTM, webuiKS = cfg, iom, tm, ks
webuiTR, webuiCR, webuiPR, webuiEvBus = tr, cr, pr, evBus
webuiStatusProvider = sp
webuiProviderMgr = pm
webuiBaseAPIKey = baseKey
}
func init() {
plugin.RegisterPluginMeta("webui", "Web 控制台", "WebUI")
plugin.RegisterFactory("webui", func(name string, config map[string]interface{}) (sdk.Plugin, error) {
if webuiSup == nil {
return nil, nil // 未 Configure 则跳过(不给日志警告)
@ -69,6 +75,7 @@ func init() {
webuiSup, webuiMem, webuiSK, webuiLua,
webuiCfg, webuiIOM, webuiTM, webuiKS,
webuiTR, webuiCR, webuiPR, webuiEvBus, webuiStatusProvider,
webuiProviderMgr, webuiBaseAPIKey,
), nil
})
}
@ -93,6 +100,8 @@ type Plugin struct {
pr *plugin.Registry
evBus *events.Bus
statusProvider agentCore.StatusProvider
providerMgr *agentAPI.ProviderManager
baseAPIKey string
}
func New(name, addr string,
@ -100,7 +109,7 @@ func New(name, addr string,
lua *luaVM.VM, cfg *types.Config, iom *agentIO.IOManager,
tm *text.Memory, ks *knowledge.Store, tr *tracker.Tracker,
cr *internalConfig.ConfigRegistry, pr *plugin.Registry, evBus *events.Bus,
sp agentCore.StatusProvider,
sp agentCore.StatusProvider, pm *agentAPI.ProviderManager, baseKey string,
) *Plugin {
return &Plugin{
name: name,
@ -108,7 +117,7 @@ func New(name, addr string,
mux: http.NewServeMux(),
sup: sup, mem: mem, sk: sk, lua: lua, cfg: cfg,
iom: iom, tm: tm, ks: ks, tr: tr, cr: cr, pr: pr, evBus: evBus,
statusProvider: sp,
statusProvider: sp, providerMgr: pm, baseAPIKey: baseKey,
}
}
@ -151,7 +160,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.Settings().RegisterDef(sdk.ConfigDef{Key: "password", Default: "", Type: "password", DisplayName: "Web 控制台登录密码", Description: "Web 控制台登录密码", Category: "webui"})
s.Settings().RegisterDef(sdk.ConfigDef{Key: "session_ttl_hours", Default: "24", Type: "int", DisplayName: "会话时长(小时)", Description: "登录 cookie 有效时长", Category: "webui"})
p.ensureAuthBootstrap(s)
h := NewHandler(p.sup, p.mem, p.sk, p.lua, p.cfg, p.iom, p.tm, p.ks, p.tr, p.cr, p.pr, p.evBus, p.statusProvider)
h := NewHandler(p.sup, p.mem, p.sk, p.lua, p.cfg, p.iom, p.tm, p.ks, p.tr, p.cr, p.pr, p.evBus, p.statusProvider, p.providerMgr, p.baseAPIKey)
p.handler = h
h.RegisterRoutes(p.mux)