package main import ( "net" "net/url" "strings" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) // memoryOwnerRoleID 是组合根写入提取出的记忆时所用的角色 id;主人是唯一会写入 // 私有记忆的 actor(群聊/陌生人会话根本不提取,design/11 §2)。 const memoryOwnerRoleID = "owner" // buildMemoryExtractor 选择循环在每轮落定后运行的记忆提取器(design/03 §1 // LLM 异步自动提取): // // - provider 栈存在可用的活动组时用 LLM 提取器,由模型去噪并去重真正值得记的内容; // - 否则用确定性的 ManualExtractor,使无 key 的部署仍能记录主人显式的「记住 X」 // 指令,而不是每轮都让模型调用失败。 // // 该提取器刻意不持有任何人设:一个进程现在会以多个角色作答(design/22 §4.2), // 若在此固化某个人设,就会把所有提取出的记忆都归到那一个角色下。循环在持久化时 // 用产生该轮的角色逐条打标(loop.persistMemories)。 // // 循环会把 LLM 提取器分离到后台 goroutine(ExtractAsync);见 // internal/runtime/loop/memory.go。 func (a *app) buildMemoryExtractor() memory.Extractor { if llmGroupUsable(a.llm) { a.log.Info("memory extractor: llm", "model", a.llm.Model(), "provider", a.llm.Provider()) return memory.NewLLMExtractor(a.llm, "", memoryOwnerRoleID) } a.log.Warn("memory extractor: manual (no usable llm model group)") return memory.NewManualExtractor("", memoryOwnerRoleID) } // llmGroupUsable 报告运行时栈的活动模型组是否真的可调用:模型与 base URL 是必需 // 项,且要么配置了 API key,要么端点是本地/内网地址 —— 后者无 key 调用属正常情况 // (自托管的 OpenAI 兼容服务)。 // // 它刻意只做廉价的静态检查,绝不发探测请求:启动不该花掉一次模型调用。看起来可用 // 但调用时才失败的组,只会记一条提取错误日志(提取失败从不致命)。 func llmGroupUsable(llm *model.RuntimeLLM) bool { if llm == nil { return false } group := llm.Group() if strings.TrimSpace(group.Model) == "" || strings.TrimSpace(group.BaseURL) == "" { return false } if strings.TrimSpace(group.APIKey) != "" { return true } return keylessEndpoint(group.BaseURL) } // keylessEndpoint 报告 baseURL 是否指向回环或内网主机 —— 这类地址上缺少 API key // 是预期情况,而非配置错误。 func keylessEndpoint(baseURL string) bool { parsed, err := url.Parse(strings.TrimSpace(baseURL)) if err != nil { return false } host := parsed.Hostname() if host == "" { return false } if strings.EqualFold(host, "localhost") { return true } ip := net.ParseIP(host) if ip == nil { return false } return ip.IsLoopback() || ip.IsPrivate() }