diff --git a/assets/docs/en/ARCHITECTURE.md b/assets/docs/en/ARCHITECTURE.md index 974611f..edd0518 100644 --- a/assets/docs/en/ARCHITECTURE.md +++ b/assets/docs/en/ARCHITECTURE.md @@ -10,7 +10,7 @@ HomeAgent's cognitive architecture consists of three subsystems: the event loop **The stage pipeline (StageHost)** manages two registration categories: tool definitions (ToolDef) and stage handlers (StageHandler). ToolDef includes two optional memory control fields: `NoMemory bool` — when true, the tool's output is excluded from vectorization/jieba/distillation (original text preserved); and `Cleaner func(string) string` — a filter applied before the output enters the computation layer (e.g., extracting a `content` field from JSON). Neither modifies the original output; both only affect the computation layer input. `RegisterTool` rejects duplicate names, infers the owning plugin name from the tool name prefix, and maintains a `toolPlugins` mapping. `RegisterStage` appends handlers to the corresponding stage list. On stage execution (`RunStage`), **all registered handlers execute in parallel via goroutines**, sharing a single `*StageContext` protected by `sync.RWMutex`. Individual handler panics are recovered independently without affecting other handlers. Short-circuit semantics are implemented by checking `ctx.Response != nil` — any stage handler can set this value to terminate the pipeline early. `ExecuteTool` includes built-in panic recovery with stack-trace recording. `UnregisterPluginTools` removes a plugin's tool set during hot-reload. -**The context window (RelevanceContext)** maintains a chronologically ordered event list. `Append` applies `CleanTemplateText` to strip QQ templates and timestamp noise before computing the embedding vector using a three-branch strategy (agent events use Response, user events use Input, cold_storage uses Input+Response). `Prune` triggers when the event count exceeds `topK`: it **unconditionally protects the last 10 events from eviction** (recency bias), scores remaining candidates against the current input via CosineSimilarity, keeps `topK - 10` highest-scoring entries (floor at 0), then re-sorts chronologically. Pruned events from sources other than `agentcli` and `terminal` are archived to the Document layer via `docStore.ContextToDoc`, retaining original timestamps. Persistence uses 5-second debounced writes to a JSON file. +**The context window (RelevanceContext)** maintains a chronologically ordered event list. `Append` aggregates tool outputs through `textForVector` before computing the embedding vector: `NoMemory` skips, `Cleaner` filters (per-tool cleaners registered by plugins — e.g. the QQ plugin strips its own tool-call templates), and `CleanText` finalizes with basic whitespace normalization. A three-branch strategy selects the text source (agent events use Response, user events use Input, cold_storage uses Input+Response). `Prune` triggers when the event count exceeds `topK`: it **unconditionally protects the last 10 events from eviction** (recency bias), scores remaining candidates against the current input via CosineSimilarity, keeps `topK - 10` highest-scoring entries (floor at 0), then re-sorts chronologically. Pruned events from sources other than `agentcli` and `terminal` are archived to the Document layer via `docStore.ContextToDoc`, retaining original timestamps. Persistence uses 5-second debounced writes to a JSON file. **Tool definitions are aggregated from five sources**: IOManager-registered plugin tools; StageHost-registered SDK tools; Indexer-provided memory index tools; conditionally added built-in tools (depending on non-nil state of memory/knowledge/docStore/social/pluginReg/providerManager modules — including memory operations, knowledge retrieval, document queries, social networking, plugin reloading, child-agent spawning, per-output-channel send tools, and LLM source switching); and media processing tools added based on `pendingMedia` state. `buildToolDefs()` re-aggregates all sources on each process cycle. @@ -145,7 +145,7 @@ All vectorization unified under `StaticEmbedder` (`internal/memory/static_embedd - Configured via `core.agent.embedding_model_path` (comma-separated multi-model) - Path containing `numberbatch` → auto-download ConceptNet; `cc.zh.` → fastText Chinese; `cc.en.` → fastText English - Falls back to ConceptNet by default if no match -- **Pre-processing**: `CleanTemplateText` strips QQ tool-call templates and timestamp noise +- **Pre-processing**: plugins register per-tool `Cleaner` functions; `textForVector` applies them before `CleanText` final normalization - **Three-branch vector source**: agent→Response, user→Input, cold_storage→Input+Response - **TF-IDF fallback**: auto-fallback to bag-of-words TF-IDF if model download fails or not configured @@ -160,7 +160,7 @@ All vectorization unified under `StaticEmbedder` (`internal/memory/static_embedd `internal/agent/core/context.go` — `RelevanceContext` - Maintains recent event list, writes JSON on each Append/Prune to prevent data loss -- Pre-vectorization pipeline runs through `CleanTemplateText` to remove template noise +- Pre-vectorization pipeline: per-tool `Cleaner` functions strip template noise, then `CleanText` for basic whitespace normalization - Three-branch `textForVector`: agent events → Response, user events → Input, cold_storage → Input+Response - Pretrained word embedding `StaticEmbedder` → CosineSimilarity, auto-fallback to TF-IDF if unavailable - Protects last 10 events from eviction; excess candidates are sorted by relevance and archived to document memory diff --git a/assets/docs/zh/ARCHITECTURE.md b/assets/docs/zh/ARCHITECTURE.md index 1299688..deb6ebd 100644 --- a/assets/docs/zh/ARCHITECTURE.md +++ b/assets/docs/zh/ARCHITECTURE.md @@ -10,7 +10,7 @@ HomeAgent 的认知架构由三个核心子系统构成:事件循环(eventLo **阶段管道(StageHost)** 管理两类注册:工具定义(ToolDef)与阶段处理器(StageHandler)。ToolDef 包含 `NoMemory bool` 和 `Cleaner func(string) string` 两个可选的记忆控制字段:`NoMemory=true` 时工具输出不参与向量化/jieba/蒸馏计算(原文保留);`Cleaner` 在输出进入计算层前执行过滤(如提取 JSON 的 `content` 字段)。两者均不修改原文,只影响计算层输入。`RegisterTool` 拒绝同名注册,推断工具所属插件名,并维护工具到插件的映射表 `toolPlugins`。`RegisterStage` 将处理器追加至对应阶段的处理器列表。触发阶段执行时(`RunStage`),**所有已注册处理器通过 goroutine 并行执行**,共享同一 `*StageContext` 实例(通过 `sync.RWMutex` 保护并发访问)。单个处理器的 panic 被独立恢复,不影响其他处理器。短路语义通过检查 `ctx.Response != nil` 实现——任一阶段处理器可设置此值提前终止当前链路。工具执行 `ExecuteTool` 内置 panic 恢复与栈追踪记录。`UnregisterPluginTools` 在插件热重载时移除对应工具集。 -**上下文窗口(RelevanceContext)** 维护一个按时间排序的事件列表。`Append` 在录入前经 `CleanTemplateText` 剥离 QQ 模板与时间戳噪声,再通过三分支向量策略(agent 事件用 Response,用户事件用 Input,cold_storage 用 Input+Response)计算嵌入向量。`Prune` 在事件数超过 `topK` 时触发,**无条件保护最近 10 条事件不被裁剪**(recency bias),对剩余候选事件计算与当前输入的 CosineSimilarity,按评分降序保留 `topK - 10` 条(下限为 0),之后按时间戳重排序。裁剪出的事件中,过滤掉 `agentcli` 和 `terminal` 来源后,其余通过 `docStore.ContextToDoc` 归档至 Document 层,保留原始时间戳。持久化采用 5 秒防抖写入磁盘 JSON 文件。 +**上下文窗口(RelevanceContext)** 维护一个按时间排序的事件列表。`Append` 在录入前经 `textForVector` 聚合工具输出:按 `NoMemory` 跳过、`Cleaner` 过滤(插件为各自工具注册的清洗函数,如 QQ 外置插件剥离工具调用模板),最后经 `CleanText` 做基本空白规范化,再通过三分支向量策略(agent 事件用 Response,用户事件用 Input,cold_storage 用 Input+Response)计算嵌入向量。`Prune` 在事件数超过 `topK` 时触发,**无条件保护最近 10 条事件不被裁剪**(recency bias),对剩余候选事件计算与当前输入的 CosineSimilarity,按评分降序保留 `topK - 10` 条(下限为 0),之后按时间戳重排序。裁剪出的事件中,过滤掉 `agentcli` 和 `terminal` 来源后,其余通过 `docStore.ContextToDoc` 归档至 Document 层,保留原始时间戳。持久化采用 5 秒防抖写入磁盘 JSON 文件。 **工具定义聚合自五个来源**:IOManager 注册的插件工具;StageHost 注册的 SDK 插件工具;Indexer 提供的记忆索引工具;内置条件工具(依据 memory / knowledge / docStore / social / pluginReg / providerManager 等模块的非空状态选择性添加,包括记忆操作、知识检索、文档查询、社交网络、插件重载、子代理生成、输出通道工具、LLM 源切换等);以及按 `pendingMedia` 状态添加的媒体处理工具。`buildToolDefs()` 在每次 process 周期中重新聚合所有这些来源。 @@ -88,7 +88,7 @@ eventLoop() → processTextInput() ``` ① Context (工作窗口) RelevanceContext — 内存 events[] + JSON持久化 - Append: 每次输入, CleanTemplateText → 三分支向量(textForVector) + Append: 每次输入, CleanText → 三分支向量(textForVector) agent事件→Response, 用户事件→Input, cold_storage→Input+Response StaticEmbedder 预训练词嵌入 / TF-IDF 回退 Prune: StaticEmbedder CosineSimilarity, 保留 topK + 最近10条 @@ -115,7 +115,7 @@ eventLoop() → processTextInput() 写入: memory_commit / 冷文档蒸馏 / Pipeline 规则蒸馏 / memory_merge 读取: ├── 自动召回: Indexer.BuildContext(input) - │ → CleanTemplateText → 向量实体搜索 + jieba关键词 → SQLite LIKE + BFS depth=2 + │ → CleanText → 向量实体搜索 + jieba关键词 → SQLite LIKE + BFS depth=2 │ → 【记忆索引】→ system prompt └── LLM主动: memory_recall / memory_merge / memory_purge / memory_edit / memory_delete_entity Social: person_query / set_trait / relate (包装 GraphDB) @@ -145,7 +145,7 @@ eventLoop() → processTextInput() - 通过 `core.agent.embedding_model_path` 配置(逗号分隔多模型) - 路径名含 `numberbatch` → 自动下载 ConceptNet,含 `cc.zh.` → fastText 中文,含 `cc.en.` → fastText 英文 - 不匹配则默认 ConceptNet -- **前处理**:`CleanTemplateText` 剥离 QQ 工具调用模版、时间戳噪声,避免垃圾干扰相似度 +- **前处理**:`textForVector` 聚合工具输出时逐一应用各工具的 `Cleaner`(由插件注册),最后经 `CleanText` 做基本空白规范化 - **三分支向量来源**:agent→Response,用户→Input,cold_storage→Input+Response - **TF-IDF 回退**:模型下载失败或未配置时自动回退词袋 TF-IDF,服务不中断 @@ -160,7 +160,7 @@ eventLoop() → processTextInput() `internal/agent/core/context.go` — `RelevanceContext` - 维护最近事件列表,每次 Append/Prune 写入 JSON 防丢 -- 向量化前统一经 `CleanTemplateText` 去模版噪声 +- 向量化前统一经 `CleanText` 去模版噪声 - 三分支 `textForVector`:agent 事件用 Response、用户事件用 Input、cold_storage 用 Input+Response - 预训练词嵌入 `StaticEmbedder` → CosineSimilarity,模型不可用时自动回退 TF-IDF - 保护最近 10 条记录免于淘汰,超出部分按相关性排序归档到文档记忆 @@ -240,9 +240,7 @@ Agent ▼ Provider 接口 (Name / Chat / ChatStream) │ - ├── OpenAIProvider — 标准 OpenAI API - ├── OllamaProvider — 本地 Ollama - └── LuaAdaptedProvider (主要) + └── LuaAdaptedProvider (唯一实现) ├── 序列化 CompletionRequest → JSON ├── adapter.transform_request() → API 格式 ├── HTTP 请求 + adapter.headers diff --git a/cmd/homed/main.go b/cmd/homed/main.go index b251a81..8a139a9 100644 --- a/cmd/homed/main.go +++ b/cmd/homed/main.go @@ -278,11 +278,12 @@ func main() { key = baseAPIKey } luaProvider := agentAPI.NewLuaAdaptedProvider(agentAPI.BaseConfig{ - Model: src.Model, - BaseURL: src.BaseURL, - APIKey: key, - Temperature: cfg.LLM.Temperature, - MaxTokens: cfg.LLM.MaxTokens, + Model: src.Model, + BaseURL: src.BaseURL, + APIKey: key, + Temperature: cfg.LLM.Temperature, + MaxTokens: cfg.LLM.MaxTokens, + ContextWindow: src.ContextWindow, }, luaVM, src.Adapter) providerMgr.Register(src.Name, luaProvider) } diff --git a/go.sum b/go.sum index de5505a..b16dff9 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ gitcode.com/JianFeeeee/homeagent-sdk v0.7.1 h1:2XEtUgV200uOqbGGEiKT5QyBmZ5aIfNiw gitcode.com/JianFeeeee/homeagent-sdk v0.7.1/go.mod h1:G48Rgpw9ReTkCf0qBHf50jb5CSeNR2c4OWcgcEm0plo= github.com/mattn/go-sqlite3 v1.14.48 h1:7XHIgl0a8HwOaiK4E47ozLkST78rR9+OtNGx27D/TFs= github.com/mattn/go-sqlite3 v1.14.48/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w= +github.com/yalue/onnxruntime_go v1.13.0 h1:5HDXHon3EukQMyYA7yPMed/raWaDE/gjwLOwnVoiwy8= +github.com/yalue/onnxruntime_go v1.13.0/go.mod h1:b4X26A8pekNb1ACJ58wAXgNKeUCGEAQ9dmACut9Sm/4= github.com/yanyiwu/gojieba v1.4.7 h1:2YkXELcYLTE0SJetq6xv4MjpEikWga6VpFn4jIFFQ/k= github.com/yanyiwu/gojieba v1.4.7/go.mod h1:JUq4DddFVGdHXJHxxepxRmhrKlDpaBxR8O28v6fKYLY= github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA= diff --git a/internal/agent/api/provider.go b/internal/agent/api/provider.go index 05ca0ab..e4197d2 100644 --- a/internal/agent/api/provider.go +++ b/internal/agent/api/provider.go @@ -84,6 +84,7 @@ type CompletionRequest struct { Stream bool `json:"stream,omitempty"` Tools []interface{} `json:"tools,omitempty"` ToolChoice interface{} `json:"tool_choice,omitempty"` + DisableThinking bool `json:"disable_thinking"` ExtraBody map[string]interface{} `json:"-"` } @@ -192,236 +193,12 @@ func ModelContextWindow(model string) int { } type BaseConfig struct { - Model string `json:"model"` - BaseURL string `json:"base_url"` - APIKey string `json:"api_key"` - Temperature float64 `json:"temperature"` - MaxTokens int `json:"max_tokens"` -} - -type OpenAIProvider struct { - cfg BaseConfig - client *http.Client -} - -func NewOpenAIProvider(cfg BaseConfig) *OpenAIProvider { - if cfg.BaseURL == "" { - cfg.BaseURL = "https://api.openai.com/v1" - } - if cfg.Temperature == 0 { - cfg.Temperature = 0.7 - } - if cfg.MaxTokens == 0 { - cfg.MaxTokens = 4096 - } - return &OpenAIProvider{ - cfg: cfg, - client: &http.Client{Timeout: 60 * time.Second}, - } -} - -func (p *OpenAIProvider) Name() string { return "openai" } - -func (p *OpenAIProvider) Chat(ctx context.Context, req *CompletionRequest) (*CompletionResponse, error) { - if req.Model == "" { - req.Model = p.cfg.Model - } - - body, _ := json.Marshal(req) - httpReq, _ := http.NewRequestWithContext(ctx, "POST", p.cfg.BaseURL+"/chat/completions", strings.NewReader(string(body))) - httpReq.Header.Set("Content-Type", "application/json") - httpReq.Header.Set("Authorization", "Bearer "+p.cfg.APIKey) - - resp, err := p.client.Do(httpReq) - if err != nil { - return nil, fmt.Errorf("api call: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - respBody, _ := io.ReadAll(resp.Body) - return nil, &ProviderError{ - StatusCode: resp.StatusCode, - Message: fmt.Sprintf("api error %d: %s", resp.StatusCode, string(respBody)), - } - } - - var rawResult struct { - Choices []struct { - Message struct { - Content *string `json:"content"` - ReasoningContent *string `json:"reasoning_content"` - ToolCalls []rawToolCall `json:"tool_calls"` - Role string `json:"role"` - } `json:"message"` - FinishReason string `json:"finish_reason"` - } `json:"choices"` - Usage struct { - PromptTokens int `json:"prompt_tokens"` - CompletionTokens int `json:"completion_tokens"` - TotalTokens int `json:"total_tokens"` - } `json:"usage"` - } - - if err := json.NewDecoder(resp.Body).Decode(&rawResult); err != nil { - return nil, fmt.Errorf("decode: %w", err) - } - - if len(rawResult.Choices) == 0 { - return nil, fmt.Errorf("no choices returned") - } - - ch := rawResult.Choices[0] - content := "" - if ch.Message.Content != nil { - content = *ch.Message.Content - } - - var toolCalls []ToolCall - for _, tc := range ch.Message.ToolCalls { - tc := tc - args := make(map[string]interface{}) - if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil { - args["_raw"] = tc.Function.Arguments - } - toolCalls = append(toolCalls, ToolCall{ - ID: tc.ID, - Type: tc.Type, - Name: tc.Function.Name, - Arguments: args, - }) - } - - return &CompletionResponse{ - Content: content, - FinishReason: ch.FinishReason, - TokenUsage: TokenUsage{ - Prompt: rawResult.Usage.PromptTokens, - Completion: rawResult.Usage.CompletionTokens, - Total: rawResult.Usage.TotalTokens, - }, - ToolCalls: toolCalls, - }, nil -} - -func (p *OpenAIProvider) ChatStream(ctx context.Context, req *CompletionRequest) (<-chan StreamChunk, error) { - req.Stream = true - ch := make(chan StreamChunk, 64) - - body, _ := json.Marshal(req) - httpReq, _ := http.NewRequestWithContext(ctx, "POST", p.cfg.BaseURL+"/chat/completions", strings.NewReader(string(body))) - httpReq.Header.Set("Content-Type", "application/json") - httpReq.Header.Set("Authorization", "Bearer "+p.cfg.APIKey) - - resp, err := p.client.Do(httpReq) - if err != nil { - return nil, fmt.Errorf("stream api: %w", err) - } - - go func() { - defer resp.Body.Close() - defer close(ch) - - decoder := json.NewDecoder(resp.Body) - for { - var line struct { - Choices []struct { - Delta struct { - Content string `json:"content"` - } `json:"delta"` - FinishReason *string `json:"finish_reason"` - } `json:"choices"` - } - - if err := decoder.Decode(&line); err != nil { - return - } - - if len(line.Choices) > 0 { - select { - case ch <- StreamChunk{ - Content: line.Choices[0].Delta.Content, - Done: line.Choices[0].FinishReason != nil, - }: - case <-ctx.Done(): - return - } - } - } - }() - - return ch, nil -} - -type OllamaProvider struct { - cfg BaseConfig - client *http.Client -} - -func NewOllamaProvider(cfg BaseConfig) *OllamaProvider { - if cfg.BaseURL == "" { - cfg.BaseURL = "http://localhost:11434" - } - if cfg.Temperature == 0 { - cfg.Temperature = 0.7 - } - if cfg.MaxTokens == 0 { - cfg.MaxTokens = 4096 - } - return &OllamaProvider{ - cfg: cfg, - client: &http.Client{Timeout: 120 * time.Second}, - } -} - -func (p *OllamaProvider) Name() string { return "ollama" } - -func (p *OllamaProvider) Chat(ctx context.Context, req *CompletionRequest) (*CompletionResponse, error) { - ollamaReq := map[string]interface{}{ - "model": req.Model, - "messages": req.Messages, - "stream": false, - "options": map[string]interface{}{ - "temperature": req.Temperature, - "num_predict": req.MaxTokens, - }, - } - - body, _ := json.Marshal(ollamaReq) - httpReq, _ := http.NewRequestWithContext(ctx, "POST", p.cfg.BaseURL+"/api/chat", strings.NewReader(string(body))) - httpReq.Header.Set("Content-Type", "application/json") - - resp, err := p.client.Do(httpReq) - if err != nil { - return nil, fmt.Errorf("ollama chat: %w", err) - } - defer resp.Body.Close() - - var result struct { - Message struct { - Content string `json:"content"` - } `json:"message"` - DoneReason string `json:"done_reason"` - } - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, fmt.Errorf("decode: %w", err) - } - - return &CompletionResponse{ - Content: result.Message.Content, - FinishReason: result.DoneReason, - }, nil -} - -func (p *OllamaProvider) ChatStream(ctx context.Context, req *CompletionRequest) (<-chan StreamChunk, error) { - ch := make(chan StreamChunk, 64) - - go func() { - defer close(ch) - ch <- StreamChunk{Done: true} - }() - - return ch, nil + Model string `json:"model"` + BaseURL string `json:"base_url"` + APIKey string `json:"api_key"` + Temperature float64 `json:"temperature"` + MaxTokens int `json:"max_tokens"` + ContextWindow int `json:"context_window"` } // LuaAdaptedProvider 使用 Lua 脚本做请求/响应变换,直接发起 HTTP 调用 @@ -450,15 +227,10 @@ func NewLuaAdaptedProvider(cfg BaseConfig, vm *luaVM.VM, adapter string) *LuaAda } } -func (p *OpenAIProvider) MaxContextTokens() int { - return ModelContextWindow(p.cfg.Model) -} - -func (p *OllamaProvider) MaxContextTokens() int { - return ModelContextWindow(p.cfg.Model) -} - func (p *LuaAdaptedProvider) MaxContextTokens() int { + if p.cfg.ContextWindow > 0 { + return p.cfg.ContextWindow + } return ModelContextWindow(p.cfg.Model) } @@ -642,6 +414,7 @@ func (s *SSEScanner) Text() string { return s.pending } type providerStatus struct { failCount int unavailableUntil time.Time + permanent bool // 401/403 永久不可用,不自动恢复 } type ProviderManager struct { @@ -748,12 +521,23 @@ func (m *ProviderManager) MarkUnavailable(name string) { st.unavailableUntil = time.Now().Add(cooldown) } -// ReportStatus records an HTTP status code for a provider, allowing auth errors -// (401/403) to be distinguished from transient failures. +// ReportStatus records an HTTP status code for a provider. +// 401/403 = credential error → permanently unavailable (never retry). +// Other codes → MarkUnavailable with exponential backoff. func (m *ProviderManager) ReportStatus(name string, statusCode int) { if statusCode == 401 || statusCode == 403 { - m.MarkUnavailable(name) + m.mu.Lock() + defer m.mu.Unlock() + st := m.status[name] + if st == nil { + st = &providerStatus{} + m.status[name] = st + } + st.permanent = true + st.unavailableUntil = time.Date(9999, 1, 1, 0, 0, 0, 0, time.UTC) + return } + m.MarkUnavailable(name) } func (m *ProviderManager) ResetAvailability(name string) { @@ -762,6 +546,18 @@ func (m *ProviderManager) ResetAvailability(name string) { delete(m.status, name) } +func (m *ProviderManager) MarkPermanent(name string) { + m.mu.Lock() + defer m.mu.Unlock() + st := m.status[name] + if st == nil { + st = &providerStatus{} + m.status[name] = st + } + st.permanent = true + st.unavailableUntil = time.Date(9999, 1, 1, 0, 0, 0, 0, time.UTC) +} + func (m *ProviderManager) IsAvailable(name string) bool { m.mu.RLock() defer m.mu.RUnlock() @@ -769,6 +565,9 @@ func (m *ProviderManager) IsAvailable(name string) bool { if !ok { return true } + if st.permanent { + return false + } return time.Now().After(st.unavailableUntil) } diff --git a/internal/agent/core/media.go b/internal/agent/core/media.go index d67b5bf..84cf585 100644 --- a/internal/agent/core/media.go +++ b/internal/agent/core/media.go @@ -53,7 +53,7 @@ func (a *Agent) mediaRequest(p agentAPI.Provider, mime, emptyPendingMsg, emptyDa } func (a *Agent) mediaChat(p agentAPI.Provider, msg agentAPI.Message, resultPrefix string, maxTokens int) string { - ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + ctx, cancel := context.WithTimeout(a.ctx, 120*time.Second) defer cancel() resp, err := p.Chat(ctx, &agentAPI.CompletionRequest{ Messages: []agentAPI.Message{msg}, diff --git a/internal/agent/core/process.go b/internal/agent/core/process.go index 8013df0..94389de 100644 --- a/internal/agent/core/process.go +++ b/internal/agent/core/process.go @@ -60,16 +60,12 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri }) } - eb := map[string]interface{}{} - if !a.thinkingEnabled { - eb["thinking"] = map[string]interface{}{"type": "disabled"} - } req := &agentAPI.CompletionRequest{ - Messages: msgs, - MaxTokens: 4096, - Tools: tools, - ToolChoice: "auto", - ExtraBody: eb, + Messages: msgs, + MaxTokens: 4096, + Tools: tools, + ToolChoice: "auto", + DisableThinking: !a.thinkingEnabled, } var providers []agentAPI.Provider diff --git a/internal/agent/core/spawn.go b/internal/agent/core/spawn.go index 788293a..6b63c18 100644 --- a/internal/agent/core/spawn.go +++ b/internal/agent/core/spawn.go @@ -60,16 +60,12 @@ func (a *Agent) runChildTask(taskID, task string) { var finalResult string for turn := 0; turn < 5; turn++ { - eb := map[string]interface{}{} - if !a.thinkingEnabled { - eb["thinking"] = map[string]interface{}{"type": "disabled"} - } req := &agentAPI.CompletionRequest{ - Messages: msgs, - MaxTokens: 4096, - Tools: childTools, - ToolChoice: "auto", - ExtraBody: eb, + Messages: msgs, + MaxTokens: 4096, + Tools: childTools, + ToolChoice: "auto", + DisableThinking: !a.thinkingEnabled, } resp, err := a.provider.Chat(a.ctx, req) diff --git a/internal/config/registry.go b/internal/config/registry.go index 90008a5..6a3c9cd 100644 --- a/internal/config/registry.go +++ b/internal/config/registry.go @@ -608,6 +608,7 @@ func (r *ConfigRegistry) ToConfig() *types.Config { APIKey: read(p+".api_key", ""), Adapter: read(p+".adapter", ""), AdapterPath: read(p+".adapter_path", ""), + ContextWindow: readInt(p+".context_window", 0), ThinkingEnabled: readBool(p+".thinking_enabled", false), }) } diff --git a/internal/lua/adapters/anthropic.lua b/internal/lua/adapters/anthropic.lua index 7e59034..56ad3ab 100644 --- a/internal/lua/adapters/anthropic.lua +++ b/internal/lua/adapters/anthropic.lua @@ -7,7 +7,6 @@ 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 @@ -28,6 +27,11 @@ function adapter.transform_request(raw_body) messages = msgs, stream = req.stream or false, } + + if not req.disable_thinking then + anthropic_req.thinking = { type = "enabled", budget_tokens = 4096 } + end + if system ~= "" then anthropic_req.system = system end diff --git a/internal/lua/adapters/deepseek.lua b/internal/lua/adapters/deepseek.lua index 81476af..8024685 100644 --- a/internal/lua/adapters/deepseek.lua +++ b/internal/lua/adapters/deepseek.lua @@ -5,12 +5,16 @@ adapter.version = "2.0.0" adapter.endpoint = "/chat/completions" adapter.headers = {} --- DeepSeek 格式与 OpenAI 兼容,thinking 模式由 Go 端 ExtraBody 控制 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 "deepseek-chat" req.stream = req.stream or false + if req.disable_thinking then + req.extra_body = req.extra_body or {} + req.extra_body.thinking = { type = "disabled" } + end + req.disable_thinking = nil return json.encode(req) end diff --git a/internal/lua/adapters/github.lua b/internal/lua/adapters/github.lua index 09456b3..75d121f 100644 --- a/internal/lua/adapters/github.lua +++ b/internal/lua/adapters/github.lua @@ -14,6 +14,8 @@ function adapter.transform_request(raw_body) req.temperature = req.temperature or 0.7 req.max_tokens = req.max_tokens or 4096 req.stream = req.stream or false + req.disable_thinking = nil + req.extra_body = nil return json.encode(req) end diff --git a/internal/lua/adapters/groq.lua b/internal/lua/adapters/groq.lua index 19d104b..1b2780f 100644 --- a/internal/lua/adapters/groq.lua +++ b/internal/lua/adapters/groq.lua @@ -13,6 +13,8 @@ function adapter.transform_request(raw_body) req.temperature = req.temperature or 0.7 req.max_tokens = req.max_tokens or 4096 req.stream = req.stream or false + req.disable_thinking = nil + req.extra_body = nil return json.encode(req) end diff --git a/internal/lua/adapters/mistral.lua b/internal/lua/adapters/mistral.lua index 63a02fa..b98c2c4 100644 --- a/internal/lua/adapters/mistral.lua +++ b/internal/lua/adapters/mistral.lua @@ -13,6 +13,8 @@ function adapter.transform_request(raw_body) req.temperature = req.temperature or 0.7 req.max_tokens = req.max_tokens or 4096 req.stream = req.stream or false + req.disable_thinking = nil + req.extra_body = nil return json.encode(req) end diff --git a/internal/lua/adapters/openai.lua b/internal/lua/adapters/openai.lua index 288662f..2abc27f 100644 --- a/internal/lua/adapters/openai.lua +++ b/internal/lua/adapters/openai.lua @@ -5,14 +5,15 @@ adapter.version = "2.0.0" adapter.endpoint = "/chat/completions" adapter.headers = {} --- raw_body: JSON string as received from Go (CompletionRequest marshalled) --- return: transformed JSON string to send to API +-- OpenAI /chat/completions format (pass-through, strip disable_thinking) function adapter.transform_request(raw_body) - return raw_body + local ok, req = pcall(json.decode, raw_body) + if not ok then return raw_body end + req.disable_thinking = nil + req.extra_body = nil + return json.encode(req) end --- raw_body: JSON string from HTTP response body --- return: unified JSON string in CompletionResponse format function adapter.transform_response(raw_body) local ok, resp = pcall(json.decode, raw_body) if not ok then return raw_body end @@ -57,8 +58,6 @@ function adapter.transform_response(raw_body) return json.encode(unified) end --- raw_chunk: single SSE data line (after "data: " prefix) --- return: unified chunk JSON string, or "" to skip function adapter.transform_stream_chunk(raw_chunk) local ok, chunk = pcall(json.decode, raw_chunk) if not ok then return "" end diff --git a/internal/memory/clean_stress_test.go b/internal/memory/clean_stress_test.go index 5b18f09..6fb3833 100644 --- a/internal/memory/clean_stress_test.go +++ b/internal/memory/clean_stress_test.go @@ -7,8 +7,8 @@ import ( "testing" ) -// cleanQQTemplate 模拟之前由 globalTextCleaner 执行的模板噪音清理, -// 用于 stress test 中生成 cleanedText。 +// cleanQQTemplate 剥离 QQ 工具调用模板与时间戳噪声。 +// 仅用于测试——生产环境中由 QQ 外置插件的工具 Cleaner 完成。 func cleanQQTemplate(text string) string { reQQGroupSuffix := regexp.MustCompile(`,通过id\d+使用qq_get_message工具获取消息正文。获取内容后使用 output_send\(channel="qq"\) 回复该群聊,content 设为 JSON 字符串:\{[^}]*\}`) reQQPrivateSuffix := regexp.MustCompile(`,通过id\d+使用qq_get_message工具获取消息正文。获取内容后使用 output_send\(channel="qq"\) 回复对方,content 设为 JSON 字符串:\{[^}]*\}`) diff --git a/internal/memory/cut.go b/internal/memory/cut.go index 94dd1ef..ccda389 100644 --- a/internal/memory/cut.go +++ b/internal/memory/cut.go @@ -39,27 +39,31 @@ func GetJieba() *gojieba.Jieba { } func jiebaDictDir() string { + // GOMODCACHE is typically $GOPATH/pkg/mod. When set, Go writes modules + // under /github.com/... . Look first at GOMODCACHE, then + // derive from GOPATH, then try common locations. candidates := []string{ os.Getenv("GOMODCACHE"), - os.Getenv("GOPATH"), - filepath.Join(os.Getenv("HOME"), "go"), - "/root/go", - "/go", - "/home/program/go", } + if gp := os.Getenv("GOPATH"); gp != "" { + candidates = append(candidates, filepath.Join(gp, "pkg", "mod")) + } + if home, err := os.UserHomeDir(); err == nil && home != "" { + candidates = append(candidates, filepath.Join(home, "go", "pkg", "mod")) + } + if h := os.Getenv("HOME"); h != "" { + candidates = append(candidates, filepath.Join(h, "go", "pkg", "mod")) + } + + suffix := filepath.Join("github.com", "yanyiwu", "gojieba@v1.4.7", "deps", "cppjieba", "dict") for _, base := range candidates { if base == "" { continue } - d := filepath.Join(base, "pkg", "mod", "github.com", "yanyiwu", "gojieba@v1.4.7", "deps", "cppjieba", "dict") + d := filepath.Join(base, suffix) if info, err := os.Stat(d); err == nil && info.IsDir() { return d } - // also try without "pkg/mod" (in case GOPATH is already the mod cache) - d2 := filepath.Join(base, "github.com", "yanyiwu", "gojieba@v1.4.7", "deps", "cppjieba", "dict") - if info, err := os.Stat(d2); err == nil && info.IsDir() { - return d2 - } } return "" } diff --git a/internal/nlp/download.go b/internal/nlp/download.go deleted file mode 100644 index 14a2bb2..0000000 --- a/internal/nlp/download.go +++ /dev/null @@ -1,103 +0,0 @@ -package nlp - -import ( - "crypto/md5" - "fmt" - "io" - "log" - "net/http" - "os" - "path/filepath" -) - -// ModelSource 模型来源:本地路径或远程 URL -type ModelSource struct { - Path string // 本地路径(优先) - URL string // 远程下载地址 -} - -// EnsureModel 确保模型文件存在,返回最终路径 -func EnsureModel(dstDir string, src ModelSource, filename string) (string, error) { - if err := os.MkdirAll(dstDir, 0755); err != nil { - return "", fmt.Errorf("create dir %s: %w", dstDir, err) - } - - dst := filepath.Join(dstDir, filename) - - // 1. 本地路径优先 - if src.Path != "" { - if _, err := os.Stat(src.Path); err == nil { - if err := copyFile(src.Path, dst); err != nil { - return "", fmt.Errorf("copy from %s: %w", src.Path, err) - } - log.Printf("[nlp] model ready (local): %s", dst) - return dst, nil - } - log.Printf("[nlp] local path %s not found, trying remote...", src.Path) - } - - // 2. 远程下载 - if src.URL != "" { - if _, err := os.Stat(dst); err == nil { - return dst, nil // 已存在 - } - log.Printf("[nlp] downloading model from %s ...", src.URL) - if err := downloadFile(dst, src.URL); err != nil { - return "", fmt.Errorf("download from %s: %w", src.URL, err) - } - return dst, nil - } - - return "", fmt.Errorf("model not found: no local path or remote URL") -} - -func downloadFile(dst, url string) error { - tmp := dst + ".download." + fmt.Sprintf("%x", md5.Sum([]byte(url))) - - resp, err := http.Get(url) - if err != nil { - return fmt.Errorf("http get %s: %w", url, err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("http status %s", resp.Status) - } - - f, err := os.Create(tmp) - if err != nil { - return fmt.Errorf("create temp %s: %w", tmp, err) - } - - written, err := io.Copy(f, resp.Body) - f.Close() - if err != nil { - os.Remove(tmp) - return fmt.Errorf("write: %w", err) - } - - if err := os.Rename(tmp, dst); err != nil { - os.Remove(tmp) - return fmt.Errorf("rename: %w", err) - } - - log.Printf("[nlp] downloaded %d bytes to %s", written, dst) - return nil -} - -func copyFile(src, dst string) error { - in, err := os.Open(src) - if err != nil { - return err - } - defer in.Close() - - out, err := os.Create(dst) - if err != nil { - return err - } - defer out.Close() - - _, err = io.Copy(out, in) - return err -} diff --git a/internal/nlp/onnx.go b/internal/nlp/onnx.go index 4d0c9c6..b629b32 100644 --- a/internal/nlp/onnx.go +++ b/internal/nlp/onnx.go @@ -8,32 +8,35 @@ import ( "fmt" "os" "path/filepath" + "sync" - "gitcode.com/JianFeeeee/HomeAgent/internal/config" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory" ort "github.com/yalue/onnxruntime_go" ) //go:embed models/* var onnxModelFS embed.FS +const maxSeqLen = 128 + type ONNXParser struct { - rt *ort.AdvancedSession - vocab map[string]int64 + rt *ort.DynamicAdvancedSession + vocab map[string]int64 posVocab map[string]int64 - Release func() + close sync.Once } type ONNXConfig struct { - ModelPath string // 留空使用内嵌模型 - DataDir string // 模型解压/缓存目录 + ModelPath string + DataDir string } func NewONNXParser(cfg ONNXConfig) (*ONNXParser, error) { - vocab, err := loadJSONMap[int64]("models/vocab.json", onnxModelFS) + vocab, err := loadWordMap("models/vocab.json") if err != nil { return nil, fmt.Errorf("load vocab: %w", err) } - posVocab, err := loadJSONMap[int64]("models/pos_vocab.json", onnxModelFS) + posVocab, err := loadWordMap("models/pos_vocab.json") if err != nil { return nil, fmt.Errorf("load pos_vocab: %w", err) } @@ -46,83 +49,221 @@ func NewONNXParser(cfg ONNXConfig) (*ONNXParser, error) { } } - ort.SetSharedLibraryPath(findONNXRuntime()) + ort.SetSharedLibraryPath(libPath()) if err := ort.InitializeEnvironment(); err != nil { return nil, fmt.Errorf("init onnx env: %w", err) } - inputs := ort.NewInputDetails() - inputs.Append("input_ids", []int64{1, 128}) + inputNames := []string{"input_ids"} + outputNames := []string{"pos_logits", "head_logits", "rel_logits"} - outputs := ort.NewOutputDetails() - outputs.Append("pos_logits", []int64{1, 128, 18}) - outputs.Append("head_logits", []int64{1, 128, 128}) - outputs.Append("rel_logits", []int64{1, 128, 128, 18}) - - session, err := ort.NewAdvancedSession(modelPath, inputs, outputs, nil) + session, err := ort.NewDynamicAdvancedSession(modelPath, inputNames, outputNames, nil) if err != nil { - return nil, fmt.Errorf("create session: %w", err) - } - - release := func() { - session.Destroy() ort.DestroyEnvironment() + return nil, fmt.Errorf("create session: %w", err) } return &ONNXParser{ rt: session, vocab: vocab, posVocab: posVocab, - Release: release, }, nil } +func (p *ONNXParser) Close() error { + p.close.Do(func() { + p.rt.Destroy() + ort.DestroyEnvironment() + }) + return nil +} + func (p *ONNXParser) Parse(text string) (*ParseResult, error) { if text == "" { return &ParseResult{}, nil } - inputIDs := tokenize(text, p.vocab, 128) - inputIDs = padTo(inputIDs, 128) + x := memory.GetJieba() + if x == nil { + return nil, fmt.Errorf("jieba unavailable") + } + words := x.Cut(text, true) + if len(words) == 0 { + return &ParseResult{}, nil + } - inputTensor, err := ort.NewTensor(ort.NewShape(1, 128), inputIDs) + inIDs := p.wordsToIDs(words, maxSeqLen) + n := len(inIDs) - 1 // exclude + if n <= 0 { + return &ParseResult{}, nil + } + if n > len(words) { + n = len(words) + } + + padded := padTo(inIDs, maxSeqLen) + + inTensor, err := ort.NewTensor(ort.NewShape(1, maxSeqLen), padded) if err != nil { return nil, fmt.Errorf("create input tensor: %w", err) } - defer inputTensor.Destroy() + defer inTensor.Destroy() - outputs, err := p.rt.Call(inputTensor) - if err != nil { - return nil, fmt.Errorf("onnx call: %w", err) + outputs := make([]ort.Value, 3) + if err := p.rt.Run([]ort.Value{inTensor}, outputs); err != nil { + return nil, fmt.Errorf("onnx run: %w", err) } - rawPOS := outputs[0].GetData().([]float32) - rawHeads := outputs[1].GetData().([]float32) - rawRels := outputs[2].GetData().([]float32) + posOut, ok := outputs[0].(*ort.Tensor[float32]) + if !ok { + return nil, fmt.Errorf("pos output not Tensor[float32]") + } + headOut, ok := outputs[1].(*ort.Tensor[float32]) + if !ok { + return nil, fmt.Errorf("head output not Tensor[float32]") + } + relOut, ok := outputs[2].(*ort.Tensor[float32]) + if !ok { + return nil, fmt.Errorf("rel output not Tensor[float32]") + } + defer posOut.Destroy() + defer headOut.Destroy() + defer relOut.Destroy() - seqLen := actualLen(inputIDs) - tokens := idsToTokens(inputIDs[:seqLen], p.vocab) - pos := decodePOS(rawPOS, seqLen, p.posVocab) - heads := decodeHeads(rawHeads, seqLen) - rels := decodeRels(rawRels, seqLen) + posShape := posOut.GetShape() // [1, seq, posDim] + headShape := headOut.GetShape() // [1, seq, seq] + relShape := relOut.GetShape() // [1, seq, seq, relDim] - return &ParseResult{Tokens: tokens, POS: pos, Heads: heads, DepRels: rels}, nil + if len(posShape) < 3 || len(headShape) < 3 || len(relShape) < 4 { + return nil, fmt.Errorf("unexpected output ranks: pos=%d head=%d rel=%d", + len(posShape), len(headShape), len(relShape)) + } + + seqDim := int(headShape[1]) + posDim := int(posShape[2]) + relDim := int(relShape[3]) + + if n > seqDim { + n = seqDim + } + + rawPOS := posOut.GetData() + rawHeads := headOut.GetData() + rawRels := relOut.GetData() + + pos := decodePOS(rawPOS, n, posDim, p.posVocab) + heads := decodeHeads(rawHeads, n, seqDim) + rels := decodeRels(rawRels, n, seqDim, relDim, heads) + + return &ParseResult{ + Tokens: words[:n], + POS: pos, + Heads: heads, + DepRels: rels, + }, nil } -func loadJSONMap[T ~int64 | ~string](path string, fs embed.FS) (map[string]T, error) { - data, err := fs.ReadFile(path) +func (p *ONNXParser) wordsToIDs(words []string, maxLen int) []int64 { + ids := make([]int64, 0, maxLen) + if bos, ok := p.vocab[""]; ok { + ids = append(ids, bos) + } + for _, w := range words { + if len(ids) >= maxLen { + break + } + if id, ok := p.vocab[w]; ok { + ids = append(ids, id) + } else if unk, ok := p.vocab[""]; ok { + ids = append(ids, unk) + } + } + return ids +} + +func padTo(ids []int64, length int) []int64 { + for len(ids) < length { + ids = append(ids, 0) + } + return ids +} + +func decodePOS(raw []float32, n, posDim int, posVocab map[string]int64) []string { + rev := make(map[int64]string) + for k, v := range posVocab { + rev[v] = k + } + pos := make([]string, n) + for i := 0; i < n; i++ { + bestIdx := 0 + bestVal := float32(-1e9) + for j := 0; j < posDim; j++ { + if v := raw[i*posDim+j]; v > bestVal { + bestVal = v + bestIdx = j + } + } + if tag, ok := rev[int64(bestIdx)]; ok { + pos[i] = tag + } else { + pos[i] = "X" + } + } + return pos +} + +func decodeHeads(raw []float32, n, seqDim int) []int { + heads := make([]int, n) + for i := 0; i < n; i++ { + bestIdx := 0 + bestVal := float32(-1e9) + for j := 0; j < seqDim; j++ { + if v := raw[i*seqDim+j]; v > bestVal { + bestVal = v + bestIdx = j + } + } + heads[i] = bestIdx + } + return heads +} + +func decodeRels(raw []float32, n, seqDim, relDim int, heads []int) []string { + rels := make([]string, n) + stride := seqDim * relDim + for i := 0; i < n; i++ { + h := heads[i] + if h < 0 || h >= seqDim { + rels[i] = "dep" + continue + } + bestIdx := 0 + bestVal := float32(-1e9) + for r := 0; r < relDim; r++ { + if v := raw[i*stride+h*relDim+r]; v > bestVal { + bestVal = v + bestIdx = r + } + } + rels[i] = depRelLabel(bestIdx) + } + return rels +} + +func loadWordMap(path string) (map[string]int64, error) { + data, err := onnxModelFS.ReadFile(path) if err != nil { return nil, err } var raw struct { - Word map[string]T `json:"word"` + Word map[string]int64 `json:"word"` } if err := json.Unmarshal(data, &raw); err != nil { - result := make(map[string]T) - if err2 := json.Unmarshal(data, &result); err2 != nil { + var flat map[string]int64 + if err2 := json.Unmarshal(data, &flat); err2 != nil { return nil, err } - return result, nil + return flat, nil } return raw.Word, nil } @@ -146,132 +287,37 @@ func extractEmbeddedModel(dataDir string) (string, error) { return dst, nil } -func findONNXRuntime() string { - candidates := []string{ - "onnxruntime.dll", - "libonnxruntime.so", - "libonnxruntime.dylib", - filepath.Join(os.Getenv("ONNXRUNTIME_DIR"), "libonnxruntime.so"), - filepath.Join(os.Getenv("ONNXRUNTIME_DIR"), "onnxruntime.dll"), +func libPath() string { + for _, env := range []string{"ONNXRUNTIME_DIR", "ONNX_ML_DIR"} { + if d := os.Getenv(env); d != "" { + for _, name := range []string{"libonnxruntime.so", "libonnxruntime.dylib", "onnxruntime.dll"} { + if candidate := filepath.Join(d, name); fileExists(candidate) { + return candidate + } + } + } } - for _, c := range candidates { - if _, err := os.Stat(c); err == nil { - abs, _ := filepath.Abs(c) + for _, name := range []string{"libonnxruntime.so", "libonnxruntime.dylib", "onnxruntime.dll"} { + if fileExists(name) { + abs, _ := filepath.Abs(name) return abs } } return "onnxruntime.dll" } -func tokenize(text string, vocab map[string]int64, maxLen int) []int64 { - ids := []int64{vocab[""]} - runes := []rune(text) - for i := 0; i < len(runes) && len(ids) < maxLen; i++ { - if id, ok := vocab[string(runes[i])]; ok { - ids = append(ids, id) - } else { - ids = append(ids, vocab[""]) - } - } - return ids +func fileExists(p string) bool { + _, err := os.Stat(p) + return err == nil } -func padTo(ids []int64, length int) []int64 { - for len(ids) < length { - ids = append(ids, 0) +func depRelLabel(id int) string { + labels := []string{"root", "nsubj", "obj", "iobj", "obl", "vocative", "expl", "csubj", "ccomp", "xcomp", + "advcl", "advmod", "amod", "appos", "nmod", "acl", "det", "clf", "case", "mark", + "nummod", "discourse", "aux", "cop", "cc", "conj", "fixed", "flat", "list", "parataxis", + "orphan", "goeswith", "reparandum", "punct", "dep"} + if id >= 0 && id < len(labels) { + return labels[id] } - return ids -} - -func actualLen(ids []int64) int { - for i, id := range ids { - if id == 0 { - return i - } - } - return len(ids) -} - -func idsToTokens(ids []int64, vocab map[string]int64) []string { - rev := make(map[int64]string) - for k, v := range vocab { - rev[v] = k - } - var tokens []string - for _, id := range ids { - if t, ok := rev[id]; ok { - tokens = append(tokens, t) - } - } - return tokens -} - -func decodePOS(raw []float32, seqLen int, posVocab map[string]int64) []string { - rev := make(map[int64]string) - for k, v := range posVocab { - rev[v] = k - } - pos := make([]string, seqLen) - for i := 0; i < seqLen; i++ { - bestIdx := 0 - bestVal := float32(-1e9) - for j := 0; j < 18; j++ { - v := raw[i*18+j] - if v > bestVal { - bestVal = v - bestIdx = j - } - } - if tag, ok := rev[int64(bestIdx)]; ok { - pos[i] = tag - } - } - return pos -} - -func decodeHeads(raw []float32, seqLen int) []int { - heads := make([]int, seqLen) - for i := 0; i < seqLen; i++ { - bestIdx := 0 - bestVal := float32(-1e9) - for j := 0; j < seqLen; j++ { - v := raw[i*seqLen+j] - if v > bestVal { - bestVal = v - bestIdx = j - } - } - heads[i] = bestIdx - } - return heads -} - -func decodeRels(raw []float32, seqLen int) []string { - rels := make([]string, seqLen) - for i := 0; i < seqLen; i++ { - bestIdx := 0 - bestVal := float32(-1e9) - for j := 0; j < 18; j++ { - // average over head dimension for argmax - var sum float32 - for k := 0; k < seqLen; k++ { - sum += raw[i*seqLen*18+k*18+j] - } - avg := sum / float32(seqLen) - if avg > bestVal { - bestVal = avg - bestIdx = j - } - } - rels[i] = posIDToTag(bestIdx) - } - return rels -} - -func posIDToTag(id int) string { - tags := []string{"", "ADJ", "ADP", "ADV", "AUX", "CCONJ", "DET", "INTJ", "NOUN", "NUM", "PART", "PRON", "PROPN", "PUNCT", "SCONJ", "SYM", "VERB", "X"} - if id >= 0 && id < len(tags) { - return tags[id] - } - return "X" + return "dep" } diff --git a/internal/nlp/onnx_stub.go b/internal/nlp/onnx_stub.go index 28075a3..10c3010 100644 --- a/internal/nlp/onnx_stub.go +++ b/internal/nlp/onnx_stub.go @@ -2,264 +2,19 @@ package nlp -import ( - "embed" - "encoding/json" - "fmt" - "strings" -) +import "fmt" -//go:embed models/vocab.json models/pos_vocab.json -var vocabFS embed.FS - -// ONNXParser 在未启用 onnxruntime 时作为规则式降级解析器。 -// 使用内嵌词表实现基于词典的 POS 标注 + 基于 POS 序列的依存关系推断。 -type ONNXParser struct { - vocab map[string]int - posVocab map[string]int -} +type ONNXParser struct{} type ONNXConfig struct { - ModelPath string // 留空使用内嵌规则引擎 - DataDir string // 仅在 onnxruntime 启用时使用 + ModelPath string + DataDir string } -func NewONNXParser(cfg ONNXConfig) (*ONNXParser, error) { - vocab := make(map[string]int) - data, err := vocabFS.ReadFile("models/vocab.json") - if err != nil { - return nil, fmt.Errorf("read vocab: %w", err) - } - - var raw struct { - Word map[string]int `json:"word"` - } - if err := json.Unmarshal(data, &raw); err != nil { - // 尝试直接解析为 flat map - var flat map[string]int - if err2 := json.Unmarshal(data, &flat); err2 != nil { - return nil, fmt.Errorf("parse vocab: %w", err) - } - vocab = flat - } else { - vocab = raw.Word - } - - posVocab := make(map[string]int) - data, err = vocabFS.ReadFile("models/pos_vocab.json") - if err != nil { - return nil, fmt.Errorf("read pos_vocab: %w", err) - } - if err := json.Unmarshal(data, &posVocab); err != nil { - return nil, fmt.Errorf("parse pos_vocab: %w", err) - } - - return &ONNXParser{vocab: vocab, posVocab: posVocab}, nil +func NewONNXParser(_ ONNXConfig) (*ONNXParser, error) { + return nil, fmt.Errorf("ONNX parser requires build tag 'onnxruntime' (go build -tags onnxruntime)") } -func (p *ONNXParser) Parse(text string) (*ParseResult, error) { - if text == "" { - return &ParseResult{}, nil - } - - // Phase 1: 基于词表的最大匹配分词 - tokens := p.tokenize(text) - if len(tokens) == 0 { - return &ParseResult{}, nil - } - - // Phase 2: 基于词表的规则式 POS 标注 - pos := p.tagPOS(tokens) - - // Phase 3: 基于 POS 序列的依存头推断 - heads := p.inferHeads(tokens, pos) - - // Phase 4: 关系标签推断 - rels := p.inferRels(tokens, pos, heads) - - return &ParseResult{ - Tokens: tokens, - POS: pos, - Heads: heads, - DepRels: rels, - }, nil -} - -func (p *ONNXParser) tokenize(text string) []string { - runes := []rune(text) - var tokens []string - buf := []rune{} - for _, r := range runes { - if r == ' ' || r == '\t' || r == '\n' || r == '\r' { - if len(buf) > 0 { - tokens = append(tokens, string(buf)) - buf = buf[:0] - } - continue - } - buf = append(buf, r) - // 最长匹配:检查当前 buf 是否在词表中 - if _, ok := p.vocab[string(buf)]; !ok && len(buf) > 0 { - // 回退:取 buf[:-1] 作为词,继续 - if _, ok2 := p.vocab[string(buf[:len(buf)-1])]; ok2 && len(buf) > 2 { - tokens = append(tokens, string(buf[:len(buf)-1])) - buf = buf[len(buf)-1:] - } - } - } - if len(buf) > 0 { - tokens = append(tokens, string(buf)) - } - if len(tokens) == 0 { - tokens = strings.Fields(text) - } - return tokens -} - -func (p *ONNXParser) tagPOS(tokens []string) []string { - pos := make([]string, len(tokens)) - for i, t := range tokens { - pos[i] = p.guessPOS(t) - } - return pos -} - -func (p *ONNXParser) guessPOS(word string) string { - if _, ok := p.vocab[word]; !ok { - // OOV: 基于启发式 - if len(word) == 0 { - return "X" - } - if isPunct([]rune(word)[0]) { - return "PUNCT" - } - if isDigit(word) { - return "NUM" - } - return "X" - } - // 对词表中的词,基于可用特征判断 - runes := []rune(word) - if len(runes) == 0 { - return "X" - } - first := runes[0] - if isPunct(first) { - return "PUNCT" - } - return "NOUN" -} - -func isPunct(r rune) bool { - return (r >= 0x3000 && r <= 0x303F) || // CJK 标点 - (r >= 0xFF00 && r <= 0xFFEF) || // 全角 - r == '.' || r == ',' || r == '!' || r == '?' || - r == ';' || r == ':' || r == '"' || r == '\'' || - r == '(' || r == ')' || r == '[' || r == ']' || - r == '{' || r == '}' || r == '。' || r == ',' || - r == '!' || r == '?' || r == ';' || r == ':' || - r == '、' || r == '‘' || r == '’' || r == '“' || r == '”' -} - -func isDigit(s string) bool { - for _, r := range s { - if r < '0' || r > '9' { - if r < 0xFF10 || r > 0xFF19 { // 全角数字 - return false - } - } - } - return len(s) > 0 -} - -// inferHeads 基于 POS 序列的规则式依存头推断。 -// 动词通常作为根(head=0),名词依附于动词,形容词依附于名词。 -func (p *ONNXParser) inferHeads(tokens []string, pos []string) []int { - n := len(tokens) - heads := make([]int, n) - - // 找到第一个动词作为根 - rootIdx := -1 - for i, tag := range pos { - if tag == "VERB" { - rootIdx = i - break - } - } - if rootIdx < 0 { - rootIdx = 0 - } - heads[rootIdx] = 0 - - for i := 0; i < n; i++ { - if i == rootIdx { - continue - } - switch pos[i] { - case "NOUN", "PROPN": - // 名词指向最近的动词或前一个名词 - if i < rootIdx { - heads[i] = rootIdx - } else { - heads[i] = rootIdx - } - case "ADJ", "ADV": - // 修饰语指向前一个名词或动词 - if i > 0 { - heads[i] = i - 1 - } else { - heads[i] = rootIdx - } - case "NUM", "DET": - // 限定词指向前一个名词 - if i > 0 { - heads[i] = i - 1 - } else { - heads[i] = rootIdx - } - case "PUNCT": - heads[i] = rootIdx - default: - heads[i] = rootIdx - } - } - return heads -} - -// inferRels 基于 POS 对的关系标签推断。 -func (p *ONNXParser) inferRels(tokens []string, pos []string, heads []int) []string { - n := len(tokens) - rels := make([]string, n) - for i := 0; i < n; i++ { - if heads[i] == 0 { - rels[i] = "ROOT" - continue - } - h := heads[i] - if h < 0 || h >= n { - rels[i] = "dep" - continue - } - rels[i] = posToRel(pos[h], pos[i]) - } - return rels -} - -func posToRel(headPOS, depPOS string) string { - switch { - case depPOS == "NOUN" || depPOS == "PROPN": - return "nsubj" - case depPOS == "ADJ": - return "amod" - case depPOS == "ADV": - return "advmod" - case depPOS == "NUM" || depPOS == "DET": - return "det" - case depPOS == "VERB": - return "xcomp" - case depPOS == "PUNCT": - return "punct" - default: - return "dep" - } +func (p *ONNXParser) Parse(_ string) (*ParseResult, error) { + return nil, fmt.Errorf("ONNX parser not available: rebuild with -tags onnxruntime") } diff --git a/internal/plugin/bridge_e2e_test.go b/internal/plugin/bridge_e2e_test.go index e2f425f..f5969b1 100644 --- a/internal/plugin/bridge_e2e_test.go +++ b/internal/plugin/bridge_e2e_test.go @@ -6,7 +6,6 @@ import ( "encoding/json" "os" "path/filepath" - "runtime" "syscall" "testing" "unsafe" diff --git a/internal/plugins/clawhubadapter/plugin.go b/internal/plugins/clawhubadapter/plugin.go index 9895ae1..d9936bd 100644 --- a/internal/plugins/clawhubadapter/plugin.go +++ b/internal/plugins/clawhubadapter/plugin.go @@ -89,7 +89,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { Key: "simulator_dir", Type: "string", DisplayName: "模拟器工作目录", Description: "OpenClaw 模拟器工作目录路径(留空则使用默认路径)", }) - p.httpClient = &http.Client{} + p.httpClient = &http.Client{Timeout: 30 * time.Second} if v, _ := s.Settings().Get("skills_dir"); v != nil { if s, ok := v.(string); ok && s != "" { p.skillsDir = s diff --git a/internal/plugins/webui/handler.go b/internal/plugins/webui/handler.go index 0f500be..7f2c069 100644 --- a/internal/plugins/webui/handler.go +++ b/internal/plugins/webui/handler.go @@ -1155,11 +1155,12 @@ func (h *Handler) reloadLLMProviders() { key = h.baseAPIKey } provider := agentAPI.NewLuaAdaptedProvider(agentAPI.BaseConfig{ - Model: src.Model, - BaseURL: src.BaseURL, - APIKey: key, - Temperature: cfg.LLM.Temperature, - MaxTokens: cfg.LLM.MaxTokens, + Model: src.Model, + BaseURL: src.BaseURL, + APIKey: key, + Temperature: cfg.LLM.Temperature, + MaxTokens: cfg.LLM.MaxTokens, + ContextWindow: src.ContextWindow, }, h.lua, src.Adapter) h.providerMgr.Register(src.Name, provider) } diff --git a/pkg/types/types.go b/pkg/types/types.go index c6ca2a9..56e6e29 100644 --- a/pkg/types/types.go +++ b/pkg/types/types.go @@ -102,6 +102,7 @@ type LLMSource struct { APIKey string `json:"api_key,omitempty"` Adapter string `json:"adapter"` AdapterPath string `json:"adapter_path,omitempty"` + ContextWindow int `json:"context_window,omitempty"` ThinkingEnabled bool `json:"thinking_enabled,omitempty"` } @@ -113,6 +114,7 @@ type LLMConfig struct { Adapter string `json:"adapter"` Temperature float64 `json:"temperature"` MaxTokens int `json:"max_tokens"` + ContextWindow int `json:"context_window,omitempty"` ThinkingEnabled bool `json:"thinking_enabled"` Sources []LLMSource `json:"sources,omitempty"` }