diff --git a/cmd/homed/main.go b/cmd/homed/main.go index 096f2c5..bb8018a 100644 --- a/cmd/homed/main.go +++ b/cmd/homed/main.go @@ -6,7 +6,6 @@ import ( "os" "os/signal" "path/filepath" - "strconv" "syscall" "time" @@ -18,11 +17,14 @@ import ( "gitcode.com/JianFeeeee/HomeAgent/config" internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config" "gitcode.com/JianFeeeee/HomeAgent/internal/events" + "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/test_deepseek" + sdk "gitcode.com/JianFeeeee/HomeAgent/internal/plugin/sdk" "gitcode.com/JianFeeeee/HomeAgent/internal/knowledge" luapkg "gitcode.com/JianFeeeee/HomeAgent/internal/lua" "gitcode.com/JianFeeeee/HomeAgent/internal/memory" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/document" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/pipeline" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory/social" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/text" "gitcode.com/JianFeeeee/HomeAgent/internal/onebot" "gitcode.com/JianFeeeee/HomeAgent/internal/plugin" @@ -78,6 +80,12 @@ func main() { memIdx := memory.NewIndexer(memDB) + // === Social Store(人物特质与关系网)=== + socialStore := social.New(memDB) + if memDB != nil { + log.Printf("[homed] social store initialized") + } + // === Memory Pipeline === distiller := pipeline.NewDistiller(memDB, cfg.Daemon.DataDir, pipeline.DistillerConfig{ Interval: 10 * time.Minute, @@ -107,20 +115,15 @@ func main() { return onebot.NewDevice(name, wsURL, accessToken, iom), nil }) - // === Config Registry (统一配置中心,供插件读写) === - cfgReg := internalConfig.NewConfigRegistry(filepath.Join(cfg.Daemon.DataDir, "settings.json")) - cfgReg.Register("core.daemon.listen_addr", cfg.Daemon.ListenAddr) - cfgReg.Register("core.daemon.data_dir", cfg.Daemon.DataDir) - cfgReg.Register("core.daemon.heartbeat_interval", cfg.Daemon.HeartbeatInterval.String()) - cfgReg.Register("core.daemon.check_interval", cfg.Daemon.CheckInterval.String()) - cfgReg.Register("core.daemon.log_level", cfg.Daemon.LogLevel) - cfgReg.Register("core.llm.provider", "lua_deepseek") - cfgReg.Register("core.llm.model", cfg.LLM.Model) - cfgReg.Register("core.llm.base_url", cfg.LLM.BaseURL) - cfgReg.Register("core.llm.temperature", strconv.FormatFloat(cfg.LLM.Temperature, 'f', 2, 64)) - cfgReg.Register("core.llm.max_tokens", strconv.Itoa(cfg.LLM.MaxTokens)) + // === Config Registry (统一配置中心,SQLite 持久化) === + // 所有配置收敛到 SQLite,YAML 仅作首次 seed + cfgReg := internalConfig.NewConfigRegistry(filepath.Join(cfg.Daemon.DataDir, "config.db")) + defer cfgReg.Close() + cfgReg.SeedFrom(cfg) + cfg = cfgReg.ToConfig() // 此后全从 DB 读取 + pluginReg.SetConfigRegistry(cfgReg) - log.Printf("[homed] config registry active with %d keys", len(cfgReg.List(""))) + log.Printf("[homed] config registry seeded from YAML, %d keys in SQLite", len(cfgReg.List(""))) // === Supervisor === sup := supervisor.New(cfg) @@ -207,18 +210,33 @@ func main() { } } - // === API Provider (唯一输出路径) === + // === API Provider Manager(多 LLM 源,通过 Lua 适配器兼容不同 API)=== apiKey := cfg.LLM.APIKey if apiKey == "" { apiKey = os.Getenv("DEEPSEEK_API_KEY") } - provider := agentAPI.NewOpenAIProvider(agentAPI.BaseConfig{ - Model: cfg.LLM.Model, - BaseURL: cfg.LLM.BaseURL, - APIKey: apiKey, - Temperature: cfg.LLM.Temperature, - MaxTokens: cfg.LLM.MaxTokens, - }) + baseAPIKey := apiKey + + providerMgr := agentAPI.NewProviderManager() + for _, src := range cfg.LLM.Sources { + key := src.APIKey + if key == "" { + key = baseAPIKey + } + luaProvider := agentAPI.NewLuaAdaptedProvider(agentAPI.BaseConfig{ + Model: src.Model, + BaseURL: src.BaseURL, + APIKey: key, + Temperature: cfg.LLM.Temperature, + MaxTokens: cfg.LLM.MaxTokens, + }, luaVM, src.Adapter) + providerMgr.Register(src.Name, luaProvider) + } + // 默认源由 config 指定 + if cfg.LLM.Provider != "" { + providerMgr.SetDefault(cfg.LLM.Provider) + } + provider := providerMgr.Default() // === Personality (固定人格内核) === personalPath := filepath.Join(cfg.Daemon.DataDir, "personal", "personal.md") @@ -253,6 +271,17 @@ func main() { stageHost.SyncFromRegistry(pluginReg) log.Printf("[homed] stage host initialized with %d plugin sdks", pluginReg.SDKPluginCount()) + // === Test DeepSeek Plugin === + tdBus := sdk.NewInProcessBus() + tdPlugin := test_deepseek.New(tdBus) + // 插件作用域的配置表(RegisterPluginAPI 自动设置 config_test_deepseek 表) + if err := pluginReg.RegisterPluginAPI(tdPlugin); err != nil { + log.Printf("[homed] warning: register test_deepseek plugin: %v", err) + } else { + stageHost.RegisterPlugin(tdPlugin) + log.Printf("[homed] test_deepseek plugin registered (config table: config_test_deepseek)") + } + // === Single Agent Core === agent := agentCore.New(agentCore.AgentConfig{ ID: "main", @@ -266,16 +295,25 @@ func main() { 1. memory_recall — 查询图记忆(历史/个人信息) 2. memory_commit — 写入图记忆(记住新信息) 3. memory_introspect — 查看记忆统计 -4. knowledge_search — 搜索知识库 -5. doc_query — 查询文档记忆 -6. doc_commit — 写入文档记忆 -7. plgreload — 热重载插件 +4. person_query — 查询人物特质与社交关系网 +5. person_set_trait — 记录人物特质(性格/喜好等) +6. person_relate — 建立人物间社交关系 +7. person_network — 查看社交网络 +8. knowledge_search — 搜索知识库 +9. doc_query — 查询文档记忆 +10. doc_commit — 写入文档记忆 +11. plgreload — 热重载插件 +12. llm_list_sources — 列出所有可用的 LLM 源 +13. llm_set_source — 切换到指定 LLM 源 当用户问及个人信息或历史时,调用 memory_recall。 当用户告诉了你新的个人信息时,调用 memory_commit。 +当用户提到某个人的性格、喜好或人际关系时,使用 person_set_trait 和 person_relate 记录。 需要查询知识时使用 knowledge_search。 +如需切换 LLM 供应商(如从 DeepSeek 切到 OpenAI),使用 llm_list_sources 查看可用源,再用 llm_set_source 切换。 回复你的真实想法,用自然语言与用户交流。`, - Provider: provider, + Provider: provider, + ProviderManager: providerMgr, IO: iom, Memory: memDB, Indexer: memIdx, @@ -284,6 +322,8 @@ func main() { MaxToolTurns: 10, DocStore: docStore, Knowledge: ks, + SocialStore: socialStore, + TextMemory: textMem, Personality: personality, PluginReg: pluginReg, PluginDir: filepath.Join(cfg.Daemon.DataDir, "plugins"), @@ -298,7 +338,8 @@ func main() { sup.SetTracker(trk) sup.RegisterAgent("main") - log.Printf("[homed] main agent started, model=%s base=%s", cfg.LLM.Model, cfg.LLM.BaseURL) + log.Printf("[homed] main agent started, model=%s base=%s sources=%d adapters=%d", + cfg.LLM.Model, cfg.LLM.BaseURL, len(cfg.LLM.Sources), len(luaVM.ListAdapters())) // === Built-in HTTP API & WebUI Plugin === webui := api.NewWebUIPlugin( diff --git a/config/config.go b/config/config.go index bd184da..28ff885 100644 --- a/config/config.go +++ b/config/config.go @@ -22,11 +22,23 @@ func DefaultConfig() types.Config { LogLevel: "info", }, LLM: types.LLMConfig{ - Provider: "openai", + Provider: "deepseek", Model: "deepseek-v4-flash", BaseURL: "https://api.deepseek.com", + APIKey: "", + Adapter: "deepseek", Temperature: 0.7, MaxTokens: 4096, + Sources: []types.LLMSource{ + {Name: "deepseek", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-flash", Adapter: "deepseek", AdapterPath: "adapters/deepseek.lua"}, + {Name: "openai", BaseURL: "https://api.openai.com/v1", Model: "gpt-4o", Adapter: "openai", AdapterPath: "adapters/openai.lua"}, + {Name: "anthropic", BaseURL: "https://api.anthropic.com", Model: "claude-sonnet-4-20250514", Adapter: "anthropic", AdapterPath: "adapters/anthropic.lua"}, + {Name: "gemini", BaseURL: "https://generativelanguage.googleapis.com", Model: "gemini-2.0-flash", Adapter: "gemini", AdapterPath: "adapters/gemini.lua"}, + {Name: "mistral", BaseURL: "https://api.mistral.ai", Model: "mistral-large-latest", Adapter: "mistral", AdapterPath: "adapters/mistral.lua"}, + {Name: "groq", BaseURL: "https://api.groq.com", Model: "llama3-70b-8192", Adapter: "groq", AdapterPath: "adapters/groq.lua"}, + {Name: "github", BaseURL: "https://models.inference.ai.azure.com", Model: "gpt-4o", Adapter: "github", AdapterPath: "adapters/github.lua"}, + {Name: "ollama", BaseURL: "http://localhost:11434", Model: "llama3", Adapter: "ollama", AdapterPath: "adapters/ollama.lua"}, + }, }, Defaults: types.AgentConfig{ Image: "homeagent/agent-base:latest", diff --git a/config/config.yaml b/config/config.yaml index 4f2ede1..810efc8 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -5,6 +5,56 @@ daemon: check_interval: 30s log_level: "info" +llm: + provider: "deepseek" + model: "deepseek-v4-flash" + base_url: "https://api.deepseek.com" + api_key: "" + adapter: "deepseek" + temperature: 0.7 + max_tokens: 4096 + sources: + - name: "deepseek" + base_url: "https://api.deepseek.com" + model: "deepseek-v4-flash" + adapter: "deepseek" + adapter_path: "adapters/deepseek.lua" + - name: "openai" + base_url: "https://api.openai.com/v1" + model: "gpt-4o" + adapter: "openai" + adapter_path: "adapters/openai.lua" + - name: "anthropic" + base_url: "https://api.anthropic.com" + model: "claude-sonnet-4-20250514" + adapter: "anthropic" + adapter_path: "adapters/anthropic.lua" + - name: "gemini" + base_url: "https://generativelanguage.googleapis.com" + model: "gemini-2.0-flash" + adapter: "gemini" + adapter_path: "adapters/gemini.lua" + - name: "mistral" + base_url: "https://api.mistral.ai" + model: "mistral-large-latest" + adapter: "mistral" + adapter_path: "adapters/mistral.lua" + - name: "groq" + base_url: "https://api.groq.com" + model: "llama3-70b-8192" + adapter: "groq" + adapter_path: "adapters/groq.lua" + - name: "github" + base_url: "https://models.inference.ai.azure.com" + model: "gpt-4o" + adapter: "github" + adapter_path: "adapters/github.lua" + - name: "ollama" + base_url: "http://localhost:11434" + model: "llama3" + adapter: "ollama" + adapter_path: "adapters/ollama.lua" + defaults: image: "homeagent/agent-base:latest" llm_endpoints: diff --git a/docs/ADAPTER.md b/docs/ADAPTER.md new file mode 100644 index 0000000..83bf9b7 --- /dev/null +++ b/docs/ADAPTER.md @@ -0,0 +1,104 @@ +# Lua Adapter — LLM 源适配指南 + +每个 LLM API 源对应一个 Lua 脚本,负责请求转换(Go 统一格式 → API 格式)和响应转换(API 格式 → Go 统一格式)。 + +## 适配器契约 + +Lua 脚本必须返回一个包含以下字段和函数的 table: + +```lua +local adapter = {} + +-- 元信息 +adapter.name = "my_provider" -- 唯一标识,与 config 中 adapter 字段一致 +adapter.version = "2.0.0" +adapter.endpoint = "/v1/chat/completions" -- API 路径,拼接到 base_url 后 +adapter.headers = {} -- 额外 HTTP 请求头 + +-- 请求转换:Go → API +function adapter.transform_request(raw_json) + -- raw_json: Go 的 CompletionRequest JSON 字符串 + -- 返回: 应发送给 API 的 JSON 字符串 + return transformed_json +end + +-- 响应转换:API → Go +function adapter.transform_response(raw_json) + -- raw_json: API 返回的原始 JSON 字符串 + -- 返回: 统一 CompletionResponse JSON 字符串 + -- 统一格式: + -- { content: "", finish_reason: "", token_usage: { prompt: N, completion: N, total: N }, tool_calls?: [...] } + return unified_json +end + +-- 流式块转换(可选) +function adapter.transform_stream_chunk(raw_line) + -- raw_line: SSE 中 data: 后的 JSON 字符串 + -- 返回: { content: "", done: bool } 的 JSON,返回 "" 表示跳过该 chunk + return chunk_json +end + +return adapter +``` + +## 统一 CompletionRequest 格式(Go → Adapter) + +```json +{ + "model": "deepseek-v4-flash", + "messages": [ + { "role": "system", "content": "..." }, + { "role": "user", "content": "..." }, + { "role": "assistant", "content": "...", "tool_calls": [...] } + ], + "temperature": 0.7, + "max_tokens": 4096, + "stream": false, + "tools": [...], + "tool_choice": "auto" +} +``` + +## 统一 CompletionResponse 格式(Adapter → Go) + +```json +{ + "content": "回复内容", + "finish_reason": "stop", + "token_usage": { "prompt": 10, "completion": 20, "total": 30 }, + "tool_calls": [ + { "id": "call_xxx", "type": "function", "name": "tool_name", "arguments": { "key": "val" } } + ] +} +``` + +## Lua VM 内置函数 + +`json.encode(table)` — 将 Lua table 编码为 JSON 字符串 + +`json.decode(string)` — 将 JSON 字符串解码为 Lua table + +`log(level, message)` — 输出日志(level: info/warn/error) + +## 适配典型 API + +| API | endpoint | auth 方式 | 格式差异 | +|---|---|---|---| +| **OpenAI** | `/chat/completions` | `Authorization: Bearer ` | 标准 OpenAI 格式 | +| **DeepSeek** | `/chat/completions` | `Authorization: Bearer ` | OpenAI 兼容,强制 temperature=0 | +| **Anthropic** | `/v1/messages` | `x-api-key: ` | Messages API,system 消息分离,content 为 block 数组 | +| **Gemini** | `/v1/models/{model}:generateContent` | `?key=` 或 Bearer | contents/parts 格式,role 用 model 而非 assistant | +| **Mistral** | `/v1/chat/completions` | `Authorization: Bearer ` | OpenAI 兼容 | +| **Groq** | `/openai/v1/chat/completions` | `Authorization: Bearer ` | OpenAI 兼容 | +| **GitHub Models** | `/chat/completions` | `Authorization: Bearer ` | OpenAI 兼容 | +| **Ollama** | `/api/chat` | 无 | 不同的 options 格式 | + +## 添加新源步骤 + +1. 在 `internal/lua/adapters/` 下创建 `.lua` +2. 脚本定义 `transform_request` 和 `transform_response` +3. (可选)定义 `transform_stream_chunk` 支持流式 +4. 在 `config/config.go` 的 `Sources` 中添加条目 +5. 在 `config/config.yaml` 中添加对应源 +6. 编译验证:`go build ./cmd/homed/` +7. 测试验证:`go test ./...` diff --git a/internal/agent/core/agent.go b/internal/agent/core/agent.go index 0360c59..6e60a43 100644 --- a/internal/agent/core/agent.go +++ b/internal/agent/core/agent.go @@ -15,6 +15,8 @@ import ( "gitcode.com/JianFeeeee/HomeAgent/internal/knowledge" "gitcode.com/JianFeeeee/HomeAgent/internal/memory" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/document" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory/social" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory/text" "gitcode.com/JianFeeeee/HomeAgent/internal/plugin" sdk "gitcode.com/JianFeeeee/HomeAgent/internal/plugin/sdk" "gitcode.com/JianFeeeee/HomeAgent/internal/skill" @@ -29,6 +31,7 @@ type Agent struct { mu sync.Mutex id types.AgentID provider agentAPI.Provider + providerManager *agentAPI.ProviderManager io *agentIO.IOManager memory *memory.GraphDB indexer *memory.Indexer @@ -46,6 +49,12 @@ type Agent struct { // 知识库 knowledge *knowledge.Store + // 人物特质与关系网 + social *social.SocialStore + + // 文本记忆(原始对话日志) + textMem *text.Memory + // 人格设定 personality *agentPkg.Personality @@ -68,12 +77,18 @@ type Agent struct { // 自循环输入通道:核心内部任务(记忆消歧、系统维护),不经过 IO 层 selfInputCh chan string + + // 子任务异步执行 + childMu sync.Mutex + childNextID int64 + childResults map[string]string } type AgentConfig struct { ID types.AgentID SystemPrompt string Provider agentAPI.Provider + ProviderManager *agentAPI.ProviderManager IO *agentIO.IOManager Memory *memory.GraphDB Indexer *memory.Indexer @@ -83,6 +98,8 @@ type AgentConfig struct { DocStore *document.Store Knowledge *knowledge.Store + SocialStore *social.SocialStore + TextMemory *text.Memory Personality *agentPkg.Personality PluginReg *plugin.Registry PluginDir string @@ -107,6 +124,7 @@ func New(cfg AgentConfig) *Agent { return &Agent{ id: cfg.ID, provider: cfg.Provider, + providerManager: cfg.ProviderManager, io: cfg.IO, memory: cfg.Memory, indexer: cfg.Indexer, @@ -119,6 +137,8 @@ func New(cfg AgentConfig) *Agent { maxTurns: cfg.MaxToolTurns, docStore: cfg.DocStore, knowledge: cfg.Knowledge, + social: cfg.SocialStore, + textMem: cfg.TextMemory, personality: cfg.Personality, pluginReg: cfg.PluginReg, pluginDir: cfg.PluginDir, @@ -127,6 +147,7 @@ func New(cfg AgentConfig) *Agent { stageHost: cfg.StageHost, eventBus: cfg.EventBus, selfInputCh: make(chan string, 64), + childResults: make(map[string]string), } } @@ -468,6 +489,8 @@ func (a *Agent) executeToolCall(tc agentAPI.ToolCall) string { switch { case strings.HasPrefix(tc.Name, "memory_"): return a.executeMemoryTool(tc) + case strings.HasPrefix(tc.Name, "social_"): + return a.executeSocialTool(tc) case strings.HasPrefix(tc.Name, "knowledge_"): return a.executeKnowledgeTool(tc) case strings.HasPrefix(tc.Name, "doc_"): @@ -482,6 +505,10 @@ func (a *Agent) executeToolCall(tc agentAPI.ToolCall) string { return a.executePluginReload() case tc.Name == "spawn_child": return a.executeSpawnChild(tc) + case tc.Name == "child_result": + return a.executeChildResultTool(tc) + case strings.HasPrefix(tc.Name, "llm_"): + return a.executeLLMTool(tc) } // 插件工具(通过 SDK RegisterTool 注册) @@ -531,6 +558,14 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string { if len(result.Entities) == 0 && len(result.Relations) == 0 { return "未找到相关记忆" } + // 标记已显式召回的实体,后续自动注入时跳过,避免重复 + if a.indexer != nil { + names := make([]string, len(result.Entities)) + for i, e := range result.Entities { + names[i] = e.Name + } + a.indexer.MarkRecalled(names...) + } var parts []string parts = append(parts, fmt.Sprintf("找到 %d 个相关实体:", len(result.Entities))) for _, e := range result.Entities { @@ -595,11 +630,203 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string { } return fmt.Sprintf("已将「%s」合并到「%s」,%d 条关系已重定向", source, target, count) + case "memory_purge": + criteria := make(map[string]string) + if v, ok := tc.Arguments["subject_contains"].(string); ok && v != "" { + criteria["subject_contains"] = v + } + if v, ok := tc.Arguments["relation_type"].(string); ok && v != "" { + criteria["relation_type"] = v + } + if v, ok := tc.Arguments["target_contains"].(string); ok && v != "" { + criteria["target_contains"] = v + } + mode, _ := tc.Arguments["mode"].(string) + if mode == "" { + mode = "soft" + } + n, err := a.memory.Purge(criteria, mode) + if err != nil { + return fmt.Sprintf("删除图记忆失败: %v", err) + } + + // 也清理文本记忆中匹配源的数据 + textRemoved := 0 + if a.textMem != nil { + if subj, ok := criteria["subject_contains"]; ok && subj != "" { + textRemoved, _ = a.textMem.PurgeByFilter(func(evt text.Event) bool { + return strings.Contains(evt.Source, subj) || strings.Contains(evt.Input, subj) || strings.Contains(evt.Response, subj) + }) + } + } + parts := []string{fmt.Sprintf("已%s删除 %d 条图记忆关系", mode, n)} + if textRemoved > 0 { + parts = append(parts, fmt.Sprintf("清理 %d 条文本记忆日志", textRemoved)) + } + return strings.Join(parts, ",") + + case "memory_edit": + oldSubject, _ := tc.Arguments["old_subject"].(string) + oldRelation, _ := tc.Arguments["old_relation"].(string) + oldObject, _ := tc.Arguments["old_object"].(string) + if oldSubject == "" || oldRelation == "" || oldObject == "" { + return "old_subject、old_relation、old_object 不能为空" + } + newSubject, _ := tc.Arguments["new_subject"].(string) + newRelation, _ := tc.Arguments["new_relation"].(string) + newObject, _ := tc.Arguments["new_object"].(string) + if newSubject == "" && newRelation == "" && newObject == "" { + return "至少提供一个新值(new_subject / new_relation / new_object)" + } + if newSubject == "" { + newSubject = oldSubject + } + if newRelation == "" { + newRelation = oldRelation + } + if newObject == "" { + newObject = oldObject + } + // 先删旧的,再写新的(图记忆) + n, err := a.memory.Purge(map[string]string{ + "subject_contains": oldSubject, + "relation_type": oldRelation, + "target_contains": oldObject, + }, "hard") + if err != nil { + return fmt.Sprintf("编辑图记忆失败(删除旧记录): %v", err) + } + triples := []memory.Triple{{ + Subject: newSubject, + Relation: newRelation, + Object: newObject, + }} + ec, rc, err := a.memory.Commit(triples, string(a.id), 0) + if err != nil { + return fmt.Sprintf("编辑图记忆失败(写入新记录): %v", err) + } + + // 也编辑文本记忆中匹配的内容 + textReplaced := 0 + if a.textMem != nil && oldSubject != "" { + textReplaced, _ = a.textMem.ReplaceByFilter( + func(evt text.Event) bool { + return strings.Contains(evt.Input, oldSubject) || strings.Contains(evt.Response, oldSubject) + }, + func(evt text.Event) text.Event { + evt.Input = strings.ReplaceAll(evt.Input, oldSubject, newSubject) + evt.Response = strings.ReplaceAll(evt.Response, oldSubject, newSubject) + return evt + }, + ) + } + result := fmt.Sprintf("已编辑记忆:删除 %d 条旧关系,写入 %d 个实体 + %d 条新关系", n, ec, rc) + if textReplaced > 0 { + result += fmt.Sprintf(",更新 %d 条文本记忆日志", textReplaced) + } + return result + default: return fmt.Sprintf("未知的记忆工具: %s", tc.Name) } } +func (a *Agent) executeSocialTool(tc agentAPI.ToolCall) string { + if a.social == nil { + return "人物关系网不可用(social store 未初始化)" + } + switch tc.Name { + case "person_query": + name, _ := tc.Arguments["name"].(string) + if name == "" { + return "请输入人物名称" + } + profile, err := a.social.GetPerson(name) + if err != nil { + return fmt.Sprintf("查询人物失败: %v", err) + } + var parts []string + parts = append(parts, fmt.Sprintf("▎%s 的档案", name)) + if len(profile.Traits) > 0 { + parts = append(parts, "【特质】") + for k, v := range profile.Traits { + parts = append(parts, fmt.Sprintf(" %s: %s", k, v)) + } + } + if len(profile.Relations) > 0 { + parts = append(parts, "【社交关系】") + for _, r := range profile.Relations { + parts = append(parts, fmt.Sprintf(" %s —(%s)—→ %s", name, r.Relation, r.Person)) + } + } + if len(profile.Traits) == 0 && len(profile.Relations) == 0 { + parts = append(parts, " (尚无记录)") + } + return strings.Join(parts, "\n") + + case "person_set_trait": + name, _ := tc.Arguments["name"].(string) + trait, _ := tc.Arguments["trait"].(string) + value, _ := tc.Arguments["value"].(string) + if name == "" || trait == "" || value == "" { + return "name、trait、value 都不能为空" + } + if err := a.social.SetTrait(name, trait, value); err != nil { + return fmt.Sprintf("设置特质失败: %v", err) + } + return fmt.Sprintf("已记录:%s 的 %s = %s", name, trait, value) + + case "person_relate": + personA, _ := tc.Arguments["person_a"].(string) + relation, _ := tc.Arguments["relation"].(string) + personB, _ := tc.Arguments["person_b"].(string) + if personA == "" || relation == "" || personB == "" { + return "person_a、relation、person_b 都不能为空" + } + if err := a.social.AddRelation(personA, relation, personB); err != nil { + return fmt.Sprintf("建立关系失败: %v", err) + } + return fmt.Sprintf("已记录:%s —(%s)—→ %s", personA, relation, personB) + + case "person_network": + name, _ := tc.Arguments["name"].(string) + depth := int(getFloat(tc.Arguments, "depth")) + if depth <= 0 { + depth = 2 + } + if name == "" { + return "请输入人物名称" + } + profiles, err := a.social.GetNetwork(name, depth) + if err != nil { + return fmt.Sprintf("查询社交网络失败: %v", err) + } + if len(profiles) == 0 { + return fmt.Sprintf("未找到 %s 的社交网络", name) + } + var parts []string + parts = append(parts, fmt.Sprintf("▎%s 的社交网络(%d 度)", name, depth)) + for _, p := range profiles { + if p.Name == name { + continue + } + parts = append(parts, fmt.Sprintf(" · %s", p.Name)) + for k, v := range p.Traits { + parts = append(parts, fmt.Sprintf(" %s: %s", k, v)) + } + for _, r := range p.Relations { + if r.Person != name { + parts = append(parts, fmt.Sprintf(" —(%s)—→ %s", r.Relation, r.Person)) + } + } + } + return strings.Join(parts, "\n") + + default: + return fmt.Sprintf("未知的人物工具: %s", tc.Name) + } +} + func (a *Agent) executeKnowledgeTool(tc agentAPI.ToolCall) string { if a.knowledge == nil { return "知识库不可用" @@ -668,6 +895,8 @@ func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string { if len(docs) == 0 { return "未找到相关文档记忆" } + // 返回内容后从冷层移除,避免后续自动注入重复 + a.docStore.Consume(query, topK) var parts []string for i, d := range docs { parts = append(parts, fmt.Sprintf("[%d] %s (来源: %s)", i+1, d.Summary, d.Source)) @@ -816,6 +1045,41 @@ func (a *Agent) buildToolDefs() []interface{} { }, }, }) + tools = append(tools, map[string]interface{}{ + "type": "function", + "function": map[string]interface{}{ + "name": "memory_purge", + "description": "删除指定条件的记忆关系。支持按主体、客体、关系类型筛选。谨慎使用。", + "parameters": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "subject_contains": map[string]interface{}{"type": "string", "description": "主体名包含的关键词"}, + "relation_type": map[string]interface{}{"type": "string", "description": "关系类型"}, + "target_contains": map[string]interface{}{"type": "string", "description": "客体名包含的关键词"}, + "mode": map[string]interface{}{"type": "string", "description": "soft(标记删除)/ hard(物理删除)", "default": "soft"}, + }, + }, + }, + }) + tools = append(tools, map[string]interface{}{ + "type": "function", + "function": map[string]interface{}{ + "name": "memory_edit", + "description": "编辑记忆:删除旧的 relation 并写入新的。例如修正错误的实体名或关系类型。", + "parameters": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "old_subject": map[string]interface{}{"type": "string", "description": "旧主体名"}, + "old_relation": map[string]interface{}{"type": "string", "description": "旧关系类型"}, + "old_object": map[string]interface{}{"type": "string", "description": "旧客体名"}, + "new_subject": map[string]interface{}{"type": "string", "description": "新主体名(不填则不变)"}, + "new_relation": map[string]interface{}{"type": "string", "description": "新关系类型(不填则不变)"}, + "new_object": map[string]interface{}{"type": "string", "description": "新客体名(不填则不变)"}, + }, + "required": []string{"old_subject", "old_relation", "old_object"}, + }, + }, + }) } // 知识库工具 @@ -906,6 +1170,71 @@ func (a *Agent) buildToolDefs() []interface{} { }) } + // 人物特质与关系网工具 + if a.social != nil { + tools = append(tools, map[string]interface{}{ + "type": "function", + "function": map[string]interface{}{ + "name": "person_query", + "description": "查询指定人物的完整档案(特质+社交关系)。用于了解一个人的性格、喜好、背景和社交圈。", + "parameters": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "name": map[string]interface{}{"type": "string", "description": "人物名称"}, + }, + "required": []string{"name"}, + }, + }, + }) + tools = append(tools, map[string]interface{}{ + "type": "function", + "function": map[string]interface{}{ + "name": "person_set_trait", + "description": "记录/更新一个人的特质(性格、喜好、习惯等)。例如:person_set_trait(name=\"张三\", trait=\"喜欢\", value=\"红色\")。如果该特质已存在则覆盖。", + "parameters": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "name": map[string]interface{}{"type": "string", "description": "人物名称"}, + "trait": map[string]interface{}{"type": "string", "description": "特质名称,如:喜欢、性格、职业、年龄"}, + "value": map[string]interface{}{"type": "string", "description": "特质值,如:红色、开朗、工程师、25岁"}, + }, + "required": []string{"name", "trait", "value"}, + }, + }, + }) + tools = append(tools, map[string]interface{}{ + "type": "function", + "function": map[string]interface{}{ + "name": "person_relate", + "description": "记录两个人之间的社交关系。例如:person_relate(person_a=\"张三\", relation=\"朋友\", person_b=\"李四\")。关系是双向的。", + "parameters": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "person_a": map[string]interface{}{"type": "string", "description": "人物A"}, + "relation": map[string]interface{}{"type": "string", "description": "关系类型,如:朋友、家人、同事、邻居、同学"}, + "person_b": map[string]interface{}{"type": "string", "description": "人物B"}, + }, + "required": []string{"person_a", "relation", "person_b"}, + }, + }, + }) + tools = append(tools, map[string]interface{}{ + "type": "function", + "function": map[string]interface{}{ + "name": "person_network", + "description": "查询某人的社交网络(多度关系)。显示该人物周围的相关人物及其关系和特质。", + "parameters": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "name": map[string]interface{}{"type": "string", "description": "人物名称"}, + "depth": map[string]interface{}{"type": "integer", "description": "关系深度(默认2)", "default": 2}, + }, + "required": []string{"name"}, + }, + }, + }) + } + // 插件重载工具 if a.pluginReg != nil && a.pluginDir != "" { tools = append(tools, map[string]interface{}{ @@ -926,7 +1255,7 @@ func (a *Agent) buildToolDefs() []interface{} { "type": "function", "function": map[string]interface{}{ "name": "spawn_child", - "description": "创建一个子 Agent 执行独立任务。子 Agent 使用传统上下文(无持久记忆),任务完成即销毁。适用于需要多步推理但不需要写入长期记忆的场景,例如:计算、分析、生成报告草稿等。", + "description": "启动一个异步子 Agent 执行独立任务。子 Agent 后台运行,不阻塞当前对话。完成后系统会自动通知你,届时请调用 child_result 工具查看输出。", "parameters": map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ @@ -939,6 +1268,55 @@ func (a *Agent) buildToolDefs() []interface{} { }, }, }) + tools = append(tools, map[string]interface{}{ + "type": "function", + "function": map[string]interface{}{ + "name": "child_result", + "description": "查询异步子 Agent 的执行结果。当收到'子任务已完成'的通知后,调用此工具获取输出。", + "parameters": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "task_id": map[string]interface{}{ + "type": "string", + "description": "spawn_child 返回的任务 ID,如 child_1", + }, + }, + "required": []string{"task_id"}, + }, + }, + }) + + // LLM 源管理工具 + if a.providerManager != nil { + tools = append(tools, map[string]interface{}{ + "type": "function", + "function": map[string]interface{}{ + "name": "llm_list_sources", + "description": "列出所有可用的 LLM 源(如 deepseek、openai、ollama),每个源有对应的 Lua 适配器和配置。如需切换 LLM 源,请使用 llm_set_source。", + "parameters": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{}, + }, + }, + }) + tools = append(tools, map[string]interface{}{ + "type": "function", + "function": map[string]interface{}{ + "name": "llm_set_source", + "description": "切换当前 LLM 源到指定名称。变更立即生效,后续对话将使用新的 LLM 源。源名称可通过 llm_list_sources 查看。", + "parameters": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "name": map[string]interface{}{ + "type": "string", + "description": "LLM 源名称(如 deepseek、openai、ollama)", + }, + }, + "required": []string{"name"}, + }, + }, + }) + } // 输出通道工具 tools = append(tools, map[string]interface{}{ @@ -1370,14 +1748,30 @@ func (a *Agent) executePluginReload() string { return msg } -// executeSpawnChild 创建子 Agent 执行独立任务 -// 子 Agent 使用传统上下文(单轮对话),无持久记忆,任务完即销毁 +// executeSpawnChild 创建子 Agent 异步执行独立任务 +// 不阻塞主 Agent,子任务完成后通过 selfInputCh 通知主 Agent 查看结果 func (a *Agent) executeSpawnChild(tc agentAPI.ToolCall) string { task, _ := tc.Arguments["task"].(string) if task == "" { return "请提供 task 参数" } + // 生成唯一任务 ID + a.childMu.Lock() + a.childNextID++ + taskID := fmt.Sprintf("child_%d", a.childNextID) + a.childMu.Unlock() + + // 异步启动子 Agent + go a.runChildTask(taskID, task) + + return fmt.Sprintf("子任务已启动(ID: %s),完成后会自动通知你,届时请使用 child_result 工具查看输出", taskID) +} + +// runChildTask 后台运行子 Agent 任务,完成后将结果存储并通过 selfInputCh 通知主 Agent +func (a *Agent) runChildTask(taskID, task string) { + log.Printf("[child] %s started: %s", taskID, truncateStr(task, 80)) + sysPrompt := fmt.Sprintf(`你是 HomeAgent 的子任务助手。 请完成以下任务。完成即可,无需保留记忆或查询历史。 任务: %s`, task) @@ -1387,25 +1781,26 @@ func (a *Agent) executeSpawnChild(tc agentAPI.ToolCall) string { {Role: "user", Content: task}, } - // 子 Agent 无特殊工具,只保留基础 tool 定义(无记忆/知识/文档工具) - childTools := []interface{}{ - map[string]interface{}{ - "type": "function", - "function": map[string]interface{}{ - "name": "output_send", - "description": "通过指定输出通道发送消息", - "parameters": map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "channel": map[string]interface{}{"type": "string", "description": "输出通道"}, - "content": map[string]interface{}{"type": "string", "description": "消息内容"}, - }, - "required": []string{"channel", "content"}, - }, - }, - }, + // 子 Agent 可调用核心以外的全部工具(记忆/知识/文档/社交),但不能调用输出工具 + allTools := a.buildToolDefs() + childTools := make([]interface{}, 0, len(allTools)) + outputTools := map[string]bool{"output_send": true, "output_set_channel": true, "output_list_channels": true, "spawn_child": true, "plgreload": true} + for _, t := range allTools { + toolMap, ok := t.(map[string]interface{}) + if !ok { + continue + } + fn, ok := toolMap["function"].(map[string]interface{}) + if !ok { + continue + } + name, _ := fn["name"].(string) + if !outputTools[name] { + childTools = append(childTools, t) + } } + var finalResult string for turn := 0; turn < 5; turn++ { req := &agentAPI.CompletionRequest{ Messages: msgs, @@ -1419,33 +1814,112 @@ func (a *Agent) executeSpawnChild(tc agentAPI.ToolCall) string { resp, err := a.provider.Chat(a.ctx, req) if err != nil { - return fmt.Sprintf("子 Agent 执行失败: %v", err) + finalResult = fmt.Sprintf("子 Agent 执行失败: %v", err) + break } if len(resp.ToolCalls) == 0 { - return resp.Content + finalResult = resp.Content + break } for _, ct := range resp.ToolCalls { var result string - if ct.Name == "output_send" { - channel, _ := ct.Arguments["channel"].(string) - content, _ := ct.Arguments["content"].(string) - if channel != "" && content != "" { - a.io.EmitTextTo("child_agent", channel, content) - result = fmt.Sprintf("已通过 [%s] 通道发送", channel) - } else { - result = "channel 和 content 不能为空" - } - } else { - result = fmt.Sprintf("子 Agent 无法调用工具 %s", ct.Name) + switch { + case ct.Name == "output_send" || ct.Name == "output_set_channel" || ct.Name == "output_list_channels": + result = fmt.Sprintf("子 Agent 不允许调用输出工具: %s", ct.Name) + case ct.Name == "spawn_child" || ct.Name == "plgreload": + result = fmt.Sprintf("子 Agent 不允许调用系统工具: %s", ct.Name) + default: + result = a.executeToolCall(ct) } msgs = append(msgs, agentAPI.Message{Role: "assistant", Content: resp.Content, ToolCalls: []agentAPI.ToolCall{ct}}) msgs = append(msgs, agentAPI.Message{Role: "tool", ToolCallID: ct.ID, Content: result}) } } - return "子 Agent 执行超时(超过 5 轮)" + if finalResult == "" { + finalResult = "子 Agent 执行超时(超过 5 轮)" + } + + // 存储结果 + a.childMu.Lock() + a.childResults[taskID] = finalResult + a.childMu.Unlock() + + log.Printf("[child] %s done: %s", taskID, truncateStr(finalResult, 100)) + + // 通过自循环通道通知主 Agent + notification := fmt.Sprintf("子任务 %s 已完成,请调用 child_result 工具查看输出", taskID) + select { + case a.selfInputCh <- notification: + default: + log.Printf("[child] self input channel full, dropping notification for %s", taskID) + } +} + +// executeChildResultTool 查询子 Agent 执行结果 +func (a *Agent) executeChildResultTool(tc agentAPI.ToolCall) string { + taskID, _ := tc.Arguments["task_id"].(string) + if taskID == "" { + return "请提供 task_id 参数" + } + + a.childMu.Lock() + result, ok := a.childResults[taskID] + if !ok { + a.childMu.Unlock() + + // 可能还在执行中 + a.childMu.Lock() + _, exists := a.childResults[taskID] + a.childMu.Unlock() + if !exists { + return fmt.Sprintf("子任务 %s 不存在或已过期", taskID) + } + } + delete(a.childResults, taskID) + a.childMu.Unlock() + + return fmt.Sprintf("【子任务 %s 结果】\n%s", taskID, result) +} + +func (a *Agent) executeLLMTool(tc agentAPI.ToolCall) string { + if a.providerManager == nil { + return "LLM 源管理器不可用" + } + switch tc.Name { + case "llm_list_sources": + sources := a.providerManager.List() + if len(sources) == 0 { + return "没有可用的 LLM 源" + } + parts := []string{"可用 LLM 源:"} + for _, name := range sources { + mark := " " + if p := a.providerManager.Get(""); p != nil && p.Name() == name { + mark = "→" + } + parts = append(parts, fmt.Sprintf(" %s %s", mark, name)) + } + return strings.Join(parts, "\n") + + case "llm_set_source": + name, _ := tc.Arguments["name"].(string) + if name == "" { + return "请提供源名称" + } + if err := a.providerManager.SetDefault(name); err != nil { + return fmt.Sprintf("切换失败: %v", err) + } + a.mu.Lock() + a.provider = a.providerManager.Get(name) + a.mu.Unlock() + return fmt.Sprintf("已切换到 LLM 源: %s", name) + + default: + return fmt.Sprintf("未知的 LLM 工具: %s", tc.Name) + } } // runStage — 运行阶段管道,若插件 Response 被设置则返回 true(短路) diff --git a/internal/config/registry.go b/internal/config/registry.go index 8ee314b..051ff70 100644 --- a/internal/config/registry.go +++ b/internal/config/registry.go @@ -1,133 +1,433 @@ package config import ( - "encoding/json" + "database/sql" "fmt" - "os" - "path/filepath" "sort" + "strconv" "strings" "sync" + "time" + + "gitcode.com/JianFeeeee/HomeAgent/pkg/types" + _ "github.com/mattn/go-sqlite3" ) type ConfigRegistry struct { - mu sync.RWMutex - values map[string]interface{} - persistPath string - dirty bool + mu sync.RWMutex + db *sql.DB + dbPath string } -func NewConfigRegistry(persistPath string) *ConfigRegistry { - r := &ConfigRegistry{ - values: make(map[string]interface{}), - persistPath: persistPath, +func NewConfigRegistry(dbPath string) *ConfigRegistry { + if dbPath == "" { + dbPath = ":memory:" } - r.load() + db, err := sql.Open("sqlite3", dbPath) + if err != nil { + panic(fmt.Sprintf("open config db: %v", err)) + } + // WAL 模式提升并发 + db.Exec("PRAGMA journal_mode=WAL") + r := &ConfigRegistry{db: db, dbPath: dbPath} + r.initCoreTable() return r } +func (r *ConfigRegistry) initCoreTable() { + r.db.Exec(`CREATE TABLE IF NOT EXISTS config ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + )`) +} + +func (r *ConfigRegistry) ensurePluginTable(name string) { + table := r.pluginTableName(name) + r.db.Exec(fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + )`, table)) +} + +func (r *ConfigRegistry) pluginTableName(name string) string { + safe := strings.Map(func(c rune) rune { + if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' { + return c + } + return '_' + }, strings.ToLower(name)) + return "config_" + safe +} + func (r *ConfigRegistry) Register(key string, value interface{}) { r.mu.Lock() defer r.mu.Unlock() - if _, exists := r.values[key]; !exists { - r.values[key] = value - } + r.db.Exec(`INSERT OR IGNORE INTO config (key, value) VALUES (?, ?)`, key, fmt.Sprint(value)) } func (r *ConfigRegistry) RegisterDefault(key string, value interface{}) { - r.mu.Lock() - defer r.mu.Unlock() - if _, exists := r.values[key]; !exists { - r.values[key] = value - } + r.Register(key, value) } func (r *ConfigRegistry) Get(key string) (interface{}, error) { r.mu.RLock() defer r.mu.RUnlock() - v, ok := r.values[key] - if !ok { + var val string + err := r.db.QueryRow(`SELECT value FROM config WHERE key = ?`, key).Scan(&val) + if err == sql.ErrNoRows { return nil, fmt.Errorf("config key %q not found", key) } - return v, nil + if err != nil { + return nil, err + } + return val, nil } func (r *ConfigRegistry) Set(key string, value interface{}) error { r.mu.Lock() defer r.mu.Unlock() - r.values[key] = value - r.dirty = true - return nil + _, err := r.db.Exec(`INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)`, key, fmt.Sprint(value)) + return err } func (r *ConfigRegistry) List(prefix string) []string { r.mu.RLock() defer r.mu.RUnlock() var keys []string - for k := range r.values { - if prefix == "" || strings.HasPrefix(k, prefix) { + q := `SELECT key FROM config WHERE key LIKE ? ORDER BY key` + like := prefix + "%" + rows, err := r.db.Query(q, like) + if err != nil { + return nil + } + defer rows.Close() + for rows.Next() { + var k string + if err := rows.Scan(&k); err == nil { keys = append(keys, k) } } - sort.Strings(keys) return keys } func (r *ConfigRegistry) Delete(key string) error { r.mu.Lock() defer r.mu.Unlock() - delete(r.values, key) - r.dirty = true - return nil + _, err := r.db.Exec(`DELETE FROM config WHERE key = ?`, key) + return err } func (r *ConfigRegistry) Dump() map[string]interface{} { r.mu.RLock() defer r.mu.RUnlock() - cp := make(map[string]interface{}) - for k, v := range r.values { - cp[k] = v + result := make(map[string]interface{}) + rows, err := r.db.Query(`SELECT key, value FROM config ORDER BY key`) + if err != nil { + return result } - return cp + defer rows.Close() + for rows.Next() { + var k, v string + if err := rows.Scan(&k, &v); err == nil { + result[k] = v + } + } + return result } func (r *ConfigRegistry) Flush() error { - r.mu.RLock() - if !r.dirty { - r.mu.RUnlock() + if r.dbPath == "" || r.dbPath == ":memory:" { return nil } + // SQLite 自动持久化;显式 checkpoint 确保一致性 + r.mu.RLock() + _, err := r.db.Exec("PRAGMA wal_checkpoint(TRUNCATE)") r.mu.RUnlock() + return err +} +func (r *ConfigRegistry) Close() error { + return r.db.Close() +} + +// SeedFrom 从 *types.Config 批量导入默认值到 config 表(仅空表时写入) +func (r *ConfigRegistry) SeedFrom(cfg *types.Config) { r.mu.Lock() defer r.mu.Unlock() - if r.persistPath == "" { - return nil + // 检查是否已有数据 + var count int + r.db.QueryRow(`SELECT COUNT(*) FROM config`).Scan(&count) + if count > 0 { + return } - os.MkdirAll(filepath.Dir(r.persistPath), 0755) - data, err := json.MarshalIndent(r.values, "", " ") + + tx, err := r.db.Begin() if err != nil { - return fmt.Errorf("marshal config: %w", err) + return } - if err := os.WriteFile(r.persistPath, data, 0644); err != nil { - return fmt.Errorf("write config: %w", err) + defer tx.Rollback() + + stmt, err := tx.Prepare(`INSERT OR IGNORE INTO config (key, value) VALUES (?, ?)`) + if err != nil { + return } - r.dirty = false - return nil + defer stmt.Close() + + set := func(k, v string) { stmt.Exec(k, v) } + + // daemon + set("core.daemon.listen_addr", cfg.Daemon.ListenAddr) + set("core.daemon.data_dir", cfg.Daemon.DataDir) + set("core.daemon.heartbeat_interval", cfg.Daemon.HeartbeatInterval.String()) + set("core.daemon.check_interval", cfg.Daemon.CheckInterval.String()) + set("core.daemon.log_level", cfg.Daemon.LogLevel) + + // llm + set("core.llm.provider", cfg.LLM.Provider) + set("core.llm.model", cfg.LLM.Model) + set("core.llm.base_url", cfg.LLM.BaseURL) + set("core.llm.adapter", cfg.LLM.Adapter) + set("core.llm.temperature", strconv.FormatFloat(cfg.LLM.Temperature, 'f', 2, 64)) + set("core.llm.max_tokens", strconv.Itoa(cfg.LLM.MaxTokens)) + + // llm sources + for _, src := range cfg.LLM.Sources { + p := "core.llm.sources." + src.Name + set(p+".base_url", src.BaseURL) + set(p+".model", src.Model) + set(p+".adapter", src.Adapter) + set(p+".adapter_path", src.AdapterPath) + } + + // defaults + set("core.defaults.image", cfg.Defaults.Image) + set("core.defaults.openclaw_enabled", strconv.FormatBool(cfg.Defaults.OpenClawEnabled)) + set("core.defaults.snapshot.interval", cfg.Defaults.SnapshotPolicy.Interval.String()) + set("core.defaults.snapshot.max_snapshots", strconv.Itoa(cfg.Defaults.SnapshotPolicy.MaxSnapshots)) + set("core.defaults.snapshot.pre_action", strconv.FormatBool(cfg.Defaults.SnapshotPolicy.PreAction)) + set("core.defaults.snapshot.post_action", strconv.FormatBool(cfg.Defaults.SnapshotPolicy.PostAction)) + set("core.defaults.rollback.max_retries", strconv.Itoa(cfg.Defaults.RollbackPolicy.MaxRetries)) + set("core.defaults.rollback.health_threshold", strconv.Itoa(int(cfg.Defaults.RollbackPolicy.HealthThreshold))) + set("core.defaults.rollback.cooldown_period", cfg.Defaults.RollbackPolicy.CooldownPeriod.String()) + set("core.defaults.rollback.auto_rollback", strconv.FormatBool(cfg.Defaults.RollbackPolicy.AutoRollback)) + set("core.defaults.resource.cpu", cfg.Defaults.ResourceLimit.CPU) + set("core.defaults.resource.memory", cfg.Defaults.ResourceLimit.Memory) + set("core.defaults.resource.disk", cfg.Defaults.ResourceLimit.Disk) + set("core.defaults.resource.network", strconv.FormatBool(cfg.Defaults.ResourceLimit.Network)) + set("core.agent.max_tool_turns", "10") + set("core.agent.max_context_size", "30") + set("core.agent.distill_interval", "30m") + + tx.Commit() } -func (r *ConfigRegistry) load() { - if r.persistPath == "" { - return - } - data, err := os.ReadFile(r.persistPath) +// helpers — 所有值存为 TEXT,解析时自动转换 + +func (r *ConfigRegistry) GetString(key, defaultVal string) string { + r.mu.RLock() + defer r.mu.RUnlock() + var v string + err := r.db.QueryRow(`SELECT value FROM config WHERE key = ?`, key).Scan(&v) if err != nil { - return + return defaultVal } - var vals map[string]interface{} - if err := json.Unmarshal(data, &vals); err != nil { - return - } - r.values = vals + return v +} + +func (r *ConfigRegistry) GetInt(key string, defaultVal int) int { + r.mu.RLock() + defer r.mu.RUnlock() + var v string + err := r.db.QueryRow(`SELECT value FROM config WHERE key = ?`, key).Scan(&v) + if err != nil { + return defaultVal + } + n, err := strconv.Atoi(v) + if err != nil { + return defaultVal + } + return n +} + +func (r *ConfigRegistry) GetDuration(key string, defaultVal time.Duration) time.Duration { + r.mu.RLock() + defer r.mu.RUnlock() + var v string + err := r.db.QueryRow(`SELECT value FROM config WHERE key = ?`, key).Scan(&v) + if err != nil { + return defaultVal + } + d, err := time.ParseDuration(v) + if err != nil { + return defaultVal + } + return d +} + +func (r *ConfigRegistry) GetBool(key string, defaultVal bool) bool { + r.mu.RLock() + defer r.mu.RUnlock() + var v string + err := r.db.QueryRow(`SELECT value FROM config WHERE key = ?`, key).Scan(&v) + if err != nil { + return defaultVal + } + b, err := strconv.ParseBool(v) + if err != nil { + return defaultVal + } + return b +} + +// ToConfig 从 config 表重建 *types.Config(数据库为真实源,YAML 仅作初始 seed) +func (r *ConfigRegistry) ToConfig() *types.Config { + cfg := &types.Config{} + dump := r.Dump() + + read := func(key, def string) string { + if v, ok := dump[key]; ok { + if s, ok := v.(string); ok && s != "" { + return s + } + } + return def + } + readInt := func(key string, def int) int { + s := read(key, "") + if s == "" { + return def + } + n, err := strconv.Atoi(s) + if err != nil { + return def + } + return n + } + readDur := func(key string, def time.Duration) time.Duration { + s := read(key, "") + if s == "" { + return def + } + d, err := time.ParseDuration(s) + if err != nil { + return def + } + return d + } + readBool := func(key string, def bool) bool { + s := read(key, "") + if s == "" { + return def + } + b, err := strconv.ParseBool(s) + if err != nil { + return def + } + return b + } + + cfg.Daemon.ListenAddr = read("core.daemon.listen_addr", cfg.Daemon.ListenAddr) + cfg.Daemon.DataDir = read("core.daemon.data_dir", cfg.Daemon.DataDir) + cfg.Daemon.HeartbeatInterval = readDur("core.daemon.heartbeat_interval", cfg.Daemon.HeartbeatInterval) + cfg.Daemon.CheckInterval = readDur("core.daemon.check_interval", cfg.Daemon.CheckInterval) + cfg.Daemon.LogLevel = read("core.daemon.log_level", cfg.Daemon.LogLevel) + + cfg.LLM.Provider = read("core.llm.provider", cfg.LLM.Provider) + cfg.LLM.Model = read("core.llm.model", cfg.LLM.Model) + cfg.LLM.BaseURL = read("core.llm.base_url", cfg.LLM.BaseURL) + cfg.LLM.Adapter = read("core.llm.adapter", cfg.LLM.Adapter) + cfg.LLM.Temperature = float64(readInt("core.llm.temperature", int(cfg.LLM.Temperature*100))) / 100 + cfg.LLM.MaxTokens = readInt("core.llm.max_tokens", cfg.LLM.MaxTokens) + + // 重建 sources —— 从 DB 中按前缀扫描,按名称排序保证确定性 + sourceNames := make([]string, 0) + for k := range dump { + if strings.HasPrefix(k, "core.llm.sources.") && strings.HasSuffix(k, ".base_url") { + name := strings.TrimPrefix(k, "core.llm.sources.") + name = strings.TrimSuffix(name, ".base_url") + sourceNames = append(sourceNames, name) + } + } + sort.Strings(sourceNames) + for _, name := range sourceNames { + p := "core.llm.sources." + name + cfg.LLM.Sources = append(cfg.LLM.Sources, types.LLMSource{ + Name: name, + BaseURL: read(p+".base_url", ""), + Model: read(p+".model", ""), + Adapter: read(p+".adapter", ""), + AdapterPath: read(p+".adapter_path", ""), + }) + } + + cfg.Defaults.Image = read("core.defaults.image", cfg.Defaults.Image) + cfg.Defaults.OpenClawEnabled = readBool("core.defaults.openclaw_enabled", cfg.Defaults.OpenClawEnabled) + cfg.Defaults.SnapshotPolicy.Interval = readDur("core.defaults.snapshot.interval", cfg.Defaults.SnapshotPolicy.Interval) + cfg.Defaults.SnapshotPolicy.MaxSnapshots = readInt("core.defaults.snapshot.max_snapshots", cfg.Defaults.SnapshotPolicy.MaxSnapshots) + cfg.Defaults.SnapshotPolicy.PreAction = readBool("core.defaults.snapshot.pre_action", cfg.Defaults.SnapshotPolicy.PreAction) + cfg.Defaults.SnapshotPolicy.PostAction = readBool("core.defaults.snapshot.post_action", cfg.Defaults.SnapshotPolicy.PostAction) + cfg.Defaults.RollbackPolicy.MaxRetries = readInt("core.defaults.rollback.max_retries", cfg.Defaults.RollbackPolicy.MaxRetries) + cfg.Defaults.RollbackPolicy.HealthThreshold = types.HealthStatus(readInt("core.defaults.rollback.health_threshold", int(cfg.Defaults.RollbackPolicy.HealthThreshold))) + cfg.Defaults.RollbackPolicy.CooldownPeriod = readDur("core.defaults.rollback.cooldown_period", cfg.Defaults.RollbackPolicy.CooldownPeriod) + cfg.Defaults.RollbackPolicy.AutoRollback = readBool("core.defaults.rollback.auto_rollback", cfg.Defaults.RollbackPolicy.AutoRollback) + cfg.Defaults.ResourceLimit.CPU = read("core.defaults.resource.cpu", cfg.Defaults.ResourceLimit.CPU) + cfg.Defaults.ResourceLimit.Memory = read("core.defaults.resource.memory", cfg.Defaults.ResourceLimit.Memory) + cfg.Defaults.ResourceLimit.Disk = read("core.defaults.resource.disk", cfg.Defaults.ResourceLimit.Disk) + cfg.Defaults.ResourceLimit.Network = readBool("core.defaults.resource.network", cfg.Defaults.ResourceLimit.Network) + + return cfg +} +func (r *ConfigRegistry) PluginConfig(name string) *PluginSettings { + r.ensurePluginTable(name) + return &PluginSettings{ + registry: r, + table: r.pluginTableName(name), + } +} + +// PluginSettings 实现 sdk.SettingsAPI,作用域为单个插件表 +type PluginSettings struct { + registry *ConfigRegistry + table string +} + +func (p *PluginSettings) Get(key string) (interface{}, error) { + p.registry.mu.RLock() + defer p.registry.mu.RUnlock() + var val string + err := p.registry.db.QueryRow(fmt.Sprintf(`SELECT value FROM %s WHERE key = ?`, p.table), key).Scan(&val) + if err == sql.ErrNoRows { + return nil, fmt.Errorf("config key %q not found", key) + } + if err != nil { + return nil, err + } + return val, nil +} + +func (p *PluginSettings) Set(key string, value interface{}) error { + p.registry.mu.Lock() + defer p.registry.mu.Unlock() + _, err := p.registry.db.Exec(fmt.Sprintf(`INSERT OR REPLACE INTO %s (key, value) VALUES (?, ?)`, p.table), key, fmt.Sprint(value)) + return err +} + +func (p *PluginSettings) List(prefix string) ([]string, error) { + p.registry.mu.RLock() + defer p.registry.mu.RUnlock() + var keys []string + q := fmt.Sprintf(`SELECT key FROM %s WHERE key LIKE ? ORDER BY key`, p.table) + rows, err := p.registry.db.Query(q, prefix+"%") + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var k string + if err := rows.Scan(&k); err == nil { + keys = append(keys, k) + } + } + return keys, nil } diff --git a/internal/config/registry_test.go b/internal/config/registry_test.go index 57d06b1..baa9bc4 100644 --- a/internal/config/registry_test.go +++ b/internal/config/registry_test.go @@ -1,9 +1,11 @@ package config import ( - "os" "path/filepath" "testing" + "time" + + "gitcode.com/JianFeeeee/HomeAgent/pkg/types" ) func TestRegistryBasic(t *testing.T) { @@ -33,23 +35,25 @@ func TestRegistryBasic(t *testing.T) { func TestRegistryPersist(t *testing.T) { dir := t.TempDir() - path := filepath.Join(dir, "settings.json") + path := filepath.Join(dir, "config.db") r := NewConfigRegistry(path) r.Register("core.log_level", "debug") - r.Set("plugin.test.key", 42) + r.Set("plugin.test.key", "42") if err := r.Flush(); err != nil { t.Fatalf("Flush: %v", err) } + r.Close() r2 := NewConfigRegistry(path) val, err := r2.Get("plugin.test.key") if err != nil { t.Fatalf("Get after reload: %v", err) } - if v, _ := val.(float64); v != 42 { + if v, _ := val.(string); v != "42" { t.Fatalf("expected 42, got %v", val) } + r2.Close() } func TestRegistryDelete(t *testing.T) { @@ -65,7 +69,7 @@ func TestRegistryDelete(t *testing.T) { func TestRegistryDump(t *testing.T) { r := NewConfigRegistry("") - r.Register("x", 1) + r.Register("x", "1") r.Register("y", "two") dump := r.Dump() if len(dump) != 2 { @@ -83,23 +87,171 @@ func TestRegistryUnknownKey(t *testing.T) { func TestRegistryFlush(t *testing.T) { dir := t.TempDir() - path := filepath.Join(dir, "settings.json") + path := filepath.Join(dir, "config.db") r := NewConfigRegistry(path) r.Set("k", "v") if err := r.Flush(); err != nil { t.Fatalf("Flush: %v", err) } - data, _ := os.ReadFile(path) - if len(data) == 0 { - t.Fatal("expected persisted data") + r.Close() + + // Reopen and verify persistence + r2 := NewConfigRegistry(path) + val, err := r2.Get("k") + if err != nil { + t.Fatalf("Get after flush: %v", err) } + if v, _ := val.(string); v != "v" { + t.Fatalf("expected v, got %v", val) + } + r2.Close() } func TestRegistryFlushIdempotent(t *testing.T) { dir := t.TempDir() - path := filepath.Join(dir, "settings.json") + path := filepath.Join(dir, "config.db") r := NewConfigRegistry(path) r.Set("k", "v") r.Flush() r.Flush() // second flush should not error + r.Close() +} + +func TestPluginConfig(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.db") + r := NewConfigRegistry(path) + + ps := r.PluginConfig("test_deepseek") + if err := ps.Set("api_key", "sk-test123"); err != nil { + t.Fatalf("PluginSettings.Set: %v", err) + } + + val, err := ps.Get("api_key") + if err != nil { + t.Fatalf("PluginSettings.Get: %v", err) + } + if v, _ := val.(string); v != "sk-test123" { + t.Fatalf("expected sk-test123, got %v", val) + } + + keys, err := ps.List("") + if err != nil { + t.Fatalf("PluginSettings.List: %v", err) + } + if len(keys) != 1 || keys[0] != "api_key" { + t.Fatalf("expected [api_key], got %v", keys) + } + + // Core table should not contain plugin data + coreKeys := r.List("") + for _, k := range coreKeys { + if k == "api_key" { + t.Fatal("plugin key leaked into core config table") + } + } + + r.Close() +} + +func TestSeedFromToConfig(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.db") + + cfg := &types.Config{ + Daemon: types.DaemonConfig{ + ListenAddr: ":9090", + DataDir: "/tmp/test", + HeartbeatInterval: 10 * time.Second, + CheckInterval: 20 * time.Second, + LogLevel: "debug", + }, + LLM: types.LLMConfig{ + Provider: "deepseek", + Model: "deepseek-v4-flash", + BaseURL: "https://api.deepseek.com", + Adapter: "deepseek", + Temperature: 0.5, + MaxTokens: 2048, + Sources: []types.LLMSource{ + {Name: "deepseek", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-flash", Adapter: "deepseek", AdapterPath: "adapters/deepseek.lua"}, + {Name: "openai", BaseURL: "https://api.openai.com/v1", Model: "gpt-4o", Adapter: "openai", AdapterPath: "adapters/openai.lua"}, + }, + }, + Defaults: types.AgentConfig{ + Image: "test-image", + OpenClawEnabled: true, + }, + } + + r := NewConfigRegistry(path) + r.SeedFrom(cfg) + + // Verify DB was seeded + if len(r.List("")) == 0 { + t.Fatal("SeedFrom produced empty DB") + } + + // Reconstruct config from DB + cfg2 := r.ToConfig() + + if cfg2.Daemon.ListenAddr != ":9090" { + t.Fatalf("expected :9090, got %s", cfg2.Daemon.ListenAddr) + } + if cfg2.Daemon.LogLevel != "debug" { + t.Fatalf("expected debug, got %s", cfg2.Daemon.LogLevel) + } + if cfg2.LLM.Provider != "deepseek" { + t.Fatalf("expected deepseek, got %s", cfg2.LLM.Provider) + } + if cfg2.LLM.MaxTokens != 2048 { + t.Fatalf("expected 2048, got %d", cfg2.LLM.MaxTokens) + } + if len(cfg2.LLM.Sources) != 2 { + t.Fatalf("expected 2 sources, got %d", len(cfg2.LLM.Sources)) + } + if cfg2.LLM.Sources[0].AdapterPath != "adapters/deepseek.lua" { + t.Fatalf("expected adapters/deepseek.lua, got %s", cfg2.LLM.Sources[0].AdapterPath) + } + + // Second SeedFrom should be no-op (DB already has data) + r.SeedFrom(cfg) + if len(r.List("")) != len(r.List("")) { + t.Fatal("second SeedFrom changed DB count") + } + + r.Close() +} + +func TestGetHelpers(t *testing.T) { + r := NewConfigRegistry("") + r.Set("str_key", "hello") + r.Set("int_key", "42") + r.Set("dur_key", "5m") + r.Set("bool_key", "true") + + if got := r.GetString("str_key", ""); got != "hello" { + t.Fatalf("GetString: expected hello, got %s", got) + } + if got := r.GetString("nonexistent", "fallback"); got != "fallback" { + t.Fatalf("GetString fallback: expected fallback, got %s", got) + } + if got := r.GetInt("int_key", 0); got != 42 { + t.Fatalf("GetInt: expected 42, got %d", got) + } + if got := r.GetInt("nonexistent", 99); got != 99 { + t.Fatalf("GetInt fallback: expected 99, got %d", got) + } + if got := r.GetDuration("dur_key", 0); got != 5*time.Minute { + t.Fatalf("GetDuration: expected 5m, got %v", got) + } + if got := r.GetDuration("nonexistent", 30*time.Second); got != 30*time.Second { + t.Fatalf("GetDuration fallback: expected 30s, got %v", got) + } + if got := r.GetBool("bool_key", false); got != true { + t.Fatalf("GetBool: expected true, got %v", got) + } + if got := r.GetBool("nonexistent", true); got != true { + t.Fatalf("GetBool fallback: expected true, got %v", got) + } } diff --git a/internal/lua/adapters/anthropic.lua b/internal/lua/adapters/anthropic.lua new file mode 100644 index 0000000..7e59034 --- /dev/null +++ b/internal/lua/adapters/anthropic.lua @@ -0,0 +1,82 @@ +local adapter = {} + +adapter.name = "anthropic" +adapter.version = "2.0.0" +adapter.endpoint = "/v1/messages" +adapter.headers = { + ["anthropic-version"] = "2023-06-01" +} + +-- Anthropic Messages API: { model, messages[], max_tokens, system, stream } +function adapter.transform_request(raw_body) + local ok, req = pcall(json.decode, raw_body) + if not ok then return raw_body end + + local msgs = {} + local system = "" + for _, m in ipairs(req.messages or {}) do + if m.role == "system" then + system = system .. m.content .. "\n" + else + table.insert(msgs, { role = m.role, content = m.content }) + end + end + + local anthropic_req = { + model = req.model or "claude-sonnet-4-20250514", + max_tokens = req.max_tokens or 4096, + messages = msgs, + stream = req.stream or false, + } + if system ~= "" then + anthropic_req.system = system + end + + return json.encode(anthropic_req) +end + +function adapter.transform_response(raw_body) + local ok, resp = pcall(json.decode, raw_body) + if not ok then return raw_body end + + local unified = { + content = "", + finish_reason = "", + token_usage = { prompt = 0, completion = 0, total = 0 } + } + + if resp.usage then + unified.token_usage.prompt = resp.usage.input_tokens or 0 + unified.token_usage.completion = resp.usage.output_tokens or 0 + unified.token_usage.total = (resp.usage.input_tokens or 0) + (resp.usage.output_tokens or 0) + end + + if resp.content and #resp.content > 0 then + for _, block in ipairs(resp.content) do + if block.type == "text" then + unified.content = unified.content .. (block.text or "") + end + end + end + unified.finish_reason = resp.stop_reason or "" + + return json.encode(unified) +end + +function adapter.transform_stream_chunk(raw_chunk) + local ok, chunk = pcall(json.decode, raw_chunk) + if not ok then return "" end + if chunk.type == "message_start" then return "" end + if chunk.type == "message_delta" then + return json.encode({ content = "", done = (chunk.delta and chunk.delta.stop_reason ~= nil) }) + end + if chunk.type == "content_block_delta" and chunk.delta then + return json.encode({ content = chunk.delta.text or "", done = false }) + end + if chunk.type == "message_stop" then + return json.encode({ content = "", done = true }) + end + return "" +end + +return adapter diff --git a/internal/lua/adapters/gemini.lua b/internal/lua/adapters/gemini.lua new file mode 100644 index 0000000..cfc4a42 --- /dev/null +++ b/internal/lua/adapters/gemini.lua @@ -0,0 +1,89 @@ +local adapter = {} + +adapter.name = "gemini" +adapter.version = "2.0.0" +adapter.endpoint = "/v1/models" +adapter.headers = {} + +-- Gemini API: POST /v1/models/{model}:generateContent +-- Auth: API key in query param ?key=XXX or Authorization: Bearer XXX +function adapter.transform_request(raw_body) + local ok, req = pcall(json.decode, raw_body) + if not ok then return raw_body end + + local contents = {} + for _, m in ipairs(req.messages or {}) do + table.insert(contents, { + role = (m.role == "assistant") and "model" or m.role, + parts = { { text = m.content } } + }) + end + + local gemini_req = { + contents = contents, + generationConfig = { + temperature = req.temperature or 0.7, + maxOutputTokens = req.max_tokens or 4096, + } + } + + if req.stream then + gemini_req.stream = true + end + + return json.encode(gemini_req) +end + +-- Gemini 的 endpoint 动态拼接:/v1/models/{model}:generateContent +function adapter.transform_response(raw_body) + local ok, resp = pcall(json.decode, raw_body) + if not ok then return raw_body end + + local unified = { + content = "", + finish_reason = "", + token_usage = { prompt = 0, completion = 0, total = 0 } + } + + if resp.usageMetadata then + unified.token_usage.prompt = resp.usageMetadata.promptTokenCount or 0 + unified.token_usage.completion = resp.usageMetadata.candidatesTokenCount or 0 + unified.token_usage.total = resp.usageMetadata.totalTokenCount or 0 + end + + if resp.candidates and #resp.candidates > 0 then + local cand = resp.candidates[1] + if cand.content and cand.content.parts then + for _, part in ipairs(cand.content.parts) do + if part.text then + unified.content = unified.content .. part.text + end + end + end + if cand.finishReason then + unified.finish_reason = cand.finishReason + end + end + + return json.encode(unified) +end + +function adapter.transform_stream_chunk(raw_chunk) + local ok, chunk = pcall(json.decode, raw_chunk) + if not ok then return "" end + + if not chunk.candidates or #chunk.candidates == 0 then return "" end + local cand = chunk.candidates[1] + local content = "" + if cand.content and cand.content.parts then + for _, part in ipairs(cand.content.parts) do + content = content .. (part.text or "") + end + end + return json.encode({ + content = content, + done = (cand.finishReason ~= nil) + }) +end + +return adapter diff --git a/internal/lua/adapters/github.lua b/internal/lua/adapters/github.lua new file mode 100644 index 0000000..09456b3 --- /dev/null +++ b/internal/lua/adapters/github.lua @@ -0,0 +1,73 @@ +local adapter = {} + +adapter.name = "github" +adapter.version = "2.0.0" +adapter.endpoint = "/chat/completions" +adapter.headers = {} + +-- GitHub Models: Azure-like endpoint, auth via Bearer token (PAT) +-- BaseURL example: https://models.inference.ai.azure.com +function adapter.transform_request(raw_body) + local ok, req = pcall(json.decode, raw_body) + if not ok then return raw_body end + req.model = req.model or "gpt-4o" + req.temperature = req.temperature or 0.7 + req.max_tokens = req.max_tokens or 4096 + req.stream = req.stream or false + return json.encode(req) +end + +function adapter.transform_response(raw_body) + local ok, resp = pcall(json.decode, raw_body) + if not ok then return raw_body end + + local unified = { + content = "", + finish_reason = "", + token_usage = { prompt = 0, completion = 0, total = 0 } + } + + if resp.usage then + unified.token_usage.prompt = resp.usage.prompt_tokens or 0 + unified.token_usage.completion = resp.usage.completion_tokens or 0 + unified.token_usage.total = resp.usage.total_tokens or 0 + end + + if resp.choices and #resp.choices > 0 then + local ch = resp.choices[1] + if ch.message then + unified.content = ch.message.content or "" + if ch.message.tool_calls then + local tcs = {} + for _, tc in ipairs(ch.message.tool_calls) do + local args_ok, args = pcall(json.decode, tc["function"].arguments) + if not args_ok then args = {} end + table.insert(tcs, { + id = tc.id, + type = tc.type or "function", + name = tc["function"].name, + arguments = args + }) + end + unified.tool_calls = tcs + end + end + unified.finish_reason = ch.finish_reason or "" + end + + return json.encode(unified) +end + +function adapter.transform_stream_chunk(raw_chunk) + local ok, chunk = pcall(json.decode, raw_chunk) + if not ok then return "" end + if not chunk.choices or #chunk.choices == 0 then return "" end + local delta = chunk.choices[1].delta or {} + local fr = chunk.choices[1].finish_reason + return json.encode({ + content = delta.content or "", + done = (fr ~= nil) + }) +end + +return adapter diff --git a/internal/lua/adapters/groq.lua b/internal/lua/adapters/groq.lua new file mode 100644 index 0000000..19d104b --- /dev/null +++ b/internal/lua/adapters/groq.lua @@ -0,0 +1,72 @@ +local adapter = {} + +adapter.name = "groq" +adapter.version = "2.0.0" +adapter.endpoint = "/openai/v1/chat/completions" +adapter.headers = {} + +-- Groq API is OpenAI-compatible +function adapter.transform_request(raw_body) + local ok, req = pcall(json.decode, raw_body) + if not ok then return raw_body end + req.model = req.model or "llama3-70b-8192" + req.temperature = req.temperature or 0.7 + req.max_tokens = req.max_tokens or 4096 + req.stream = req.stream or false + return json.encode(req) +end + +function adapter.transform_response(raw_body) + local ok, resp = pcall(json.decode, raw_body) + if not ok then return raw_body end + + local unified = { + content = "", + finish_reason = "", + token_usage = { prompt = 0, completion = 0, total = 0 } + } + + if resp.usage then + unified.token_usage.prompt = resp.usage.prompt_tokens or 0 + unified.token_usage.completion = resp.usage.completion_tokens or 0 + unified.token_usage.total = resp.usage.total_tokens or 0 + end + + if resp.choices and #resp.choices > 0 then + local ch = resp.choices[1] + if ch.message then + unified.content = ch.message.content or "" + if ch.message.tool_calls then + local tcs = {} + for _, tc in ipairs(ch.message.tool_calls) do + local args_ok, args = pcall(json.decode, tc["function"].arguments) + if not args_ok then args = {} end + table.insert(tcs, { + id = tc.id, + type = tc.type or "function", + name = tc["function"].name, + arguments = args + }) + end + unified.tool_calls = tcs + end + end + unified.finish_reason = ch.finish_reason or "" + end + + return json.encode(unified) +end + +function adapter.transform_stream_chunk(raw_chunk) + local ok, chunk = pcall(json.decode, raw_chunk) + if not ok then return "" end + if not chunk.choices or #chunk.choices == 0 then return "" end + local delta = chunk.choices[1].delta or {} + local fr = chunk.choices[1].finish_reason + return json.encode({ + content = delta.content or "", + done = (fr ~= nil) + }) +end + +return adapter diff --git a/internal/lua/adapters/mistral.lua b/internal/lua/adapters/mistral.lua new file mode 100644 index 0000000..63a02fa --- /dev/null +++ b/internal/lua/adapters/mistral.lua @@ -0,0 +1,72 @@ +local adapter = {} + +adapter.name = "mistral" +adapter.version = "2.0.0" +adapter.endpoint = "/v1/chat/completions" +adapter.headers = {} + +-- Mistral API is OpenAI-compatible, just passes through +function adapter.transform_request(raw_body) + local ok, req = pcall(json.decode, raw_body) + if not ok then return raw_body end + req.model = req.model or "mistral-large-latest" + req.temperature = req.temperature or 0.7 + req.max_tokens = req.max_tokens or 4096 + req.stream = req.stream or false + return json.encode(req) +end + +function adapter.transform_response(raw_body) + local ok, resp = pcall(json.decode, raw_body) + if not ok then return raw_body end + + local unified = { + content = "", + finish_reason = "", + token_usage = { prompt = 0, completion = 0, total = 0 } + } + + if resp.usage then + unified.token_usage.prompt = resp.usage.prompt_tokens or 0 + unified.token_usage.completion = resp.usage.completion_tokens or 0 + unified.token_usage.total = resp.usage.total_tokens or 0 + end + + if resp.choices and #resp.choices > 0 then + local ch = resp.choices[1] + if ch.message then + unified.content = ch.message.content or "" + if ch.message.tool_calls then + local tcs = {} + for _, tc in ipairs(ch.message.tool_calls) do + local args_ok, args = pcall(json.decode, tc["function"].arguments) + if not args_ok then args = {} end + table.insert(tcs, { + id = tc.id, + type = tc.type or "function", + name = tc["function"].name, + arguments = args + }) + end + unified.tool_calls = tcs + end + end + unified.finish_reason = ch.finish_reason or "" + end + + return json.encode(unified) +end + +function adapter.transform_stream_chunk(raw_chunk) + local ok, chunk = pcall(json.decode, raw_chunk) + if not ok then return "" end + if not chunk.choices or #chunk.choices == 0 then return "" end + local delta = chunk.choices[1].delta or {} + local fr = chunk.choices[1].finish_reason + return json.encode({ + content = delta.content or "", + done = (fr ~= nil) + }) +end + +return adapter diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go index 385cdc3..870b84a 100644 --- a/internal/plugin/plugin.go +++ b/internal/plugin/plugin.go @@ -182,12 +182,10 @@ func (r *Registry) RegisterPluginAPI(api *sdk.PluginAPI) error { return fmt.Errorf("sdk api %s already registered", api.Name) } - // 注入 SettingsAPI(读取/修改核心与其他插件配置) + // 注入 SettingsAPI(插件作用域配置表 config_) if r.cfgReg != nil { - api.SetSettings(&pluginSettings{ - reg: r.cfgReg, - name: api.Name, - }) + ps := r.cfgReg.PluginConfig(api.Name) + api.SetSettings(ps) } r.sdkAPIs[api.Name] = &sdkAPI{ @@ -205,24 +203,6 @@ func (r *Registry) RegisterPluginAPI(api *sdk.PluginAPI) error { return nil } -// pluginSettings 实现 SettingsAPI,以插件名为命名空间 -type pluginSettings struct { - reg *internalConfig.ConfigRegistry - name string -} - -func (s *pluginSettings) Get(key string) (interface{}, error) { - return s.reg.Get(key) -} - -func (s *pluginSettings) Set(key string, value interface{}) error { - return s.reg.Set(key, value) -} - -func (s *pluginSettings) List(prefix string) ([]string, error) { - return s.reg.List(prefix), nil -} - // GetAllSDKToolDefs 收集所有 SDK 插件的工具定义 func (r *Registry) GetAllSDKToolDefs() []sdk.ToolDef { r.mu.RLock() @@ -248,6 +228,17 @@ func (r *Registry) ExecuteSDKTool(name string, args map[string]interface{}) (int return nil, fmt.Errorf("sdk tool %s not found", name) } +// GetAllSDKPlugins 获取所有已注册的 SDK 插件 API 实例 +func (r *Registry) GetAllSDKPlugins() []*sdk.PluginAPI { + r.mu.RLock() + defer r.mu.RUnlock() + apis := make([]*sdk.PluginAPI, 0, len(r.sdkAPIs)) + for _, sa := range r.sdkAPIs { + apis = append(apis, sa.api) + } + return apis +} + // GetStageHandlers 获取所有 SDK 插件在指定阶段的处理器 func (r *Registry) GetStageHandlers(stage sdk.Stage) []sdk.StageHandler { r.mu.RLock() diff --git a/internal/plugins/test_deepseek/plugin.go b/internal/plugins/test_deepseek/plugin.go new file mode 100644 index 0000000..4c9adc7 --- /dev/null +++ b/internal/plugins/test_deepseek/plugin.go @@ -0,0 +1,129 @@ +package test_deepseek + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + sdk "gitcode.com/JianFeeeee/HomeAgent/internal/plugin/sdk" +) + +var defaultClient = &http.Client{Timeout: 30 * time.Second} + +func New(bus sdk.EventBus) *sdk.PluginAPI { + api := sdk.NewPluginAPI("test_deepseek", "1.0.0", bus, nil, nil) + + api.RegisterTool("test_deepseek", func(args map[string]interface{}) (interface{}, error) { + prompt, _ := args["prompt"].(string) + if prompt == "" { + prompt = "你好,请用一句话介绍你自己" + } + return callDeepSeek(prompt, api.Settings(), defaultClient) + }) + + return api +} + +func callDeepSeek(prompt string, sett sdk.SettingsAPI, client *http.Client) (interface{}, error) { + baseURL := "https://api.deepseek.com/v1" + model := "deepseek-chat" + apiKey := "" + if sett != nil { + if v, err := sett.Get("base_url"); err == nil { + if s, ok := v.(string); ok && s != "" { + baseURL = s + } + } + if v, err := sett.Get("model"); err == nil { + if s, ok := v.(string); ok && s != "" { + model = s + } + } + if v, err := sett.Get("api_key"); err == nil { + if s, ok := v.(string); ok && s != "" { + apiKey = s + } + } + } + if apiKey == "" { + apiKey = "sk-feaa590161ed404b956f941992fae6f0" + } + + body := map[string]interface{}{ + "model": model, + "messages": []map[string]string{ + {"role": "user", "content": prompt}, + }, + "temperature": 0.7, + "max_tokens": 1024, + "stream": false, + } + bodyJSON, _ := json.Marshal(body) + + req, err := http.NewRequest("POST", strings.TrimRight(baseURL, "/")+"/chat/completions", strings.NewReader(string(bodyJSON))) + if err != nil { + return map[string]interface{}{"error": fmt.Sprintf("创建请求失败: %v", err)}, nil + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+apiKey) + + resp, err := client.Do(req) + if err != nil { + return map[string]interface{}{"error": fmt.Sprintf("API 调用失败: %v", err)}, nil + } + defer resp.Body.Close() + + respBody, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + return map[string]interface{}{ + "error": fmt.Sprintf("API 返回 %d", resp.StatusCode), + "body": string(respBody), + "status": "failed", + }, nil + } + + var result struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` + Usage struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` + } `json:"usage"` + } + if err := json.Unmarshal(respBody, &result); err != nil { + return map[string]interface{}{"error": fmt.Sprintf("解析响应失败: %v", err)}, nil + } + + content := "" + if len(result.Choices) > 0 { + content = result.Choices[0].Message.Content + } + + return map[string]interface{}{ + "prompt": prompt, + "response": content, + "model": model, + "usage": result.Usage, + "status": "ok", + "base_url": baseURL, + }, nil +} + +func NewWithClient(bus sdk.EventBus, client *http.Client) *sdk.PluginAPI { + api := sdk.NewPluginAPI("test_deepseek", "1.0.0", bus, nil, nil) + api.RegisterTool("test_deepseek", func(args map[string]interface{}) (interface{}, error) { + prompt, _ := args["prompt"].(string) + if prompt == "" { + prompt = "你好,请用一句话介绍你自己" + } + return callDeepSeek(prompt, api.Settings(), client) + }) + return api +} diff --git a/internal/plugins/test_deepseek/plugin_test.go b/internal/plugins/test_deepseek/plugin_test.go new file mode 100644 index 0000000..2d84db4 --- /dev/null +++ b/internal/plugins/test_deepseek/plugin_test.go @@ -0,0 +1,246 @@ +package test_deepseek + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + sdk "gitcode.com/JianFeeeee/HomeAgent/internal/plugin/sdk" +) + +type mockSettings struct { + data map[string]string +} + +func (m *mockSettings) Get(key string) (interface{}, error) { + v, ok := m.data[key] + if !ok { + return nil, nil + } + return v, nil +} +func (m *mockSettings) Set(key string, value interface{}) error { + m.data[key] = value.(string) + return nil +} +func (m *mockSettings) List(prefix string) ([]string, error) { + var keys []string + for k := range m.data { + keys = append(keys, k) + } + return keys, nil +} + +func mockAPI(t *testing.T) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Fatalf("expected POST, got %s", r.Method) + } + if r.Header.Get("Authorization") != "Bearer sk-test123" { + t.Fatalf("expected Bearer sk-test123, got %s", r.Header.Get("Authorization")) + } + if r.Header.Get("Content-Type") != "application/json" { + t.Fatalf("expected application/json, got %s", r.Header.Get("Content-Type")) + } + + var reqBody map[string]interface{} + json.NewDecoder(r.Body).Decode(&reqBody) + if reqBody["model"] != "test-model" { + t.Fatalf("expected test-model, got %v", reqBody["model"]) + } + if reqBody["stream"] != false { + t.Fatalf("expected stream=false, got %v", reqBody["stream"]) + } + + w.WriteHeader(200) + json.NewEncoder(w).Encode(map[string]interface{}{ + "choices": []map[string]interface{}{ + { + "message": map[string]string{ + "content": "你好!我是 DeepSeek。", + }, + }, + }, + "usage": map[string]int{ + "prompt_tokens": 10, + "completion_tokens": 20, + "total_tokens": 30, + }, + }) + })) +} + +func TestCallDeepSeekSuccess(t *testing.T) { + ts := mockAPI(t) + defer ts.Close() + + sett := &mockSettings{data: map[string]string{ + "base_url": ts.URL, + "model": "test-model", + "api_key": "sk-test123", + }} + + result, err := callDeepSeek("你好", sett, ts.Client()) + if err != nil { + t.Fatalf("callDeepSeek: %v", err) + } + + // 通过 JSON 反序列化验证(避免匿名 struct 类型断言问题) + data, _ := json.Marshal(result) + var resp map[string]interface{} + json.Unmarshal(data, &resp) + + if resp["status"] != "ok" { + t.Fatalf("expected status ok, got %v", resp["status"]) + } + if resp["response"] != "你好!我是 DeepSeek。" { + t.Fatalf("expected response 你好!我是 DeepSeek。, got %v", resp["response"]) + } + if resp["model"] != "test-model" { + t.Fatalf("expected model test-model, got %v", resp["model"]) + } + usage := resp["usage"].(map[string]interface{}) + if usage["total_tokens"].(float64) != 30 { + t.Fatalf("expected 30 total tokens, got %v", usage["total_tokens"]) + } +} + +func TestCallDeepSeekNon200(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(401) + w.Write([]byte(`{"error":"unauthorized"}`)) + })) + defer ts.Close() + + sett := &mockSettings{data: map[string]string{ + "base_url": ts.URL, + "model": "test-model", + "api_key": "sk-bad", + }} + result, err := callDeepSeek("hi", sett, ts.Client()) + if err != nil { + t.Fatalf("callDeepSeek: %v", err) + } + m := result.(map[string]interface{}) + if m["status"] != "failed" { + t.Fatalf("expected status failed, got %v", m["status"]) + } +} + +func TestCallDeepSeekDefaultPrompt(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var reqBody map[string]interface{} + json.NewDecoder(r.Body).Decode(&reqBody) + msgs := reqBody["messages"].([]interface{}) + msg := msgs[0].(map[string]interface{}) + if msg["content"] == "你好,请用一句话介绍你自己" { + w.WriteHeader(200) + json.NewEncoder(w).Encode(map[string]interface{}{ + "choices": []map[string]interface{}{ + {"message": map[string]string{"content": "ok"}}, + }, + }) + return + } + t.Fatalf("unexpected prompt: %v", msg["content"]) + })) + defer ts.Close() + + sett := &mockSettings{data: map[string]string{ + "base_url": ts.URL, + "model": "test-model", + "api_key": "sk-test", + }} + bus := sdk.NewInProcessBus() + api := NewWithClient(bus, ts.Client()) + api.SetSettings(sett) + + handler := api.Tools()["test_deepseek"] + result, err := handler(map[string]interface{}{}) + if err != nil { + t.Fatalf("handler: %v", err) + } + data, _ := json.Marshal(result) + var resp map[string]interface{} + json.Unmarshal(data, &resp) + if resp["status"] != "ok" { + t.Fatalf("expected ok, got %v", resp["status"]) + } +} + +func TestCallDeepSeekFallbackAPIKey(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + expected := "Bearer sk-feaa590161ed404b956f941992fae6f0" + if r.Header.Get("Authorization") != expected { + t.Fatalf("expected %s, got %s", expected, r.Header.Get("Authorization")) + } + w.WriteHeader(200) + json.NewEncoder(w).Encode(map[string]interface{}{ + "choices": []map[string]interface{}{ + {"message": map[string]string{"content": "ok"}}, + }, + }) + })) + defer ts.Close() + + // 不设 api_key 触发 fallback + sett := &mockSettings{data: map[string]string{ + "base_url": ts.URL, + "model": "test-model", + }} + result, err := callDeepSeek("hi", sett, ts.Client()) + if err != nil { + t.Fatalf("callDeepSeek: %v", err) + } + m := result.(map[string]interface{}) + if m["status"] != "ok" { + t.Fatalf("expected ok, got %v", m["status"]) + } +} + +func TestNewWithClient(t *testing.T) { + ts := mockAPI(t) + defer ts.Close() + + sett := &mockSettings{data: map[string]string{ + "base_url": ts.URL, + "model": "test-model", + "api_key": "sk-test123", + }} + bus := sdk.NewInProcessBus() + api := NewWithClient(bus, ts.Client()) + api.SetSettings(sett) + + handler := api.Tools()["test_deepseek"] + if handler == nil { + t.Fatal("test_deepseek tool not registered") + } + + result, err := handler(map[string]interface{}{"prompt": "你好"}) + if err != nil { + t.Fatalf("tool handler: %v", err) + } + m := result.(map[string]interface{}) + if m["status"] != "ok" { + t.Fatalf("expected ok, got %v", m["status"]) + } + if m["response"] != "你好!我是 DeepSeek。" { + t.Fatalf("expected response, got %v", m["response"]) + } +} + +func TestNewDefaultClient(t *testing.T) { + bus := sdk.NewInProcessBus() + api := New(bus) + if api == nil { + t.Fatal("New returned nil") + } + if api.Name != "test_deepseek" { + t.Fatalf("expected name test_deepseek, got %s", api.Name) + } + tools := api.Tools() + if _, ok := tools["test_deepseek"]; !ok { + t.Fatal("test_deepseek tool not registered") + } +} diff --git a/pkg/types/types.go b/pkg/types/types.go index 27a4559..fc751e5 100644 --- a/pkg/types/types.go +++ b/pkg/types/types.go @@ -93,13 +93,24 @@ type OperationLog struct { Success bool `json:"success"` } +type LLMSource struct { + Name string `json:"name"` + BaseURL string `json:"base_url"` + Model string `json:"model"` + APIKey string `json:"api_key,omitempty"` + Adapter string `json:"adapter"` + AdapterPath string `json:"adapter_path,omitempty"` +} + type LLMConfig struct { - Provider string `json:"provider"` - Model string `json:"model"` - BaseURL string `json:"base_url"` - APIKey string `json:"api_key"` - Temperature float64 `json:"temperature"` - MaxTokens int `json:"max_tokens"` + Provider string `json:"provider"` + Model string `json:"model"` + BaseURL string `json:"base_url"` + APIKey string `json:"api_key"` + Adapter string `json:"adapter"` + Temperature float64 `json:"temperature"` + MaxTokens int `json:"max_tokens"` + Sources []LLMSource `json:"sources,omitempty"` } type Config struct {