// websearch.go 是 web-search 插件的**可变源**:`$DATA_DIR/config/plugins/web-search.json` // (design/13 §2.1「插件配置数据一律进 config/plugins/<插件 id>.json,文件名 = 插件 id」; // design/09 §5.7 E 的首个用例 = 搜索 provider 配置页 —— 让多个第三方搜索 provider // 在面板里配,不再靠 `.env`)。 // // 三件事划清边界: // // 1. 本文件只管**文件形状 + 读写 + 结构校验**:它不认识 `WEB_SEARCH_*` 键、不认识 // `config.WebSearchConfig`,也不认识任何一家 provider 的名字。「把 `.env` 播种成 // 这份形状」与「把这份形状读回工具要的配置」是**组合根**的事 // (`cmd/cyrene/wire_io_tools.go`)—— 那里本来就只有一份 provider 名字表, // 多一份就是第二份真源。 // 2. **清单 vs 数据分离**(design/13 §2.1):`config/plugins.json` 只有插件管理器自己的 // 清单;插件落下来的配置数据一个插件一个文件。 // 3. 凭据只落盘、**绝不进日志/响应**:读侧出口只有 `MaskedWebSearchFile`。 // // 读写口径与 `plugins.json` / `models.json` 同源:JSON、0600、temp+rename(并发读者永远 // 看不到半个文件)。文件缺失/为空 = 「还没配过」,由调用方用 `.env` 播种,**不是错误**。 package runtimeconfig import ( "encoding/json" "errors" "fmt" "os" "path/filepath" "regexp" "strings" "time" ) // 文件形状的常量。 const ( // webSearchPluginID 是 web-search 插件的 id。文件名 = 插件 id(design/13 §2.1 的硬约定, // 它让落地文件能人工对上插件;插件声明侧的同名常量在 plugins/tools/io/search, // 两边由 search 包的测试钉住同源)。 webSearchPluginID = "web-search" // webSearchConfigSubdir / webSearchPluginsDir 是落点的两段目录 // (`$DATA_DIR/config/plugins/<插件 id>.json`)。 webSearchConfigSubdir = "config" webSearchPluginsDir = "plugins" // WebSearchVersion 是当前形状版本。读盘时接受 0(人工手写没写版本)与它,拒绝更高版本 // —— 未来版本的文件用今天的语义去解释,只会读出一份**看起来正常**的错配置。 WebSearchVersion = 1 // webSearchFileMode 是落盘权限:文件会带 API Key。 webSearchFileMode = 0o600 // webSearchDirMode 是落盘目录权限。 webSearchDirMode = 0o755 ) // webSearchProviderRe 是 provider 名的字符集。本仓内置名全是小写字母 + 下划线 // (searxng / zhipu / aliyun_iqs / bocha / volcengine / baidu_qianfan / tencent_wsa / // metaso),限定字符集是为了让名字能直接当端点段、文件名与日志字段用。 // // 它**不**判定「这个名字是否已登记」—— 那是 `plugins/tools/io/search` 的事实,本包不去 // 反向依赖插件包(组合根负责核对并记 WARN)。 var webSearchProviderRe = regexp.MustCompile(`^[a-z][a-z0-9_]*$`) // ErrInvalidWebSearch 是 web-search.json 结构不合法时的分类错误(errors.Is 可判类)。 var ErrInvalidWebSearch = errors.New("runtimeconfig: invalid web-search config") // WebSearchConfigPath 返回 web-search 面板配置的落点: // `$DATA_DIR/config/plugins/web-search.json`。 func WebSearchConfigPath(dataDir string) string { return filepath.Join(dataDir, webSearchConfigSubdir, webSearchPluginsDir, webSearchPluginID+".json") } // EnableFlag 返回 ProviderEntry.Enabled 的三态指针(`&true` / `&false` 的简写)。 func EnableFlag(v bool) *bool { return &v } // ProviderEntry 是一条 provider 配置 —— 「多实例」= providers 数组里的多条, // 每家**独立** key / 基地址 / 启用态,互不影响(design/27 §1.2)。 type ProviderEntry struct { // Provider 是 provider 名。它在文件内**唯一**:注册表、主 provider 与降级链都以名字 // 为键,同名两条在语义上无法区分(校验期直接拒掉,而不是「后者覆盖前者」这种静默取一)。 Provider string `json:"provider"` // APIKey 是上游凭据。**绝不进日志/响应**(读侧经 MaskedWebSearchFile)。 APIKey string `json:"apiKey,omitempty"` // BaseURL 是上游基地址(searxng 的基地址就是原有 `WEB_SEARCH_ENDPOINT` 的端点)。 BaseURL string `json:"baseUrl,omitempty"` // Enabled 是启用态。三态:缺省(nil)与 true 都算**启用**,显式 false 才是停用 —— // 用指针而不是 bool,是为了让「手写文件只填了 provider 与 apiKey」这种常见情形不被 // 误判成停用(与 sdk.When.Enabled 同一取舍)。 Enabled *bool `json:"enabled,omitempty"` } // IsEnabled 报告该条是否启用(缺省视为启用)。 func (e ProviderEntry) IsEnabled() bool { return e.Enabled == nil || *e.Enabled } // WebSearchFile 是 `web-search.json` 的形状。 type WebSearchFile struct { // Version 是形状版本(0 = 人工手写没写版本,按当前版本读)。 Version int `json:"version"` // Provider 是主 provider,取值语义与 `WEB_SEARCH_PROVIDER` **完全一致**:空串 / off / // none(大小写与空白不敏感)是「显式关闭」的哨兵,由组合根按同一套裁决处理 // —— 面板与 `.env` 不许有第二套关闭语义。 Provider string `json:"provider"` // Fallback 是降级链,从左到右试;空 = 只用手选的那一家。 Fallback []string `json:"fallback,omitempty"` // MaxResults 是单次检索的条数上限;0 = 未声明,由组合根回落到代码默认值。 MaxResults int `json:"maxResults,omitempty"` // Timeout 是单次上游尝试的超时(Go duration 字符串,如 "20s");空 = 未声明。 // 用字符串而不是纳秒整数,是因为这份文件是给人看、给人改的。 Timeout string `json:"timeout,omitempty"` // Providers 是各家的接线。允许为空数组(= 一家都没配 ⇒ 组合根不注册 web-search)。 Providers []ProviderEntry `json:"providers"` } // TimeoutDuration 解析 Timeout:未声明(空/全空白)⇒ 0(= 沿用代码默认值)。 // 写法非法或非正数 ⇒ ErrInvalidWebSearch(非正数的超时是笔误,不是「关掉超时」)。 func (f WebSearchFile) TimeoutDuration() (time.Duration, error) { s := strings.TrimSpace(f.Timeout) if s == "" { return 0, nil } d, err := time.ParseDuration(s) if err != nil { return 0, fmt.Errorf("%w: timeout %q 不是合法的 Go duration: %w", ErrInvalidWebSearch, s, err) } if d <= 0 { return 0, fmt.Errorf("%w: timeout 必须为正(得到 %q)", ErrInvalidWebSearch, s) } return d, nil } // ValidateWebSearch 校验文件的结构。 // // 刻意**不**校验「provider 名是否已登记」:那是插件自己的事实(search.Known), // 本包反向依赖插件包会把配置层与插件层绑死。未登记的名字由组合根记一条 WARN 并忽略 // (它不可能被选中,因为选中的家由 io 工具集在启动期校验)。 func ValidateWebSearch(f WebSearchFile) error { if f.Version < 0 || f.Version > WebSearchVersion { return fmt.Errorf("%w: version %d 超出本仓支持的 0..%d", ErrInvalidWebSearch, f.Version, WebSearchVersion) } if f.MaxResults < 0 { return fmt.Errorf("%w: maxResults 不得为负(得到 %d)", ErrInvalidWebSearch, f.MaxResults) } if _, err := f.TimeoutDuration(); err != nil { return err } seen := make(map[string]bool, len(f.Providers)) for i, e := range f.Providers { name := strings.ToLower(strings.TrimSpace(e.Provider)) if name == "" { return fmt.Errorf("%w: providers[%d].provider 必填", ErrInvalidWebSearch, i) } if !webSearchProviderRe.MatchString(name) { return fmt.Errorf("%w: providers[%d].provider %q 非法:只允许小写字母、数字与下划线", ErrInvalidWebSearch, i, e.Provider) } if seen[name] { return fmt.Errorf("%w: provider %q 重复(同名两条无法区分:注册表与降级链都以名字为键)", ErrInvalidWebSearch, name) } seen[name] = true } return nil } // LoadWebSearch 读取 web-search.json。 // // found 报告「文件存在且非空」:缺失或全空白 = 还没配过,由调用方用 `.env` 播种 // (design/09 §5.7 E 的「先读可变源、缺失回落 `.env` 播种」),**不是错误**。 // // 存在但不合法(解不开 / 结构不合法)⇒ 返回错误:调用方应记 WARN 并回落 `.env`, // 且**不得覆盖**原文件 —— 损坏的配置可以人工救回,用 `.env` 盖掉它会把人工写的条目 // 一起丢掉(与 models.json 的同一处置)。 func LoadWebSearch(path string) (f WebSearchFile, found bool, err error) { raw, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return WebSearchFile{}, false, nil } return WebSearchFile{}, false, fmt.Errorf("runtimeconfig: read %s: %w", path, err) } if strings.TrimSpace(string(raw)) == "" { return WebSearchFile{}, false, nil } if err := json.Unmarshal(raw, &f); err != nil { return WebSearchFile{}, false, fmt.Errorf("%w: decode %s: %w", ErrInvalidWebSearch, path, err) } if err := ValidateWebSearch(f); err != nil { return WebSearchFile{}, false, err } if f.Providers == nil { f.Providers = []ProviderEntry{} } return f, true, nil } // SaveWebSearch 把 f 原子写回 path:校验结构 → 建目录 → 写临时文件(0600)→ rename。 // // temp+rename 的理由与 plugins.json 一致:并发读者要么看到旧文件、要么看到新文件, // 不会读到半个 JSON。 func SaveWebSearch(path string, f WebSearchFile) error { if err := ValidateWebSearch(f); err != nil { return fmt.Errorf("runtimeconfig: save %s: %w", path, err) } if f.Providers == nil { f.Providers = []ProviderEntry{} } raw, err := json.MarshalIndent(f, "", " ") if err != nil { return fmt.Errorf("runtimeconfig: marshal: %w", err) } if dir := filepath.Dir(path); dir != "" && dir != "." { if err := os.MkdirAll(dir, webSearchDirMode); err != nil { return fmt.Errorf("runtimeconfig: mkdir %s: %w", dir, err) } } tmp := path + ".tmp" if err := os.WriteFile(tmp, raw, webSearchFileMode); err != nil { return fmt.Errorf("runtimeconfig: write %s: %w", tmp, err) } if err := os.Rename(tmp, path); err != nil { return fmt.Errorf("runtimeconfig: rename %s: %w", path, err) } return nil } // UpsertProviderEntry 返回把 e 插入或**就位替换**后的文件(其余条目的顺序不变)。 // // 匹配键是 provider 名(大小写与首尾空白不敏感,写回时归一成小写):这样「面板里改一条 // 已存在的家」不会因为大小写不同而多出一条,也不会把顺序打乱(顺序 = 面板里的稳定顺序)。 func UpsertProviderEntry(f WebSearchFile, e ProviderEntry) WebSearchFile { e.Provider = strings.ToLower(strings.TrimSpace(e.Provider)) out := make([]ProviderEntry, 0, len(f.Providers)+1) replaced := false for _, cur := range f.Providers { if strings.EqualFold(strings.TrimSpace(cur.Provider), e.Provider) { out = append(out, e) replaced = true continue } out = append(out, cur) } if !replaced { out = append(out, e) } f.Providers = out return f } // MaskedWebSearchFile 返回读侧出口用的副本:每条 APIKey 只留尾 4 位(≤4 位则全掩), // 其余字段原样。落盘的 `web-search.json` 不受影响。 // // 这是本包唯一的「回给客户端」的出口:凭据读侧**永不回显明文**(design/09 §5.4.2)。 // 掩码后的值**不是**可直接回写的凭据 —— 「未改动」必须由写侧的显式哨兵表达。 func MaskedWebSearchFile(f WebSearchFile) WebSearchFile { out := f if f.Fallback != nil { out.Fallback = append([]string(nil), f.Fallback...) } out.Providers = make([]ProviderEntry, 0, len(f.Providers)) for _, e := range f.Providers { if strings.TrimSpace(e.APIKey) != "" { e.APIKey = maskToken(strings.TrimSpace(e.APIKey)) } out.Providers = append(out.Providers, e) } return out }