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 的相关注册留给主会话。
This commit is contained in:
1 parent
2d21aa553e
commit
120d926cf8
11 files changed
+1811
-41
No files matched your search
+244
-41
@@ -1,36 +1,43 @@
|
||||
package main
|
||||
|
||||
// 本文件装配 io 工具集(plugins/tools/io)的选项:知识检索器、日志,以及
|
||||
// **web-search 的后端与超时**(切片 WS1-A 接线 + WS1-B provider 层)。
|
||||
// **web-search 的后端与超时**(切片 WS1-A 接线 + WS1-B provider 层 + PLG-4 可变源)。
|
||||
//
|
||||
// 为什么单独成文件而不是塞在 wire_runtime.go 里:那条装配线本就接近文件行数上限,
|
||||
// 而本片新增的裁决(端点/provider 关闭 ⇒ 工具不进注册面;选定 provider 未登记/未配置
|
||||
// ⇒ 启动报错)需要一个能单测的落点。
|
||||
// 而这里新增的裁决(端点/provider 关闭 ⇒ 工具不进注册面;选定 provider 未登记/未配置
|
||||
// ⇒ 启动报错;面板配置优先、缺失由 `.env` 播种)需要一个能单测的落点。
|
||||
//
|
||||
// 四条口径:
|
||||
// 五条口径:
|
||||
//
|
||||
// 1. **后端由配置选定**:`WEB_SEARCH_PROVIDER` 选主 provider,`WEB_SEARCH_FALLBACK`
|
||||
// 给降级链;各家的 key/base URL 走 `WEB_SEARCH_<NAME>_*`(design/27 §3)。
|
||||
// 2. **拿不到可用后端就不注册工具**(主人 2026-10-05 报障的核心诉求):端点显式关闭
|
||||
// 1. **后端由配置选定**:主 provider 选谁(`WEB_SEARCH_PROVIDER` / 面板文件),降级链
|
||||
// 怎么走,各家的 key 与基地址从哪来(design/27 §3)。
|
||||
// 2. **配置有可变源**(design/09 §5.7 E):先读 `$DATA_DIR/config/plugins/web-search.json`
|
||||
// —— 面板可改、0600 + temp+rename;文件**缺失/为空**时由 `.env` 播种(`.env` 从此只是
|
||||
// 「最小化功能可跑」的引导面,本片**不新增任何 env 键**);文件**非法**时记 WARN 回落
|
||||
// `.env`,且**不覆盖**原文件(损坏的配置可以人工救回)。
|
||||
// 3. **拿不到可用后端就不注册工具**(主人 2026-10-05 报障的核心诉求):端点显式关闭
|
||||
// (空串 / off / none)或不是 http(s) 绝对地址时,web-search **不进注册面** ——
|
||||
// 模型根本看不见它,而不是看得见、一调就报错。
|
||||
// 3. **未配置的家不进可用清单**:`search.Configured` 判定(云家看 key、searxng 看端点),
|
||||
// 4. **未配置的家不进可用清单**:`search.Configured` 判定(云家看 key、searxng 看端点),
|
||||
// false ⇒ 该家不出现在注册表里(fail-closed)。选定 provider 未配置时由 io 工具集在
|
||||
// **启动期报错**,绝不静默回落到兜底。
|
||||
// 4. **超时不另造默认值**:`WEB_SEARCH_TIMEOUT` 未配置(0)时沿用 io 包既有超时;
|
||||
// 负值是笔误,按未配置处理并记一条带 env 键名的 WARN(不静默)。
|
||||
// 5. **超时不另造默认值**:`WEB_SEARCH_TIMEOUT` / 面板的 `timeout` 未声明时沿用 io 包既有
|
||||
// 超时;负值是笔误,按未配置处理并记一条带键名的 WARN(不静默)。
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
iotools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
|
||||
)
|
||||
|
||||
// web-search 的 env 键名:必须与 `.env.example` 逐字一致(AGENTS §10 的 E6 条目),
|
||||
// 由 wire_web_search_test.go 逐字核对。
|
||||
// web-search 的 env 键名:与 `.env.example` 逐字一致(AGENTS §10 的 E6 条目),
|
||||
// 由 wire_web_search_test.go 逐字核对。它们仍然是**引导面**的键(.env 冻结后不再新增)。
|
||||
const (
|
||||
webSearchProviderEnv = "WEB_SEARCH_PROVIDER"
|
||||
webSearchEndpointEnv = "WEB_SEARCH_ENDPOINT"
|
||||
@@ -42,6 +49,14 @@ const (
|
||||
// ioLogSource 是本文件日志的来源标签(项目规范 §10:禁止无来源裸日志)。
|
||||
const ioLogSource = "cmd/cyrene/wire_io_tools"
|
||||
|
||||
// 生效配置的来源标签(写进启动日志:一眼看出「这次的 provider 是 .env 给的还是面板给的」)。
|
||||
const (
|
||||
// webSearchSourceEnv 表示本次的配置来自 `.env`(含「文件缺失、刚由 .env 播种」这一步)。
|
||||
webSearchSourceEnv = "env"
|
||||
// webSearchSourcePanel 表示本次的配置来自面板可变源(面板是唯一来源)。
|
||||
webSearchSourcePanel = "panel"
|
||||
)
|
||||
|
||||
// ioToolOptions 组装 io 工具集的全部选项。wire_runtime 把它原样交给 iotools.New,
|
||||
// 因此「注册面里有哪些 io 工具」只由这一处决定。
|
||||
func (a *app) ioToolOptions() []iotools.Option {
|
||||
@@ -52,36 +67,36 @@ func (a *app) ioToolOptions() []iotools.Option {
|
||||
return append(opts, a.webSearchOptions()...)
|
||||
}
|
||||
|
||||
// webSearchOptions 把 web-search 的配置翻译成 io 选项。
|
||||
// webSearchOptions 把生效配置翻译成 io 选项。
|
||||
// 端点/后端「关闭 / 非法 / 可用」三种去处都在这里定死(ResolveProvider/ResolveEndpoint)。
|
||||
func (a *app) webSearchOptions() []iotools.Option {
|
||||
log := logger.WithSource(a.log, ioLogSource)
|
||||
ws := a.cfg.WebSearch
|
||||
ws, source := a.effectiveWebSearch(log)
|
||||
|
||||
// ① 后端显式关闭(WEB_SEARCH_PROVIDER = 空 / off / none)⇒ 整件工具不注册。
|
||||
// ① 后端显式关闭(主 provider = 空 / off / none)⇒ 整件工具不注册。
|
||||
if decision := ws.ResolveProvider(); decision.Disabled {
|
||||
// 配置值**绝不写进日志**(只记键名与原因)。
|
||||
log.Warn("web-search tool not registered",
|
||||
"env", webSearchProviderEnv, "reason", config.WebSearchReasonOff,
|
||||
"env", webSearchProviderEnv, "reason", config.WebSearchReasonOff, "source", source,
|
||||
"effect", "the model will not see a web-search tool at all")
|
||||
return []iotools.Option{iotools.WithoutSearch()}
|
||||
}
|
||||
|
||||
specs := a.searchProviderSpecs()
|
||||
specs := searchProviderSpecs(ws)
|
||||
|
||||
// ② searxng 的可用性 = 端点裁决(WS1-A 的既有语义):端点显式关闭 / 不是 http(s)
|
||||
// 绝对地址时**整件工具不注册** —— 那两种取值是「关掉搜索」的显式选择,不是配置
|
||||
// 错误,因此不 fatal,只留一条带键名的 WARN。
|
||||
if endpoint := ws.ResolveEndpoint(); !endpoint.Enabled && a.selectsSearXNG() {
|
||||
if endpoint := ws.ResolveEndpoint(); !endpoint.Enabled && selectsSearXNG(ws) {
|
||||
log.Warn("web-search tool not registered",
|
||||
"env", webSearchEndpointEnv, "reason", endpoint.Reason,
|
||||
"env", webSearchEndpointEnv, "reason", endpoint.Reason, "source", source,
|
||||
"effect", "the model will not see a web-search tool at all")
|
||||
return []iotools.Option{iotools.WithoutSearch()}
|
||||
}
|
||||
|
||||
log.Info("web-search providers configured",
|
||||
"env", webSearchProviderEnv, "primary", strings.ToLower(strings.TrimSpace(ws.Provider)),
|
||||
"env_fallback", webSearchFallbackEnv, "fallback", ws.Fallback,
|
||||
"env_fallback", webSearchFallbackEnv, "fallback", ws.Fallback, "source", source,
|
||||
"available", availableProviderNames(specs))
|
||||
|
||||
opts := []iotools.Option{
|
||||
@@ -96,43 +111,206 @@ func (a *app) webSearchOptions() []iotools.Option {
|
||||
opts = append(opts, iotools.WithSearchTimeout(ws.Timeout))
|
||||
case ws.Timeout < 0:
|
||||
log.Warn("web-search timeout is negative, treated as unset",
|
||||
"env", webSearchTimeoutEnv, "effect", "io package default timeout applies")
|
||||
"env", webSearchTimeoutEnv, "source", source,
|
||||
"effect", "io package default timeout applies")
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
// selectsSearXNG 报告当前配置选的是不是 searxng(大小写与空白不敏感)。
|
||||
func (a *app) selectsSearXNG() bool {
|
||||
selected := strings.TrimSpace(a.cfg.WebSearch.Provider)
|
||||
return strings.EqualFold(selected, search.NameSearXNG)
|
||||
// effectiveWebSearch 解析 web-search 的**生效配置**,并报告它是从哪儿来的:
|
||||
//
|
||||
// - 面板文件存在且合法 ⇒ 它是唯一来源(design/09 §5.7 E 的「先读可变源」);
|
||||
// - 文件缺失/为空 ⇒ 用 `.env` 播种落盘,本次行为与 `.env` 完全一致(「缺失回落 `.env`
|
||||
// 播种」,照 models.json materialize 范式);
|
||||
// - 文件存在但非法 ⇒ 记 WARN、本次回落 `.env`,**不覆盖**原文件。
|
||||
//
|
||||
// 返回值里的第二个值是来源标签,只用于日志(`.env` 还是面板),不参与任何裁决。
|
||||
func (a *app) effectiveWebSearch(log *slog.Logger) (config.WebSearchConfig, string) {
|
||||
env := a.cfg.WebSearch
|
||||
path := runtimeconfig.WebSearchConfigPath(resolveDataDir(a.cfg))
|
||||
f, found, err := runtimeconfig.LoadWebSearch(path)
|
||||
switch {
|
||||
case err != nil:
|
||||
log.Warn("web-search 面板配置非法,本次按 .env 运行(不覆盖原文件)",
|
||||
"path", path, "error", err)
|
||||
return env, webSearchSourceEnv
|
||||
case !found:
|
||||
return a.seedWebSearchConfig(log, path, env), webSearchSourceEnv
|
||||
}
|
||||
warnUnknownSearchProviders(log, path, f)
|
||||
eff, convErr := effectiveSearchConfig(env, f)
|
||||
if convErr != nil {
|
||||
log.Warn("web-search 面板配置无法生效,本次按 .env 运行",
|
||||
"path", path, "error", convErr)
|
||||
return env, webSearchSourceEnv
|
||||
}
|
||||
log.Info("web-search 面板配置生效", "path", path, "providers", len(f.Providers))
|
||||
return eff, webSearchSourcePanel
|
||||
}
|
||||
|
||||
// searchProviderSpecs 把 `WEB_SEARCH_*` 配置翻译成各家接线(一家一行)。
|
||||
// seedWebSearchConfig 把 `.env` 播种成面板文件(`.env` 只作引导面)。
|
||||
//
|
||||
// 播种失败**不阻断启动**:本次按 `.env` 运行,只记一条 WARN(与 models.json 的处置一致)。
|
||||
func (a *app) seedWebSearchConfig(log *slog.Logger, path string, env config.WebSearchConfig) config.WebSearchConfig {
|
||||
seed := seedWebSearchFile(env)
|
||||
if err := runtimeconfig.SaveWebSearch(path, seed); err != nil {
|
||||
log.Warn("web-search 面板配置播种失败(本次按 .env 运行)", "path", path, "error", err)
|
||||
return env
|
||||
}
|
||||
log.Info("web-search 面板配置已按 .env 播种(此后面板是唯一来源)",
|
||||
"path", path, "providers", len(seed.Providers))
|
||||
return env
|
||||
}
|
||||
|
||||
// seedWebSearchFile 把 `.env` 的 web-search 段投影成可变源的初始形状。
|
||||
//
|
||||
// **本期内置的家全列**(含还没配 key 的):面板的用途就是「一次把各家配齐」,
|
||||
// 让用户先看到完整清单、再逐条填,比只列已配置的家少一轮「名字得自己知道」。
|
||||
func seedWebSearchFile(ws config.WebSearchConfig) runtimeconfig.WebSearchFile {
|
||||
enabled := runtimeconfig.EnableFlag(true)
|
||||
entries := make([]runtimeconfig.ProviderEntry, 0, len(searchProviderVector(ws))+1)
|
||||
// searxng 的基地址 = `WEB_SEARCH_ENDPOINT`。关闭哨兵(空 / off / none)播成空串:
|
||||
// 两者语义相同(都表示不出这家),但面板里不该显示一个不是地址的 "off"。
|
||||
entries = append(entries, runtimeconfig.ProviderEntry{
|
||||
Provider: search.NameSearXNG, BaseURL: seedEndpoint(ws.Endpoint), Enabled: enabled,
|
||||
})
|
||||
for _, p := range searchProviderVector(ws) {
|
||||
entries = append(entries, runtimeconfig.ProviderEntry{
|
||||
Provider: p.Name, APIKey: p.APIKey, BaseURL: p.BaseURL, Enabled: enabled,
|
||||
})
|
||||
}
|
||||
return runtimeconfig.WebSearchFile{
|
||||
Version: runtimeconfig.WebSearchVersion,
|
||||
Provider: ws.Provider,
|
||||
Fallback: ws.Fallback,
|
||||
MaxResults: ws.MaxResults,
|
||||
Timeout: seedTimeout(ws.Timeout),
|
||||
Providers: entries,
|
||||
}
|
||||
}
|
||||
|
||||
// effectiveSearchConfig 把面板文件翻译成工具要的配置。
|
||||
//
|
||||
// **权威边界**(写清楚,免得「谁覆盖谁」靠猜):
|
||||
//
|
||||
// - **结构以文件为准**:主 provider / 降级链 / 各家条目都来自文件 —— 文件里没有的家就是
|
||||
// 没有(绝不从 `.env` 复活),这正是「面板可改」的意思;
|
||||
// - **凭据以文件为准**:某家的 apiKey 为空 = 这家没配(绝不拿 `.env` 的 key 顶上,
|
||||
// 否则「我在面板里删了它」会变成一句假话);
|
||||
// - **只有「没写」才回落 `.env`(= 代码默认值)**:maxResults / timeout / 云家的 baseUrl。
|
||||
// 前两个标量在「没写」与「写成空」之间无法区分,而它们的默认值本来就是引导面的一部分;
|
||||
// 云家 baseUrl 回落到厂商默认地址(它是代码默认值,不是凭据),searxng 的地址**不回落**
|
||||
// —— 在 searxng 身上「地址为空」就是「关掉这家」的开关语义,回落会让"清空地址"变成假话。
|
||||
func effectiveSearchConfig(env config.WebSearchConfig, f runtimeconfig.WebSearchFile) (config.WebSearchConfig, error) {
|
||||
out := env
|
||||
out.Provider = f.Provider
|
||||
out.Fallback = append([]string(nil), f.Fallback...)
|
||||
if f.MaxResults > 0 {
|
||||
out.MaxResults = f.MaxResults
|
||||
}
|
||||
d, err := f.TimeoutDuration()
|
||||
if err != nil {
|
||||
return env, err
|
||||
}
|
||||
if d > 0 {
|
||||
out.Timeout = d
|
||||
}
|
||||
// 凭据一律清零后按条目回填:文件没给的家就是没配。
|
||||
out.ZhipuAPIKey = ""
|
||||
out.AliyunIQSAPIKey = ""
|
||||
out.BochaAPIKey = ""
|
||||
out.VolcengineAPIKey = ""
|
||||
out.BaiduQianfanAPIKey = ""
|
||||
out.Endpoint = ""
|
||||
for _, e := range f.Providers {
|
||||
if !e.IsEnabled() {
|
||||
continue
|
||||
}
|
||||
name := strings.ToLower(strings.TrimSpace(e.Provider))
|
||||
base := strings.TrimSpace(e.BaseURL)
|
||||
switch name {
|
||||
case search.NameSearXNG:
|
||||
out.Endpoint = base
|
||||
case search.NameZhipu:
|
||||
out.ZhipuAPIKey = e.APIKey
|
||||
out.ZhipuBaseURL = orDefault(base, out.ZhipuBaseURL)
|
||||
case search.NameAliyunIQS:
|
||||
out.AliyunIQSAPIKey = e.APIKey
|
||||
out.AliyunIQSBaseURL = orDefault(base, out.AliyunIQSBaseURL)
|
||||
case search.NameBocha:
|
||||
out.BochaAPIKey = e.APIKey
|
||||
out.BochaBaseURL = orDefault(base, out.BochaBaseURL)
|
||||
case search.NameVolcengine:
|
||||
out.VolcengineAPIKey = e.APIKey
|
||||
out.VolcengineBaseURL = orDefault(base, out.VolcengineBaseURL)
|
||||
case search.NameBaiduQianfan:
|
||||
out.BaiduQianfanAPIKey = e.APIKey
|
||||
out.BaiduQianfanBaseURL = orDefault(base, out.BaiduQianfanBaseURL)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// warnUnknownSearchProviders 对文件里本期没登记的家记一条 WARN。
|
||||
//
|
||||
// 它们不可能被选中(选中的家由 io 工具集在启动期校验),但「配了却不生效」必须说出来 ——
|
||||
// 静默忽略正是「我明明配了 Tavily 却搜不出」那类报障的来源。
|
||||
func warnUnknownSearchProviders(log *slog.Logger, path string, f runtimeconfig.WebSearchFile) {
|
||||
for _, e := range f.Providers {
|
||||
name := strings.ToLower(strings.TrimSpace(e.Provider))
|
||||
if name == "" || search.Known(name) {
|
||||
continue
|
||||
}
|
||||
log.Warn("web-search 面板配置里有未登记的提供商,已忽略",
|
||||
"path", path, "provider", name, "known", search.KnownNames())
|
||||
}
|
||||
}
|
||||
|
||||
// selectsSearXNG 报告当前配置选的是不是 searxng(大小写与空白不敏感)。
|
||||
func selectsSearXNG(ws config.WebSearchConfig) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(ws.Provider), search.NameSearXNG)
|
||||
}
|
||||
|
||||
// searchProviderWire 是「一家云 provider 的配置投影」:名字 + 凭据 + 基地址。
|
||||
// searxng 不在这张表里(它的「基地址」就是 `WEB_SEARCH_ENDPOINT`,由 ResolveEndpoint 裁决)。
|
||||
type searchProviderWire struct {
|
||||
Name string
|
||||
APIKey string
|
||||
BaseURL string
|
||||
}
|
||||
|
||||
// searchProviderVector 是本期内置各家的配置投影(**一家一行**)。
|
||||
//
|
||||
// 它同时服务两处:装配接线表(searchProviderSpecs)与 `.env` 播种(seedWebSearchFile)——
|
||||
// 一家一处,新增一家 = 这里加一行 + search 包加一个文件。
|
||||
func searchProviderVector(ws config.WebSearchConfig) []searchProviderWire {
|
||||
return []searchProviderWire{
|
||||
{Name: search.NameZhipu, APIKey: ws.ZhipuAPIKey, BaseURL: ws.ZhipuBaseURL},
|
||||
{Name: search.NameAliyunIQS, APIKey: ws.AliyunIQSAPIKey, BaseURL: ws.AliyunIQSBaseURL},
|
||||
{Name: search.NameBocha, APIKey: ws.BochaAPIKey, BaseURL: ws.BochaBaseURL},
|
||||
{Name: search.NameVolcengine, APIKey: ws.VolcengineAPIKey, BaseURL: ws.VolcengineBaseURL},
|
||||
{Name: search.NameBaiduQianfan, APIKey: ws.BaiduQianfanAPIKey, BaseURL: ws.BaiduQianfanBaseURL},
|
||||
}
|
||||
}
|
||||
|
||||
// searchProviderSpecs 把生效配置翻译成各家接线。
|
||||
//
|
||||
// Configured 的口径由 search.Configured 统一判定:searxng 看端点、云家看 API key;
|
||||
// false ⇒ 该家不进注册表、不出现在可用清单里。**密钥只在这里流转,绝不写日志。**
|
||||
func (a *app) searchProviderSpecs() []iotools.SearchProviderSpec {
|
||||
ws := a.cfg.WebSearch
|
||||
func searchProviderSpecs(ws config.WebSearchConfig) []iotools.SearchProviderSpec {
|
||||
endpoint := ws.ResolveEndpoint()
|
||||
|
||||
specs := []iotools.SearchProviderSpec{{
|
||||
Name: search.NameSearXNG,
|
||||
BaseURL: endpoint.URL,
|
||||
Configured: endpoint.Enabled,
|
||||
}}
|
||||
// 各家独立一组键(design/27 §3):新增一家 = 这里加一行 + search 包加一个文件。
|
||||
for _, p := range []struct{ name, key, base string }{
|
||||
{search.NameZhipu, ws.ZhipuAPIKey, ws.ZhipuBaseURL},
|
||||
{search.NameAliyunIQS, ws.AliyunIQSAPIKey, ws.AliyunIQSBaseURL},
|
||||
{search.NameBocha, ws.BochaAPIKey, ws.BochaBaseURL},
|
||||
{search.NameVolcengine, ws.VolcengineAPIKey, ws.VolcengineBaseURL},
|
||||
{search.NameBaiduQianfan, ws.BaiduQianfanAPIKey, ws.BaiduQianfanBaseURL},
|
||||
} {
|
||||
// 各家独立一组键(design/27 §3):新增一家 = searchProviderVector 加一行 + 一个文件。
|
||||
for _, p := range searchProviderVector(ws) {
|
||||
specs = append(specs, iotools.SearchProviderSpec{
|
||||
Name: p.name,
|
||||
APIKey: p.key,
|
||||
BaseURL: p.base,
|
||||
Configured: search.Configured(p.name, p.key, p.base),
|
||||
Name: p.Name,
|
||||
APIKey: p.APIKey,
|
||||
BaseURL: p.BaseURL,
|
||||
Configured: search.Configured(p.Name, p.APIKey, p.BaseURL),
|
||||
})
|
||||
}
|
||||
return specs
|
||||
@@ -149,3 +327,28 @@ func availableProviderNames(specs []iotools.SearchProviderSpec) []string {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// seedEndpoint 把 `WEB_SEARCH_ENDPOINT` 的取值播进 searxng 条目:显式关闭的哨兵落成空串。
|
||||
func seedEndpoint(endpoint string) string {
|
||||
if config.IsWebSearchOffSentinel(endpoint) {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(endpoint)
|
||||
}
|
||||
|
||||
// seedTimeout 把超时播成 duration 字符串:未声明 / 笔误(≤0)落成空 = 未声明。
|
||||
func seedTimeout(d time.Duration) string {
|
||||
if d <= 0 {
|
||||
return ""
|
||||
}
|
||||
return d.String()
|
||||
}
|
||||
|
||||
// orDefault 在 value 去空白后非空时用它,否则用 fallback(云家 baseUrl 的「没写就回落
|
||||
// 代码默认值」口径,见 effectiveSearchConfig 的权威边界)。
|
||||
func orDefault(value, fallback string) string {
|
||||
if s := strings.TrimSpace(value); s != "" {
|
||||
return s
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
|
||||
)
|
||||
|
||||
// 本文件钉住 PLG-4:web-search 的**可变源**(`$DATA_DIR/config/plugins/web-search.json`)
|
||||
// 在组合根里的语义 —— 「先读可变源、缺失回落 `.env` 播种」(design/09 §5.7 E):
|
||||
// `.env` 从此只是**引导面**(缺失时播种一次),面板文件才是配置面。
|
||||
//
|
||||
// 单独成文件的理由:`wire_web_search_test.go` 已经接近文件行数上限(AGENTS §10),
|
||||
// 而这一组是另一片(PLG-4)的判据,混进去会同时踩两条纪律。
|
||||
|
||||
// wsPanelPath 返回某份配置对应的可变源落点。
|
||||
func wsPanelPath(cfg *config.Config) string {
|
||||
return runtimeconfig.WebSearchConfigPath(cfg.DataDir)
|
||||
}
|
||||
|
||||
// wsWriteRawPanel 手写一份面板配置(建好父目录;用于模拟人工救回 / 损坏文件)。
|
||||
func wsWriteRawPanel(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWireIOTools_PanelConfigSeedsFromEnvThenIsAuthoritative 是可变源的主判据:
|
||||
// - 文件缺失 ⇒ 由 `.env` 播种落盘(本次行为与 `.env` 一致),且**凭据不进日志**;
|
||||
// - 播种之后 ⇒ 面板文件是**唯一来源**(`.env` 后来改成关闭也不影响它)。
|
||||
func TestWireIOTools_PanelConfigSeedsFromEnvThenIsAuthoritative(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
path := runtimeconfig.WebSearchConfigPath(dir)
|
||||
|
||||
first := wsLoadConfig(t,
|
||||
"WEB_SEARCH_PROVIDER="+search.NameBocha,
|
||||
"WEB_SEARCH_BOCHA_API_KEY=sk-seed",
|
||||
"WEB_SEARCH_BOCHA_BASE_URL=https://seed.example",
|
||||
)
|
||||
first.DataDir = dir
|
||||
var buf bytes.Buffer
|
||||
if !wsHasTool(wsSurfaceApp(t, first, &buf), "web-search") {
|
||||
t.Fatal("首次启动(.env 配好 bocha)应在注册面里")
|
||||
}
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "已按 .env 播种") {
|
||||
t.Fatalf("播种必须留下可诊断日志: %q", out)
|
||||
}
|
||||
if strings.Contains(out, "sk-seed") {
|
||||
t.Fatalf("播种日志泄漏了凭据: %q", out)
|
||||
}
|
||||
|
||||
f, found, err := runtimeconfig.LoadWebSearch(path)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("播种后文件必须在盘上: found=%v err=%v", found, err)
|
||||
}
|
||||
if f.Provider != search.NameBocha {
|
||||
t.Fatalf("播种的主 provider = %q, want %q", f.Provider, search.NameBocha)
|
||||
}
|
||||
if len(f.Providers) != len(search.KnownNames()) {
|
||||
t.Fatalf("播种必须列全本期内置的家:%d 条, want %d", len(f.Providers), len(search.KnownNames()))
|
||||
}
|
||||
seeded := false
|
||||
for _, e := range f.Providers {
|
||||
if e.Provider != search.NameBocha {
|
||||
continue
|
||||
}
|
||||
seeded = true
|
||||
if e.APIKey != "sk-seed" || e.BaseURL != "https://seed.example" || !e.IsEnabled() {
|
||||
t.Fatalf("播种的 bocha 条目 = %+v,want .env 的凭据/地址/启用", e)
|
||||
}
|
||||
}
|
||||
if !seeded {
|
||||
t.Fatal("播种里没有 bocha 条目")
|
||||
}
|
||||
|
||||
// 第二次启动:.env 明确关掉搜索 —— 面板文件仍是唯一来源(.env 只在缺失时播种一次)。
|
||||
second := wsLoadConfig(t, "WEB_SEARCH_PROVIDER=")
|
||||
second.DataDir = dir
|
||||
if !wsHasTool(wsSurfaceApp(t, second, nil), "web-search") {
|
||||
t.Fatal(".env 改成关闭后,面板文件(bocha 已配)仍应是唯一来源")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWireIOTools_PanelConfigWinsOverEnv 钉住「面板优先」不是一句口号:同一份 `.env` 下,
|
||||
// 面板把 searxng 指到假上游,查询就必须真的打到那里(`.env` 的默认端点 127.0.0.1:38471
|
||||
// 根本没被用)。
|
||||
func TestWireIOTools_PanelConfigWinsOverEnv(t *testing.T) {
|
||||
t.Parallel()
|
||||
hits := make(chan *http.Request, 1)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits <- r
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"results":[{"title":"A","url":"http://a.example","content":"alpha"}]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := wsLoadConfig(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG)
|
||||
if err := runtimeconfig.SaveWebSearch(wsPanelPath(cfg), runtimeconfig.WebSearchFile{
|
||||
Version: runtimeconfig.WebSearchVersion,
|
||||
Provider: search.NameSearXNG,
|
||||
MaxResults: 5,
|
||||
Providers: []runtimeconfig.ProviderEntry{
|
||||
{Provider: search.NameSearXNG, BaseURL: srv.URL + "/search", Enabled: runtimeconfig.EnableFlag(true)},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("写面板配置: %v", err)
|
||||
}
|
||||
|
||||
a := wsSurfaceApp(t, cfg, nil)
|
||||
tl, err := a.caps.Get("web-search")
|
||||
if err != nil {
|
||||
t.Fatalf("web-search 不在注册面里: %v", err)
|
||||
}
|
||||
exec, ok := tl.Value.(interface {
|
||||
Execute(context.Context, any) (any, error)
|
||||
})
|
||||
if !ok {
|
||||
t.Fatalf("注册表里的值类型 = %T, 不是可执行工具", tl.Value)
|
||||
}
|
||||
if _, err := exec.Execute(context.Background(), map[string]any{"query": "cyrene"}); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
select {
|
||||
case r := <-hits:
|
||||
if r.URL.Path != "/search" {
|
||||
t.Errorf("请求路径 = %q, want /search(面板里那个端点必须被原样使用)", r.URL.Path)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("面板里的端点一个请求都没收到 —— 可变源没被读进去(还在用 .env 的默认端点)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWireIOTools_PanelConfigCorruptFallsBackToEnv:非法文件 ⇒ 记 WARN、回落 `.env`、
|
||||
// **不覆盖**原文件(损坏的配置可以人工救回)。
|
||||
func TestWireIOTools_PanelConfigCorruptFallsBackToEnv(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := wsLoadConfig(t,
|
||||
"WEB_SEARCH_PROVIDER="+search.NameBocha,
|
||||
"WEB_SEARCH_BOCHA_API_KEY=sk-env",
|
||||
)
|
||||
path := wsPanelPath(cfg)
|
||||
const broken = `{"version":1,"providers":[{"provider":"bocha"},{"provider":"BOCHA"}]}`
|
||||
wsWriteRawPanel(t, path, broken)
|
||||
|
||||
var buf bytes.Buffer
|
||||
if !wsHasTool(wsSurfaceApp(t, cfg, &buf), "web-search") {
|
||||
t.Fatal("面板配置非法时必须回落 .env(这份 .env 里 bocha 是配好的)")
|
||||
}
|
||||
if out := buf.String(); !strings.Contains(out, "面板配置非法") {
|
||||
t.Fatalf("非法文件必须留下可诊断的 WARN: %q", out)
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if string(raw) != broken {
|
||||
t.Fatalf("回落 .env 时不得覆盖原文件:%q", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWireIOTools_PanelConfigUnknownProviderIsWarned 钉住「配了却不生效必须说出来」:
|
||||
// 面板文件里出现本期没登记的家 ⇒ 一条带名字的 WARN(绝不静默忽略),且不拖累整把工具。
|
||||
func TestWireIOTools_PanelConfigUnknownProviderIsWarned(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := wsLoadConfig(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG)
|
||||
if err := runtimeconfig.SaveWebSearch(wsPanelPath(cfg), runtimeconfig.WebSearchFile{
|
||||
Version: runtimeconfig.WebSearchVersion,
|
||||
Provider: search.NameSearXNG,
|
||||
Providers: []runtimeconfig.ProviderEntry{
|
||||
{Provider: search.NameSearXNG, BaseURL: "http://127.0.0.1:38471"},
|
||||
{Provider: "tavily", APIKey: "sk-not-impl"},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("写面板配置: %v", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if !wsHasTool(wsSurfaceApp(t, cfg, &buf), "web-search") {
|
||||
t.Fatal("未登记的家不该把整把工具拖下水(它只是被忽略)")
|
||||
}
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "未登记的提供商") || !strings.Contains(out, "tavily") {
|
||||
t.Fatalf("未登记的家必须留下带名字的 WARN: %q", out)
|
||||
}
|
||||
if strings.Contains(out, "sk-not-impl") {
|
||||
t.Fatalf("WARN 泄漏了凭据: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWireIOTools_PanelConfigClearedProviderDoesNotReviveFromEnv 钉住「凭据以文件为准」:
|
||||
// 面板文件里**没有**这一家(或它的 key 为空)= 这家没配,**不得**从 `.env` 的旧 key 复活
|
||||
// —— 否则「我在面板里删了它」会变成一句假话。主 provider 此时没配齐 ⇒ 按既有语义启动报错。
|
||||
func TestWireIOTools_PanelConfigClearedProviderDoesNotReviveFromEnv(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := wsLoadConfig(t,
|
||||
"WEB_SEARCH_PROVIDER="+search.NameBocha,
|
||||
"WEB_SEARCH_BOCHA_API_KEY=sk-env",
|
||||
"WEB_SEARCH_BOCHA_BASE_URL=https://env.example",
|
||||
)
|
||||
// 面板文件里只留 searxng:bocha 在文件里**不存在**。
|
||||
if err := runtimeconfig.SaveWebSearch(wsPanelPath(cfg), runtimeconfig.WebSearchFile{
|
||||
Version: runtimeconfig.WebSearchVersion,
|
||||
Provider: search.NameBocha,
|
||||
Providers: []runtimeconfig.ProviderEntry{
|
||||
{Provider: search.NameSearXNG, BaseURL: "http://127.0.0.1:38471"},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("写面板配置: %v", err)
|
||||
}
|
||||
err := wsNewTools(t, cfg)
|
||||
if !errors.Is(err, search.ErrNotConfigured) {
|
||||
t.Fatalf("err = %v, want errors.Is(..., ErrNotConfigured):文件里没有的家不得从 .env 复活", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWireIOTools_PanelSeedNormalizesOffSentinel 钉住播种时的关闭哨兵归一化:
|
||||
// `WEB_SEARCH_ENDPOINT=off` 落盘应写成**空地址**(语义相同:不出这家),而不是把
|
||||
// 字面量 "off" 当成地址存进面板(面板里不该出现一个不是地址的地址)。
|
||||
func TestWireIOTools_PanelSeedNormalizesOffSentinel(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := wsLoadConfig(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG, "WEB_SEARCH_ENDPOINT=off")
|
||||
if wsHasTool(wsSurfaceApp(t, cfg, nil), "web-search") {
|
||||
t.Fatal("端点显式关闭 ⇒ web-search 不得进注册面")
|
||||
}
|
||||
f, found, err := runtimeconfig.LoadWebSearch(wsPanelPath(cfg))
|
||||
if err != nil || !found {
|
||||
t.Fatalf("播种后文件必须在盘上: found=%v err=%v", found, err)
|
||||
}
|
||||
for _, e := range f.Providers {
|
||||
if e.Provider != search.NameSearXNG {
|
||||
continue
|
||||
}
|
||||
if e.BaseURL != "" {
|
||||
t.Fatalf("关闭哨兵必须落成空串,得到 %q", e.BaseURL)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatal("播种里没有 searxng 条目")
|
||||
}
|
||||
|
||||
// TestWireIOTools_PanelConfigDisabledProviderIsNotConfigured 钉住「停用态真的生效」:
|
||||
// 文件里把 searxng 停用(enabled=false)而它又是主 provider ⇒ 工具不进注册面
|
||||
// (与 `.env` 里把端点写成 off 的既有语义一致,绝不静默回落到 .env 的端点)。
|
||||
func TestWireIOTools_PanelConfigDisabledProviderIsNotConfigured(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := wsLoadConfig(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG)
|
||||
if err := runtimeconfig.SaveWebSearch(wsPanelPath(cfg), runtimeconfig.WebSearchFile{
|
||||
Version: runtimeconfig.WebSearchVersion,
|
||||
Provider: search.NameSearXNG,
|
||||
Providers: []runtimeconfig.ProviderEntry{
|
||||
{Provider: search.NameSearXNG, BaseURL: "http://127.0.0.1:38471", Enabled: runtimeconfig.EnableFlag(false)},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("写面板配置: %v", err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
a := wsSurfaceApp(t, cfg, &buf)
|
||||
if wsHasTool(a, "web-search") {
|
||||
t.Fatalf("searxng 被停用后 web-search 不得进注册面,实际 = %v", toolNames(a))
|
||||
}
|
||||
for _, name := range []string{"file", "http", "web-fetch", "knowledge", "document-read"} {
|
||||
if !wsHasTool(a, name) {
|
||||
t.Errorf("%q 不得因 web-search 停用而消失", name)
|
||||
}
|
||||
}
|
||||
if out := buf.String(); !strings.Contains(out, "web-search tool not registered") {
|
||||
t.Fatalf("关闭必须留一条可诊断的 WARN: %q", out)
|
||||
}
|
||||
}
|
||||
@@ -39,12 +39,17 @@ func wsTestEnvFile(t *testing.T, extra ...string) string {
|
||||
}
|
||||
|
||||
// wsLoadConfig 按给定 .env 行加载配置(进程环境优先级更高,由 config 层负责)。
|
||||
//
|
||||
// DataDir 一律指到本用例自己的临时目录:web-search 的可变源
|
||||
// (`$DATA_DIR/config/plugins/web-search.json`,PLG-4)会**按启动期语义**读/播种它,
|
||||
// 默认的 `./data` 会让并行用例互相踩同一份文件(且把仓库工作目录写脏)。
|
||||
func wsLoadConfig(t *testing.T, extra ...string) *config.Config {
|
||||
t.Helper()
|
||||
cfg, err := config.Load(wsTestEnvFile(t, extra...))
|
||||
if err != nil {
|
||||
t.Fatalf("config.Load: %v", err)
|
||||
}
|
||||
cfg.DataDir = t.TempDir()
|
||||
return cfg
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user