mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-04 00:03:59 +00:00
适配多个 LLM 源 (anthropic/gemini/mistral/groq/github) + SQLite 配置收敛 + 测试插件
This commit is contained in:
@ -6,7 +6,6 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -18,11 +17,14 @@ import (
|
|||||||
"gitcode.com/JianFeeeee/HomeAgent/config"
|
"gitcode.com/JianFeeeee/HomeAgent/config"
|
||||||
internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config"
|
internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config"
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
|
"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"
|
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
|
||||||
luapkg "gitcode.com/JianFeeeee/HomeAgent/internal/lua"
|
luapkg "gitcode.com/JianFeeeee/HomeAgent/internal/lua"
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
|
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/pipeline"
|
"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/memory/text"
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/onebot"
|
"gitcode.com/JianFeeeee/HomeAgent/internal/onebot"
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
|
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
|
||||||
@ -78,6 +80,12 @@ func main() {
|
|||||||
|
|
||||||
memIdx := memory.NewIndexer(memDB)
|
memIdx := memory.NewIndexer(memDB)
|
||||||
|
|
||||||
|
// === Social Store(人物特质与关系网)===
|
||||||
|
socialStore := social.New(memDB)
|
||||||
|
if memDB != nil {
|
||||||
|
log.Printf("[homed] social store initialized")
|
||||||
|
}
|
||||||
|
|
||||||
// === Memory Pipeline ===
|
// === Memory Pipeline ===
|
||||||
distiller := pipeline.NewDistiller(memDB, cfg.Daemon.DataDir, pipeline.DistillerConfig{
|
distiller := pipeline.NewDistiller(memDB, cfg.Daemon.DataDir, pipeline.DistillerConfig{
|
||||||
Interval: 10 * time.Minute,
|
Interval: 10 * time.Minute,
|
||||||
@ -107,20 +115,15 @@ func main() {
|
|||||||
return onebot.NewDevice(name, wsURL, accessToken, iom), nil
|
return onebot.NewDevice(name, wsURL, accessToken, iom), nil
|
||||||
})
|
})
|
||||||
|
|
||||||
// === Config Registry (统一配置中心,供插件读写) ===
|
// === Config Registry (统一配置中心,SQLite 持久化) ===
|
||||||
cfgReg := internalConfig.NewConfigRegistry(filepath.Join(cfg.Daemon.DataDir, "settings.json"))
|
// 所有配置收敛到 SQLite,YAML 仅作首次 seed
|
||||||
cfgReg.Register("core.daemon.listen_addr", cfg.Daemon.ListenAddr)
|
cfgReg := internalConfig.NewConfigRegistry(filepath.Join(cfg.Daemon.DataDir, "config.db"))
|
||||||
cfgReg.Register("core.daemon.data_dir", cfg.Daemon.DataDir)
|
defer cfgReg.Close()
|
||||||
cfgReg.Register("core.daemon.heartbeat_interval", cfg.Daemon.HeartbeatInterval.String())
|
cfgReg.SeedFrom(cfg)
|
||||||
cfgReg.Register("core.daemon.check_interval", cfg.Daemon.CheckInterval.String())
|
cfg = cfgReg.ToConfig() // 此后全从 DB 读取
|
||||||
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))
|
|
||||||
pluginReg.SetConfigRegistry(cfgReg)
|
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 ===
|
// === Supervisor ===
|
||||||
sup := supervisor.New(cfg)
|
sup := supervisor.New(cfg)
|
||||||
@ -207,18 +210,33 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// === API Provider (唯一输出路径) ===
|
// === API Provider Manager(多 LLM 源,通过 Lua 适配器兼容不同 API)===
|
||||||
apiKey := cfg.LLM.APIKey
|
apiKey := cfg.LLM.APIKey
|
||||||
if apiKey == "" {
|
if apiKey == "" {
|
||||||
apiKey = os.Getenv("DEEPSEEK_API_KEY")
|
apiKey = os.Getenv("DEEPSEEK_API_KEY")
|
||||||
}
|
}
|
||||||
provider := agentAPI.NewOpenAIProvider(agentAPI.BaseConfig{
|
baseAPIKey := apiKey
|
||||||
Model: cfg.LLM.Model,
|
|
||||||
BaseURL: cfg.LLM.BaseURL,
|
providerMgr := agentAPI.NewProviderManager()
|
||||||
APIKey: apiKey,
|
for _, src := range cfg.LLM.Sources {
|
||||||
Temperature: cfg.LLM.Temperature,
|
key := src.APIKey
|
||||||
MaxTokens: cfg.LLM.MaxTokens,
|
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 (固定人格内核) ===
|
// === Personality (固定人格内核) ===
|
||||||
personalPath := filepath.Join(cfg.Daemon.DataDir, "personal", "personal.md")
|
personalPath := filepath.Join(cfg.Daemon.DataDir, "personal", "personal.md")
|
||||||
@ -253,6 +271,17 @@ func main() {
|
|||||||
stageHost.SyncFromRegistry(pluginReg)
|
stageHost.SyncFromRegistry(pluginReg)
|
||||||
log.Printf("[homed] stage host initialized with %d plugin sdks", pluginReg.SDKPluginCount())
|
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 ===
|
// === Single Agent Core ===
|
||||||
agent := agentCore.New(agentCore.AgentConfig{
|
agent := agentCore.New(agentCore.AgentConfig{
|
||||||
ID: "main",
|
ID: "main",
|
||||||
@ -266,16 +295,25 @@ func main() {
|
|||||||
1. memory_recall — 查询图记忆(历史/个人信息)
|
1. memory_recall — 查询图记忆(历史/个人信息)
|
||||||
2. memory_commit — 写入图记忆(记住新信息)
|
2. memory_commit — 写入图记忆(记住新信息)
|
||||||
3. memory_introspect — 查看记忆统计
|
3. memory_introspect — 查看记忆统计
|
||||||
4. knowledge_search — 搜索知识库
|
4. person_query — 查询人物特质与社交关系网
|
||||||
5. doc_query — 查询文档记忆
|
5. person_set_trait — 记录人物特质(性格/喜好等)
|
||||||
6. doc_commit — 写入文档记忆
|
6. person_relate — 建立人物间社交关系
|
||||||
7. plgreload — 热重载插件
|
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_recall。
|
||||||
当用户告诉了你新的个人信息时,调用 memory_commit。
|
当用户告诉了你新的个人信息时,调用 memory_commit。
|
||||||
|
当用户提到某个人的性格、喜好或人际关系时,使用 person_set_trait 和 person_relate 记录。
|
||||||
需要查询知识时使用 knowledge_search。
|
需要查询知识时使用 knowledge_search。
|
||||||
|
如需切换 LLM 供应商(如从 DeepSeek 切到 OpenAI),使用 llm_list_sources 查看可用源,再用 llm_set_source 切换。
|
||||||
回复你的真实想法,用自然语言与用户交流。`,
|
回复你的真实想法,用自然语言与用户交流。`,
|
||||||
Provider: provider,
|
Provider: provider,
|
||||||
|
ProviderManager: providerMgr,
|
||||||
IO: iom,
|
IO: iom,
|
||||||
Memory: memDB,
|
Memory: memDB,
|
||||||
Indexer: memIdx,
|
Indexer: memIdx,
|
||||||
@ -284,6 +322,8 @@ func main() {
|
|||||||
MaxToolTurns: 10,
|
MaxToolTurns: 10,
|
||||||
DocStore: docStore,
|
DocStore: docStore,
|
||||||
Knowledge: ks,
|
Knowledge: ks,
|
||||||
|
SocialStore: socialStore,
|
||||||
|
TextMemory: textMem,
|
||||||
Personality: personality,
|
Personality: personality,
|
||||||
PluginReg: pluginReg,
|
PluginReg: pluginReg,
|
||||||
PluginDir: filepath.Join(cfg.Daemon.DataDir, "plugins"),
|
PluginDir: filepath.Join(cfg.Daemon.DataDir, "plugins"),
|
||||||
@ -298,7 +338,8 @@ func main() {
|
|||||||
sup.SetTracker(trk)
|
sup.SetTracker(trk)
|
||||||
sup.RegisterAgent("main")
|
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 ===
|
// === Built-in HTTP API & WebUI Plugin ===
|
||||||
webui := api.NewWebUIPlugin(
|
webui := api.NewWebUIPlugin(
|
||||||
|
|||||||
@ -22,11 +22,23 @@ func DefaultConfig() types.Config {
|
|||||||
LogLevel: "info",
|
LogLevel: "info",
|
||||||
},
|
},
|
||||||
LLM: types.LLMConfig{
|
LLM: types.LLMConfig{
|
||||||
Provider: "openai",
|
Provider: "deepseek",
|
||||||
Model: "deepseek-v4-flash",
|
Model: "deepseek-v4-flash",
|
||||||
BaseURL: "https://api.deepseek.com",
|
BaseURL: "https://api.deepseek.com",
|
||||||
|
APIKey: "",
|
||||||
|
Adapter: "deepseek",
|
||||||
Temperature: 0.7,
|
Temperature: 0.7,
|
||||||
MaxTokens: 4096,
|
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{
|
Defaults: types.AgentConfig{
|
||||||
Image: "homeagent/agent-base:latest",
|
Image: "homeagent/agent-base:latest",
|
||||||
|
|||||||
@ -5,6 +5,56 @@ daemon:
|
|||||||
check_interval: 30s
|
check_interval: 30s
|
||||||
log_level: "info"
|
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:
|
defaults:
|
||||||
image: "homeagent/agent-base:latest"
|
image: "homeagent/agent-base:latest"
|
||||||
llm_endpoints:
|
llm_endpoints:
|
||||||
|
|||||||
104
docs/ADAPTER.md
Normal file
104
docs/ADAPTER.md
Normal file
@ -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 <key>` | 标准 OpenAI 格式 |
|
||||||
|
| **DeepSeek** | `/chat/completions` | `Authorization: Bearer <key>` | OpenAI 兼容,强制 temperature=0 |
|
||||||
|
| **Anthropic** | `/v1/messages` | `x-api-key: <key>` | Messages API,system 消息分离,content 为 block 数组 |
|
||||||
|
| **Gemini** | `/v1/models/{model}:generateContent` | `?key=<key>` 或 Bearer | contents/parts 格式,role 用 model 而非 assistant |
|
||||||
|
| **Mistral** | `/v1/chat/completions` | `Authorization: Bearer <key>` | OpenAI 兼容 |
|
||||||
|
| **Groq** | `/openai/v1/chat/completions` | `Authorization: Bearer <key>` | OpenAI 兼容 |
|
||||||
|
| **GitHub Models** | `/chat/completions` | `Authorization: Bearer <pat>` | OpenAI 兼容 |
|
||||||
|
| **Ollama** | `/api/chat` | 无 | 不同的 options 格式 |
|
||||||
|
|
||||||
|
## 添加新源步骤
|
||||||
|
|
||||||
|
1. 在 `internal/lua/adapters/` 下创建 `<name>.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 ./...`
|
||||||
@ -15,6 +15,8 @@ import (
|
|||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
|
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
|
"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"
|
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
|
||||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/plugin/sdk"
|
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/plugin/sdk"
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/skill"
|
"gitcode.com/JianFeeeee/HomeAgent/internal/skill"
|
||||||
@ -29,6 +31,7 @@ type Agent struct {
|
|||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
id types.AgentID
|
id types.AgentID
|
||||||
provider agentAPI.Provider
|
provider agentAPI.Provider
|
||||||
|
providerManager *agentAPI.ProviderManager
|
||||||
io *agentIO.IOManager
|
io *agentIO.IOManager
|
||||||
memory *memory.GraphDB
|
memory *memory.GraphDB
|
||||||
indexer *memory.Indexer
|
indexer *memory.Indexer
|
||||||
@ -46,6 +49,12 @@ type Agent struct {
|
|||||||
// 知识库
|
// 知识库
|
||||||
knowledge *knowledge.Store
|
knowledge *knowledge.Store
|
||||||
|
|
||||||
|
// 人物特质与关系网
|
||||||
|
social *social.SocialStore
|
||||||
|
|
||||||
|
// 文本记忆(原始对话日志)
|
||||||
|
textMem *text.Memory
|
||||||
|
|
||||||
// 人格设定
|
// 人格设定
|
||||||
personality *agentPkg.Personality
|
personality *agentPkg.Personality
|
||||||
|
|
||||||
@ -68,12 +77,18 @@ type Agent struct {
|
|||||||
|
|
||||||
// 自循环输入通道:核心内部任务(记忆消歧、系统维护),不经过 IO 层
|
// 自循环输入通道:核心内部任务(记忆消歧、系统维护),不经过 IO 层
|
||||||
selfInputCh chan string
|
selfInputCh chan string
|
||||||
|
|
||||||
|
// 子任务异步执行
|
||||||
|
childMu sync.Mutex
|
||||||
|
childNextID int64
|
||||||
|
childResults map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
type AgentConfig struct {
|
type AgentConfig struct {
|
||||||
ID types.AgentID
|
ID types.AgentID
|
||||||
SystemPrompt string
|
SystemPrompt string
|
||||||
Provider agentAPI.Provider
|
Provider agentAPI.Provider
|
||||||
|
ProviderManager *agentAPI.ProviderManager
|
||||||
IO *agentIO.IOManager
|
IO *agentIO.IOManager
|
||||||
Memory *memory.GraphDB
|
Memory *memory.GraphDB
|
||||||
Indexer *memory.Indexer
|
Indexer *memory.Indexer
|
||||||
@ -83,6 +98,8 @@ type AgentConfig struct {
|
|||||||
|
|
||||||
DocStore *document.Store
|
DocStore *document.Store
|
||||||
Knowledge *knowledge.Store
|
Knowledge *knowledge.Store
|
||||||
|
SocialStore *social.SocialStore
|
||||||
|
TextMemory *text.Memory
|
||||||
Personality *agentPkg.Personality
|
Personality *agentPkg.Personality
|
||||||
PluginReg *plugin.Registry
|
PluginReg *plugin.Registry
|
||||||
PluginDir string
|
PluginDir string
|
||||||
@ -107,6 +124,7 @@ func New(cfg AgentConfig) *Agent {
|
|||||||
return &Agent{
|
return &Agent{
|
||||||
id: cfg.ID,
|
id: cfg.ID,
|
||||||
provider: cfg.Provider,
|
provider: cfg.Provider,
|
||||||
|
providerManager: cfg.ProviderManager,
|
||||||
io: cfg.IO,
|
io: cfg.IO,
|
||||||
memory: cfg.Memory,
|
memory: cfg.Memory,
|
||||||
indexer: cfg.Indexer,
|
indexer: cfg.Indexer,
|
||||||
@ -119,6 +137,8 @@ func New(cfg AgentConfig) *Agent {
|
|||||||
maxTurns: cfg.MaxToolTurns,
|
maxTurns: cfg.MaxToolTurns,
|
||||||
docStore: cfg.DocStore,
|
docStore: cfg.DocStore,
|
||||||
knowledge: cfg.Knowledge,
|
knowledge: cfg.Knowledge,
|
||||||
|
social: cfg.SocialStore,
|
||||||
|
textMem: cfg.TextMemory,
|
||||||
personality: cfg.Personality,
|
personality: cfg.Personality,
|
||||||
pluginReg: cfg.PluginReg,
|
pluginReg: cfg.PluginReg,
|
||||||
pluginDir: cfg.PluginDir,
|
pluginDir: cfg.PluginDir,
|
||||||
@ -127,6 +147,7 @@ func New(cfg AgentConfig) *Agent {
|
|||||||
stageHost: cfg.StageHost,
|
stageHost: cfg.StageHost,
|
||||||
eventBus: cfg.EventBus,
|
eventBus: cfg.EventBus,
|
||||||
selfInputCh: make(chan string, 64),
|
selfInputCh: make(chan string, 64),
|
||||||
|
childResults: make(map[string]string),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -468,6 +489,8 @@ func (a *Agent) executeToolCall(tc agentAPI.ToolCall) string {
|
|||||||
switch {
|
switch {
|
||||||
case strings.HasPrefix(tc.Name, "memory_"):
|
case strings.HasPrefix(tc.Name, "memory_"):
|
||||||
return a.executeMemoryTool(tc)
|
return a.executeMemoryTool(tc)
|
||||||
|
case strings.HasPrefix(tc.Name, "social_"):
|
||||||
|
return a.executeSocialTool(tc)
|
||||||
case strings.HasPrefix(tc.Name, "knowledge_"):
|
case strings.HasPrefix(tc.Name, "knowledge_"):
|
||||||
return a.executeKnowledgeTool(tc)
|
return a.executeKnowledgeTool(tc)
|
||||||
case strings.HasPrefix(tc.Name, "doc_"):
|
case strings.HasPrefix(tc.Name, "doc_"):
|
||||||
@ -482,6 +505,10 @@ func (a *Agent) executeToolCall(tc agentAPI.ToolCall) string {
|
|||||||
return a.executePluginReload()
|
return a.executePluginReload()
|
||||||
case tc.Name == "spawn_child":
|
case tc.Name == "spawn_child":
|
||||||
return a.executeSpawnChild(tc)
|
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 注册)
|
// 插件工具(通过 SDK RegisterTool 注册)
|
||||||
@ -531,6 +558,14 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string {
|
|||||||
if len(result.Entities) == 0 && len(result.Relations) == 0 {
|
if len(result.Entities) == 0 && len(result.Relations) == 0 {
|
||||||
return "未找到相关记忆"
|
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
|
var parts []string
|
||||||
parts = append(parts, fmt.Sprintf("找到 %d 个相关实体:", len(result.Entities)))
|
parts = append(parts, fmt.Sprintf("找到 %d 个相关实体:", len(result.Entities)))
|
||||||
for _, e := range 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)
|
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:
|
default:
|
||||||
return fmt.Sprintf("未知的记忆工具: %s", tc.Name)
|
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 {
|
func (a *Agent) executeKnowledgeTool(tc agentAPI.ToolCall) string {
|
||||||
if a.knowledge == nil {
|
if a.knowledge == nil {
|
||||||
return "知识库不可用"
|
return "知识库不可用"
|
||||||
@ -668,6 +895,8 @@ func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string {
|
|||||||
if len(docs) == 0 {
|
if len(docs) == 0 {
|
||||||
return "未找到相关文档记忆"
|
return "未找到相关文档记忆"
|
||||||
}
|
}
|
||||||
|
// 返回内容后从冷层移除,避免后续自动注入重复
|
||||||
|
a.docStore.Consume(query, topK)
|
||||||
var parts []string
|
var parts []string
|
||||||
for i, d := range docs {
|
for i, d := range docs {
|
||||||
parts = append(parts, fmt.Sprintf("[%d] %s (来源: %s)", i+1, d.Summary, d.Source))
|
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 != "" {
|
if a.pluginReg != nil && a.pluginDir != "" {
|
||||||
tools = append(tools, map[string]interface{}{
|
tools = append(tools, map[string]interface{}{
|
||||||
@ -926,7 +1255,7 @@ func (a *Agent) buildToolDefs() []interface{} {
|
|||||||
"type": "function",
|
"type": "function",
|
||||||
"function": map[string]interface{}{
|
"function": map[string]interface{}{
|
||||||
"name": "spawn_child",
|
"name": "spawn_child",
|
||||||
"description": "创建一个子 Agent 执行独立任务。子 Agent 使用传统上下文(无持久记忆),任务完成即销毁。适用于需要多步推理但不需要写入长期记忆的场景,例如:计算、分析、生成报告草稿等。",
|
"description": "启动一个异步子 Agent 执行独立任务。子 Agent 后台运行,不阻塞当前对话。完成后系统会自动通知你,届时请调用 child_result 工具查看输出。",
|
||||||
"parameters": map[string]interface{}{
|
"parameters": map[string]interface{}{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": map[string]interface{}{
|
"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{}{
|
tools = append(tools, map[string]interface{}{
|
||||||
@ -1370,14 +1748,30 @@ func (a *Agent) executePluginReload() string {
|
|||||||
return msg
|
return msg
|
||||||
}
|
}
|
||||||
|
|
||||||
// executeSpawnChild 创建子 Agent 执行独立任务
|
// executeSpawnChild 创建子 Agent 异步执行独立任务
|
||||||
// 子 Agent 使用传统上下文(单轮对话),无持久记忆,任务完即销毁
|
// 不阻塞主 Agent,子任务完成后通过 selfInputCh 通知主 Agent 查看结果
|
||||||
func (a *Agent) executeSpawnChild(tc agentAPI.ToolCall) string {
|
func (a *Agent) executeSpawnChild(tc agentAPI.ToolCall) string {
|
||||||
task, _ := tc.Arguments["task"].(string)
|
task, _ := tc.Arguments["task"].(string)
|
||||||
if task == "" {
|
if task == "" {
|
||||||
return "请提供 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 的子任务助手。
|
sysPrompt := fmt.Sprintf(`你是 HomeAgent 的子任务助手。
|
||||||
请完成以下任务。完成即可,无需保留记忆或查询历史。
|
请完成以下任务。完成即可,无需保留记忆或查询历史。
|
||||||
任务: %s`, task)
|
任务: %s`, task)
|
||||||
@ -1387,25 +1781,26 @@ func (a *Agent) executeSpawnChild(tc agentAPI.ToolCall) string {
|
|||||||
{Role: "user", Content: task},
|
{Role: "user", Content: task},
|
||||||
}
|
}
|
||||||
|
|
||||||
// 子 Agent 无特殊工具,只保留基础 tool 定义(无记忆/知识/文档工具)
|
// 子 Agent 可调用核心以外的全部工具(记忆/知识/文档/社交),但不能调用输出工具
|
||||||
childTools := []interface{}{
|
allTools := a.buildToolDefs()
|
||||||
map[string]interface{}{
|
childTools := make([]interface{}, 0, len(allTools))
|
||||||
"type": "function",
|
outputTools := map[string]bool{"output_send": true, "output_set_channel": true, "output_list_channels": true, "spawn_child": true, "plgreload": true}
|
||||||
"function": map[string]interface{}{
|
for _, t := range allTools {
|
||||||
"name": "output_send",
|
toolMap, ok := t.(map[string]interface{})
|
||||||
"description": "通过指定输出通道发送消息",
|
if !ok {
|
||||||
"parameters": map[string]interface{}{
|
continue
|
||||||
"type": "object",
|
}
|
||||||
"properties": map[string]interface{}{
|
fn, ok := toolMap["function"].(map[string]interface{})
|
||||||
"channel": map[string]interface{}{"type": "string", "description": "输出通道"},
|
if !ok {
|
||||||
"content": map[string]interface{}{"type": "string", "description": "消息内容"},
|
continue
|
||||||
},
|
}
|
||||||
"required": []string{"channel", "content"},
|
name, _ := fn["name"].(string)
|
||||||
},
|
if !outputTools[name] {
|
||||||
},
|
childTools = append(childTools, t)
|
||||||
},
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var finalResult string
|
||||||
for turn := 0; turn < 5; turn++ {
|
for turn := 0; turn < 5; turn++ {
|
||||||
req := &agentAPI.CompletionRequest{
|
req := &agentAPI.CompletionRequest{
|
||||||
Messages: msgs,
|
Messages: msgs,
|
||||||
@ -1419,33 +1814,112 @@ func (a *Agent) executeSpawnChild(tc agentAPI.ToolCall) string {
|
|||||||
|
|
||||||
resp, err := a.provider.Chat(a.ctx, req)
|
resp, err := a.provider.Chat(a.ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Sprintf("子 Agent 执行失败: %v", err)
|
finalResult = fmt.Sprintf("子 Agent 执行失败: %v", err)
|
||||||
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(resp.ToolCalls) == 0 {
|
if len(resp.ToolCalls) == 0 {
|
||||||
return resp.Content
|
finalResult = resp.Content
|
||||||
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, ct := range resp.ToolCalls {
|
for _, ct := range resp.ToolCalls {
|
||||||
var result string
|
var result string
|
||||||
if ct.Name == "output_send" {
|
switch {
|
||||||
channel, _ := ct.Arguments["channel"].(string)
|
case ct.Name == "output_send" || ct.Name == "output_set_channel" || ct.Name == "output_list_channels":
|
||||||
content, _ := ct.Arguments["content"].(string)
|
result = fmt.Sprintf("子 Agent 不允许调用输出工具: %s", ct.Name)
|
||||||
if channel != "" && content != "" {
|
case ct.Name == "spawn_child" || ct.Name == "plgreload":
|
||||||
a.io.EmitTextTo("child_agent", channel, content)
|
result = fmt.Sprintf("子 Agent 不允许调用系统工具: %s", ct.Name)
|
||||||
result = fmt.Sprintf("已通过 [%s] 通道发送", channel)
|
default:
|
||||||
} else {
|
result = a.executeToolCall(ct)
|
||||||
result = "channel 和 content 不能为空"
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
result = fmt.Sprintf("子 Agent 无法调用工具 %s", ct.Name)
|
|
||||||
}
|
}
|
||||||
msgs = append(msgs, agentAPI.Message{Role: "assistant", Content: resp.Content, ToolCalls: []agentAPI.ToolCall{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})
|
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(短路)
|
// runStage — 运行阶段管道,若插件 Response 被设置则返回 true(短路)
|
||||||
|
|||||||
@ -1,133 +1,433 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitcode.com/JianFeeeee/HomeAgent/pkg/types"
|
||||||
|
_ "github.com/mattn/go-sqlite3"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ConfigRegistry struct {
|
type ConfigRegistry struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
values map[string]interface{}
|
db *sql.DB
|
||||||
persistPath string
|
dbPath string
|
||||||
dirty bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewConfigRegistry(persistPath string) *ConfigRegistry {
|
func NewConfigRegistry(dbPath string) *ConfigRegistry {
|
||||||
r := &ConfigRegistry{
|
if dbPath == "" {
|
||||||
values: make(map[string]interface{}),
|
dbPath = ":memory:"
|
||||||
persistPath: persistPath,
|
|
||||||
}
|
}
|
||||||
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
|
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{}) {
|
func (r *ConfigRegistry) Register(key string, value interface{}) {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
defer r.mu.Unlock()
|
defer r.mu.Unlock()
|
||||||
if _, exists := r.values[key]; !exists {
|
r.db.Exec(`INSERT OR IGNORE INTO config (key, value) VALUES (?, ?)`, key, fmt.Sprint(value))
|
||||||
r.values[key] = value
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ConfigRegistry) RegisterDefault(key string, value interface{}) {
|
func (r *ConfigRegistry) RegisterDefault(key string, value interface{}) {
|
||||||
r.mu.Lock()
|
r.Register(key, value)
|
||||||
defer r.mu.Unlock()
|
|
||||||
if _, exists := r.values[key]; !exists {
|
|
||||||
r.values[key] = value
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ConfigRegistry) Get(key string) (interface{}, error) {
|
func (r *ConfigRegistry) Get(key string) (interface{}, error) {
|
||||||
r.mu.RLock()
|
r.mu.RLock()
|
||||||
defer r.mu.RUnlock()
|
defer r.mu.RUnlock()
|
||||||
v, ok := r.values[key]
|
var val string
|
||||||
if !ok {
|
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 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 {
|
func (r *ConfigRegistry) Set(key string, value interface{}) error {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
defer r.mu.Unlock()
|
defer r.mu.Unlock()
|
||||||
r.values[key] = value
|
_, err := r.db.Exec(`INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)`, key, fmt.Sprint(value))
|
||||||
r.dirty = true
|
return err
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ConfigRegistry) List(prefix string) []string {
|
func (r *ConfigRegistry) List(prefix string) []string {
|
||||||
r.mu.RLock()
|
r.mu.RLock()
|
||||||
defer r.mu.RUnlock()
|
defer r.mu.RUnlock()
|
||||||
var keys []string
|
var keys []string
|
||||||
for k := range r.values {
|
q := `SELECT key FROM config WHERE key LIKE ? ORDER BY key`
|
||||||
if prefix == "" || strings.HasPrefix(k, prefix) {
|
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)
|
keys = append(keys, k)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sort.Strings(keys)
|
|
||||||
return keys
|
return keys
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ConfigRegistry) Delete(key string) error {
|
func (r *ConfigRegistry) Delete(key string) error {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
defer r.mu.Unlock()
|
defer r.mu.Unlock()
|
||||||
delete(r.values, key)
|
_, err := r.db.Exec(`DELETE FROM config WHERE key = ?`, key)
|
||||||
r.dirty = true
|
return err
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ConfigRegistry) Dump() map[string]interface{} {
|
func (r *ConfigRegistry) Dump() map[string]interface{} {
|
||||||
r.mu.RLock()
|
r.mu.RLock()
|
||||||
defer r.mu.RUnlock()
|
defer r.mu.RUnlock()
|
||||||
cp := make(map[string]interface{})
|
result := make(map[string]interface{})
|
||||||
for k, v := range r.values {
|
rows, err := r.db.Query(`SELECT key, value FROM config ORDER BY key`)
|
||||||
cp[k] = v
|
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 {
|
func (r *ConfigRegistry) Flush() error {
|
||||||
r.mu.RLock()
|
if r.dbPath == "" || r.dbPath == ":memory:" {
|
||||||
if !r.dirty {
|
|
||||||
r.mu.RUnlock()
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
// SQLite 自动持久化;显式 checkpoint 确保一致性
|
||||||
|
r.mu.RLock()
|
||||||
|
_, err := r.db.Exec("PRAGMA wal_checkpoint(TRUNCATE)")
|
||||||
r.mu.RUnlock()
|
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()
|
r.mu.Lock()
|
||||||
defer r.mu.Unlock()
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("marshal config: %w", err)
|
return
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(r.persistPath, data, 0644); err != nil {
|
defer tx.Rollback()
|
||||||
return fmt.Errorf("write config: %w", err)
|
|
||||||
|
stmt, err := tx.Prepare(`INSERT OR IGNORE INTO config (key, value) VALUES (?, ?)`)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
r.dirty = false
|
defer stmt.Close()
|
||||||
return nil
|
|
||||||
|
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() {
|
// helpers — 所有值存为 TEXT,解析时自动转换
|
||||||
if r.persistPath == "" {
|
|
||||||
return
|
func (r *ConfigRegistry) GetString(key, defaultVal string) string {
|
||||||
}
|
r.mu.RLock()
|
||||||
data, err := os.ReadFile(r.persistPath)
|
defer r.mu.RUnlock()
|
||||||
|
var v string
|
||||||
|
err := r.db.QueryRow(`SELECT value FROM config WHERE key = ?`, key).Scan(&v)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return defaultVal
|
||||||
}
|
}
|
||||||
var vals map[string]interface{}
|
return v
|
||||||
if err := json.Unmarshal(data, &vals); err != nil {
|
}
|
||||||
return
|
|
||||||
}
|
func (r *ConfigRegistry) GetInt(key string, defaultVal int) int {
|
||||||
r.values = vals
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,11 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitcode.com/JianFeeeee/HomeAgent/pkg/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRegistryBasic(t *testing.T) {
|
func TestRegistryBasic(t *testing.T) {
|
||||||
@ -33,23 +35,25 @@ func TestRegistryBasic(t *testing.T) {
|
|||||||
|
|
||||||
func TestRegistryPersist(t *testing.T) {
|
func TestRegistryPersist(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
path := filepath.Join(dir, "settings.json")
|
path := filepath.Join(dir, "config.db")
|
||||||
|
|
||||||
r := NewConfigRegistry(path)
|
r := NewConfigRegistry(path)
|
||||||
r.Register("core.log_level", "debug")
|
r.Register("core.log_level", "debug")
|
||||||
r.Set("plugin.test.key", 42)
|
r.Set("plugin.test.key", "42")
|
||||||
if err := r.Flush(); err != nil {
|
if err := r.Flush(); err != nil {
|
||||||
t.Fatalf("Flush: %v", err)
|
t.Fatalf("Flush: %v", err)
|
||||||
}
|
}
|
||||||
|
r.Close()
|
||||||
|
|
||||||
r2 := NewConfigRegistry(path)
|
r2 := NewConfigRegistry(path)
|
||||||
val, err := r2.Get("plugin.test.key")
|
val, err := r2.Get("plugin.test.key")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Get after reload: %v", err)
|
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)
|
t.Fatalf("expected 42, got %v", val)
|
||||||
}
|
}
|
||||||
|
r2.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRegistryDelete(t *testing.T) {
|
func TestRegistryDelete(t *testing.T) {
|
||||||
@ -65,7 +69,7 @@ func TestRegistryDelete(t *testing.T) {
|
|||||||
|
|
||||||
func TestRegistryDump(t *testing.T) {
|
func TestRegistryDump(t *testing.T) {
|
||||||
r := NewConfigRegistry("")
|
r := NewConfigRegistry("")
|
||||||
r.Register("x", 1)
|
r.Register("x", "1")
|
||||||
r.Register("y", "two")
|
r.Register("y", "two")
|
||||||
dump := r.Dump()
|
dump := r.Dump()
|
||||||
if len(dump) != 2 {
|
if len(dump) != 2 {
|
||||||
@ -83,23 +87,171 @@ func TestRegistryUnknownKey(t *testing.T) {
|
|||||||
|
|
||||||
func TestRegistryFlush(t *testing.T) {
|
func TestRegistryFlush(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
path := filepath.Join(dir, "settings.json")
|
path := filepath.Join(dir, "config.db")
|
||||||
r := NewConfigRegistry(path)
|
r := NewConfigRegistry(path)
|
||||||
r.Set("k", "v")
|
r.Set("k", "v")
|
||||||
if err := r.Flush(); err != nil {
|
if err := r.Flush(); err != nil {
|
||||||
t.Fatalf("Flush: %v", err)
|
t.Fatalf("Flush: %v", err)
|
||||||
}
|
}
|
||||||
data, _ := os.ReadFile(path)
|
r.Close()
|
||||||
if len(data) == 0 {
|
|
||||||
t.Fatal("expected persisted data")
|
// 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) {
|
func TestRegistryFlushIdempotent(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
path := filepath.Join(dir, "settings.json")
|
path := filepath.Join(dir, "config.db")
|
||||||
r := NewConfigRegistry(path)
|
r := NewConfigRegistry(path)
|
||||||
r.Set("k", "v")
|
r.Set("k", "v")
|
||||||
r.Flush()
|
r.Flush()
|
||||||
r.Flush() // second flush should not error
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
82
internal/lua/adapters/anthropic.lua
Normal file
82
internal/lua/adapters/anthropic.lua
Normal file
@ -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
|
||||||
89
internal/lua/adapters/gemini.lua
Normal file
89
internal/lua/adapters/gemini.lua
Normal file
@ -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
|
||||||
73
internal/lua/adapters/github.lua
Normal file
73
internal/lua/adapters/github.lua
Normal file
@ -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
|
||||||
72
internal/lua/adapters/groq.lua
Normal file
72
internal/lua/adapters/groq.lua
Normal file
@ -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
|
||||||
72
internal/lua/adapters/mistral.lua
Normal file
72
internal/lua/adapters/mistral.lua
Normal file
@ -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
|
||||||
@ -182,12 +182,10 @@ func (r *Registry) RegisterPluginAPI(api *sdk.PluginAPI) error {
|
|||||||
return fmt.Errorf("sdk api %s already registered", api.Name)
|
return fmt.Errorf("sdk api %s already registered", api.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 注入 SettingsAPI(读取/修改核心与其他插件配置)
|
// 注入 SettingsAPI(插件作用域配置表 config_<name>)
|
||||||
if r.cfgReg != nil {
|
if r.cfgReg != nil {
|
||||||
api.SetSettings(&pluginSettings{
|
ps := r.cfgReg.PluginConfig(api.Name)
|
||||||
reg: r.cfgReg,
|
api.SetSettings(ps)
|
||||||
name: api.Name,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
r.sdkAPIs[api.Name] = &sdkAPI{
|
r.sdkAPIs[api.Name] = &sdkAPI{
|
||||||
@ -205,24 +203,6 @@ func (r *Registry) RegisterPluginAPI(api *sdk.PluginAPI) error {
|
|||||||
return nil
|
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 插件的工具定义
|
// GetAllSDKToolDefs 收集所有 SDK 插件的工具定义
|
||||||
func (r *Registry) GetAllSDKToolDefs() []sdk.ToolDef {
|
func (r *Registry) GetAllSDKToolDefs() []sdk.ToolDef {
|
||||||
r.mu.RLock()
|
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)
|
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 插件在指定阶段的处理器
|
// GetStageHandlers 获取所有 SDK 插件在指定阶段的处理器
|
||||||
func (r *Registry) GetStageHandlers(stage sdk.Stage) []sdk.StageHandler {
|
func (r *Registry) GetStageHandlers(stage sdk.Stage) []sdk.StageHandler {
|
||||||
r.mu.RLock()
|
r.mu.RLock()
|
||||||
|
|||||||
129
internal/plugins/test_deepseek/plugin.go
Normal file
129
internal/plugins/test_deepseek/plugin.go
Normal file
@ -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 = "REDACTED_API_KEY"
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
246
internal/plugins/test_deepseek/plugin_test.go
Normal file
246
internal/plugins/test_deepseek/plugin_test.go
Normal file
@ -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 REDACTED_API_KEY"
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -93,13 +93,24 @@ type OperationLog struct {
|
|||||||
Success bool `json:"success"`
|
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 {
|
type LLMConfig struct {
|
||||||
Provider string `json:"provider"`
|
Provider string `json:"provider"`
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
BaseURL string `json:"base_url"`
|
BaseURL string `json:"base_url"`
|
||||||
APIKey string `json:"api_key"`
|
APIKey string `json:"api_key"`
|
||||||
Temperature float64 `json:"temperature"`
|
Adapter string `json:"adapter"`
|
||||||
MaxTokens int `json:"max_tokens"`
|
Temperature float64 `json:"temperature"`
|
||||||
|
MaxTokens int `json:"max_tokens"`
|
||||||
|
Sources []LLMSource `json:"sources,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
|
|||||||
Reference in New Issue
Block a user