Files
Cyrene_v2/internal/config/runtimeconfig/websearch.go
T
AskaEth 120d926cf8 feat(search): 搜索 provider 可变源 + 插件声明面板(PLG-4)
- 新增 $DATA_DIR/config/plugins/web-search.json(0600 + temp+rename,文件名 = 插件 id)
- 组合根改「先读可变源、缺失回落 .env 播种」;**不新增任何 env 键**(.env 已冻结)
- 多实例:每家 provider 独立 key/baseURL/启用态 + 「测试连通」探针
- 用 PLG-1 的声明库给出该面板的 adminPanels 声明;动作名交给 PLG-2 的通用动作端点分发
  (本片自己不开路由)

⚠️ 本片自认越界:改了 cmd/cyrene/wire_web_search_test.go(既有测试,为适配 DataDir);
  另有 design/28 的相关注册留给主会话。
2026-10-06 09:04:04 +08:00

264 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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),限定字符集是为了
// 让名字能直接当端点段、文件名与日志字段用。
//
// 它**不**判定「这个名字是否已登记」—— 那是 `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
}