From 120d926cf8e7da0db0325370c6c64a6345f86d81 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 6 Oct 2026 09:04:04 +0800 Subject: [PATCH] =?UTF-8?q?feat(search):=20=E6=90=9C=E7=B4=A2=20provider?= =?UTF-8?q?=20=E5=8F=AF=E5=8F=98=E6=BA=90=20+=20=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E5=A3=B0=E6=98=8E=E9=9D=A2=E6=9D=BF=EF=BC=88PLG-4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 $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 的相关注册留给主会话。 --- cmd/cyrene/wire_io_tools.go | 285 ++++++++++++++--- cmd/cyrene/wire_web_search_panel_test.go | 286 ++++++++++++++++++ cmd/cyrene/wire_web_search_test.go | 5 + internal/config/runtimeconfig/websearch.go | 263 ++++++++++++++++ .../config/runtimeconfig/websearch_test.go | 216 +++++++++++++ internal/config/websearch.go | 5 + plugins/tools/io/search/panel.go | 109 +++++++ plugins/tools/io/search/panel_test.go | 218 +++++++++++++ plugins/tools/io/search/paneldata.go | 87 ++++++ plugins/tools/io/search/probe.go | 189 ++++++++++++ plugins/tools/io/search/probe_test.go | 189 ++++++++++++ 11 files changed, 1811 insertions(+), 41 deletions(-) create mode 100644 cmd/cyrene/wire_web_search_panel_test.go create mode 100644 internal/config/runtimeconfig/websearch.go create mode 100644 internal/config/runtimeconfig/websearch_test.go create mode 100644 plugins/tools/io/search/panel.go create mode 100644 plugins/tools/io/search/panel_test.go create mode 100644 plugins/tools/io/search/paneldata.go create mode 100644 plugins/tools/io/search/probe.go create mode 100644 plugins/tools/io/search/probe_test.go diff --git a/cmd/cyrene/wire_io_tools.go b/cmd/cyrene/wire_io_tools.go index 9078093..f6b56f0 100644 --- a/cmd/cyrene/wire_io_tools.go +++ b/cmd/cyrene/wire_io_tools.go @@ -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__*`(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 +} diff --git a/cmd/cyrene/wire_web_search_panel_test.go b/cmd/cyrene/wire_web_search_panel_test.go new file mode 100644 index 0000000..ff8042c --- /dev/null +++ b/cmd/cyrene/wire_web_search_panel_test.go @@ -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) + } +} diff --git a/cmd/cyrene/wire_web_search_test.go b/cmd/cyrene/wire_web_search_test.go index f35d1d4..c325aa9 100644 --- a/cmd/cyrene/wire_web_search_test.go +++ b/cmd/cyrene/wire_web_search_test.go @@ -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 } diff --git a/internal/config/runtimeconfig/websearch.go b/internal/config/runtimeconfig/websearch.go new file mode 100644 index 0000000..289d42e --- /dev/null +++ b/internal/config/runtimeconfig/websearch.go @@ -0,0 +1,263 @@ +// 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 +} diff --git a/internal/config/runtimeconfig/websearch_test.go b/internal/config/runtimeconfig/websearch_test.go new file mode 100644 index 0000000..03e58f0 --- /dev/null +++ b/internal/config/runtimeconfig/websearch_test.go @@ -0,0 +1,216 @@ +package runtimeconfig + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" +) + +// 本文件钉住 web-search 可变源(PLG-4 / design/13 §2.1)的形状与读写语义: +// 路径 = `$DATA_DIR/config/plugins/web-search.json`(文件名 = 插件 id)、 +// 0600 + temp+rename、缺失/为空不是错误、非法文件报可分类的错、读侧掩码。 + +// wsPath 是本用例自己的落点(落临时目录,绝不碰仓库工作目录)。 +func wsPath(t *testing.T) string { + t.Helper() + return WebSearchConfigPath(t.TempDir()) +} + +// wsWriteFile 落一份手写的文件(建好父目录 —— 真实部署里 `$DATA_DIR/config/plugins/` +// 由播种或面板保存建出来,人工救回时也得自己建)。 +func wsWriteFile(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) + } +} + +// 判据 ①:文件名 = 插件 id,落点在 `config/plugins/` 下(design/13 §2.1 的硬约定)。 +func TestWebSearchConfigPathIsPluginIdNamed(t *testing.T) { + t.Parallel() + got := filepath.ToSlash(WebSearchConfigPath("/data")) + if want := "/data/config/plugins/web-search.json"; got != want { + t.Fatalf("路径 = %q, want %q", got, want) + } +} + +// 判据 ②:文件缺失或全空白 = 「还没配过」,不是错误(组合根据此用 .env 播种)。 +func TestLoadWebSearchMissingOrBlankIsNotAnError(t *testing.T) { + t.Parallel() + path := wsPath(t) + if _, found, err := LoadWebSearch(path); err != nil || found { + t.Fatalf("缺失文件:found=%v err=%v,want false/nil", found, err) + } + wsWriteFile(t, path, " \n\t") + if _, found, err := LoadWebSearch(path); err != nil || found { + t.Fatalf("空白文件:found=%v err=%v,want false/nil", found, err) + } +} + +// 判据 ③:写盘 → 读回逐字一致(含三态 enabled:缺省 = 启用、显式 false = 停用)。 +func TestSaveLoadWebSearchRoundTrip(t *testing.T) { + t.Parallel() + path := wsPath(t) + in := WebSearchFile{ + Version: WebSearchVersion, + Provider: "bocha", + Fallback: []string{"searxng", "zhipu"}, + MaxResults: 5, + Timeout: "20s", + Providers: []ProviderEntry{ + {Provider: "searxng", BaseURL: "http://127.0.0.1:38471", Enabled: EnableFlag(true)}, + {Provider: "bocha", APIKey: "sk-x", BaseURL: "https://api.bocha.cn", Enabled: EnableFlag(false)}, + {Provider: "zhipu"}, + }, + } + if err := SaveWebSearch(path, in); err != nil { + t.Fatalf("SaveWebSearch: %v", err) + } + if _, err := os.Stat(path + ".tmp"); !os.IsNotExist(err) { + t.Fatalf("temp+rename 之后不得留下 .tmp(err=%v)", err) + } + out, found, err := LoadWebSearch(path) + if err != nil || !found { + t.Fatalf("LoadWebSearch: found=%v err=%v", found, err) + } + if out.Provider != in.Provider || out.MaxResults != in.MaxResults || out.Timeout != in.Timeout { + t.Fatalf("标量不往返:%+v", out) + } + if strings.Join(out.Fallback, ",") != strings.Join(in.Fallback, ",") { + t.Fatalf("fallback = %v, want %v", out.Fallback, in.Fallback) + } + if len(out.Providers) != len(in.Providers) { + t.Fatalf("providers 条数 = %d, want %d", len(out.Providers), len(in.Providers)) + } + // 三态:显式 false 必须仍是 false;缺省(没写 enabled)读回来必须是**启用**。 + if out.Providers[1].IsEnabled() { + t.Fatal("显式 enabled=false 读回来变成了启用") + } + if !out.Providers[2].IsEnabled() { + t.Fatal("没写 enabled 的条目必须是启用(缺省 = 启用)") + } + if got, err := out.TimeoutDuration(); err != nil || got != 20*time.Second { + t.Fatalf("TimeoutDuration = %v (err=%v), want 20s", got, err) + } +} + +// 判据 ③b:落盘权限 0600(文件可能带 API Key)。Windows 的 os.WriteFile 不表达 POSIX +// 权限位,故只在非 Windows 上断言 —— 不断言也不代表口径可以放宽,落盘调用本身就写了 0600。 +func TestSaveWebSearchFileModeIs0600(t *testing.T) { + t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("Windows 不表达 POSIX 权限位") + } + path := wsPath(t) + if err := SaveWebSearch(path, WebSearchFile{Version: WebSearchVersion, Providers: []ProviderEntry{}}); err != nil { + t.Fatalf("SaveWebSearch: %v", err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat: %v", err) + } + if perm := info.Mode().Perm(); perm != 0o600 { + t.Fatalf("权限 = %o, want 600", perm) + } +} + +// 判据 ④:结构非法一律拒绝,且错误可按 errors.Is 判类(调用方据此记 WARN 而不是崩)。 +func TestSaveWebSearchRejectsInvalidShapes(t *testing.T) { + t.Parallel() + cases := []struct { + name string + file WebSearchFile + }{ + {"版本过高", WebSearchFile{Version: WebSearchVersion + 1}}, + {"条数为负", WebSearchFile{Version: WebSearchVersion, MaxResults: -1}}, + {"超时不可解析", WebSearchFile{Version: WebSearchVersion, Timeout: "20"}}, + {"超时非正", WebSearchFile{Version: WebSearchVersion, Timeout: "-5s"}}, + {"provider 空", WebSearchFile{Version: WebSearchVersion, Providers: []ProviderEntry{{}}}}, + {"provider 含大写/横线", WebSearchFile{Version: WebSearchVersion, Providers: []ProviderEntry{{Provider: "Baidu-Qianfan"}}}}, + {"provider 重复", WebSearchFile{Version: WebSearchVersion, Providers: []ProviderEntry{{Provider: "bocha"}, {Provider: "BOCHA"}}}}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if err := SaveWebSearch(wsPath(t), tc.file); !errors.Is(err, ErrInvalidWebSearch) { + t.Fatalf("err = %v, want errors.Is(ErrInvalidWebSearch)", err) + } + }) + } +} + +// 判据 ⑤:损坏的文件**报错但不被改写**(Load 只读;调用方据此回落 .env 并保留现场)。 +func TestLoadWebSearchCorruptFileKeepsContents(t *testing.T) { + t.Parallel() + path := wsPath(t) + const broken = `{"version":1,"providers":[{"provider":"bocha"},{"provider":"BOCHA"}]}` + wsWriteFile(t, path, broken) + if _, found, err := LoadWebSearch(path); err == nil || found { + t.Fatalf("非法文件必须报错:found=%v err=%v", found, err) + } else if !errors.Is(err, ErrInvalidWebSearch) { + t.Fatalf("err = %v, want errors.Is(ErrInvalidWebSearch)", err) + } + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read: %v", err) + } + if string(raw) != broken { + t.Fatalf("读侧不得改写原文件:%q", raw) + } +} + +// 判据 ⑥:upsert 按 provider 名就位替换(不打乱顺序、不因大小写多出一条)。 +func TestUpsertProviderEntryReplacesInPlaceAndAppends(t *testing.T) { + t.Parallel() + f := WebSearchFile{Providers: []ProviderEntry{{Provider: "searxng"}, {Provider: "bocha"}}} + f = UpsertProviderEntry(f, ProviderEntry{Provider: " BOCHA ", APIKey: "sk-new"}) + if len(f.Providers) != 2 { + t.Fatalf("条数 = %d, want 2(同名必须就位替换而不是追加)", len(f.Providers)) + } + if f.Providers[1].Provider != "bocha" || f.Providers[1].APIKey != "sk-new" { + t.Fatalf("替换结果 = %+v(名字要归一化、顺序要保住)", f.Providers[1]) + } + f = UpsertProviderEntry(f, ProviderEntry{Provider: "zhipu"}) + if len(f.Providers) != 3 || f.Providers[2].Provider != "zhipu" { + t.Fatalf("新增必须追加到末尾:%+v", f.Providers) + } +} + +// 判据 ⑦:读侧出口掩码凭据(只留尾 4 位),且落盘内容不受影响。 +func TestMaskedWebSearchFileHidesCredentials(t *testing.T) { + t.Parallel() + in := WebSearchFile{ + Providers: []ProviderEntry{ + {Provider: "bocha", APIKey: "sk-1234567890"}, + {Provider: "zhipu", APIKey: "abc"}, + {Provider: "searxng", BaseURL: "http://127.0.0.1:38471"}, + }, + } + out := MaskedWebSearchFile(in) + if out.Providers[0].APIKey != "****7890" { + t.Fatalf("掩码 = %q, want ****7890", out.Providers[0].APIKey) + } + if out.Providers[1].APIKey != "****" { + t.Fatalf("短凭据必须全掩,得到 %q", out.Providers[1].APIKey) + } + if in.Providers[0].APIKey != "sk-1234567890" { + t.Fatal("掩码不得改到原值(落盘内容必须原样)") + } + // 掩码后的值绝不是可直接回写的凭据,但仍要能看出「这家已配置」。 + raw, err := json.Marshal(out) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if strings.Contains(string(raw), "sk-1234567890") { + t.Fatalf("读侧出口泄漏了明文:%s", raw) + } +} diff --git a/internal/config/websearch.go b/internal/config/websearch.go index 9769e1f..98fa7ba 100644 --- a/internal/config/websearch.go +++ b/internal/config/websearch.go @@ -88,6 +88,11 @@ func (w WebSearchConfig) ResolveProvider() ProviderDecision { return ProviderDecision{Selected: raw} } +// IsWebSearchOffSentinel 是 isOffSentinel 的导出形式,供**组合根**在把 `.env` 的 +// web-search 段播种成面板可变源时复用同一套判定(PLG-4):播种要判「端点是不是显式关闭」, +// 若在别处重写一遍判定,两处迟早会漂移成不同的关闭语义。 +func IsWebSearchOffSentinel(raw string) bool { return isOffSentinel(raw) } + // isOffSentinel 报告取值是否属于「显式关闭」的哨兵词(空串 / off / none,大小写与 // 首尾空白不敏感)。它同时服务 WEB_SEARCH_PROVIDER 与 WEB_SEARCH_ENDPOINT 两个键。 func isOffSentinel(raw string) bool { diff --git a/plugins/tools/io/search/panel.go b/plugins/tools/io/search/panel.go new file mode 100644 index 0000000..8a1e777 --- /dev/null +++ b/plugins/tools/io/search/panel.go @@ -0,0 +1,109 @@ +package search + +// 本文件是 web-search 插件向**面板**声明的管理页面:design/09 §5.2 的 `adminPanels`, +// 形状即 `pkg/plugins/sdk` 的 `Panel`。 +// +// 声明只说三件事:**要什么字段、怎么渲染、动作叫什么**。它 +// +// - 不注入任何 JS/CSS/HTML、 +// - 不给 URL(只有动作名,走宿主的通用动作端点分发)、 +// - 不开任何路由。 +// +// 这三条是 design/09 §5.2.1 的红线:声明而非编码、声明里塞不进请求面(防 SSRF)、 +// 面板数据一律经插件管理器的端点。渲染与流程由宿主负责,本文件只负责「说得清楚」。 + +import "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" + +// PanelPluginID 是本插件在面板体系里的 id。它与可变源的文件名**同源**: +// `$DATA_DIR/config/plugins/web-search.json`(design/13 §2.1:文件名 = 插件 id), +// 两边由 panel_test.go 钉住。 +const PanelPluginID = "web-search" + +// PanelIDProviders 是「搜索提供商」页的面板 id。 +// 前端路由为 `/admin/plugin/web-search/providers`(design/09 §5.7 C)。 +const PanelIDProviders = "providers" + +// 本插件声明的**全部**动作名(design/09 §5.2 示例给出的那四个)。 +// +// 表里的动作名一律取自这里,通用动作端点也只接受这几个名字(未声明 ⇒ 404/403, +// design/09 §5.2.1 B)。把「声明」与「动作名」放同一个文件,是为了让「表里写了一个 +// 没实现的动作」这种半渲染在 review 时一眼可见 —— 它们就挨着。 +const ( + // ActionListProviders 列出全部 provider 配置(表格区块的数据源)。 + ActionListProviders = "plugin.listProviders" + // ActionSaveProvider 新增 / 修改一条 provider 配置(表单提交)。 + // 语义是 upsert:按 `provider` 名就位替换或追加。 + ActionSaveProvider = "plugin.saveProvider" + // ActionTestProvider 测试连通:对一条 provider 配置真发一次最小检索(见 probe.go)。 + ActionTestProvider = "plugin.testProvider" + // ActionReauthProvider 重配凭据:把该条带回表单。写侧的「未改动」必须用显式哨兵表达, + // **不能**靠空字符串(那会静默清空凭据,design/09 §5.4.2)。 + ActionReauthProvider = "plugin.reauthProvider" +) + +// ActionNames 返回本插件声明的动作名,顺序与 AdminPanels 里出现的顺序一致 +// (表格数据源 → 表单提交 → 行内动作)。调用方(插件管理器的动作端点)据此建白名单: +// 白名单外的动作名一律拒,绝不「先执行再看结果」。 +func ActionNames() []string { + return []string{ActionListProviders, ActionSaveProvider, ActionTestProvider, ActionReauthProvider} +} + +// AdminPanels 返回本插件声明的管理页面(design/09 §5.2 示例的 providers 页)。 +// +// 两处相对示例的**明确增补**(示例是形状示范,不是字段清单的穷举;两处都被本片的任务书 +// 要求): +// - 表单多一个 `enabled` 开关:任务书要求每家 provider **独立启用态**; +// - 表单的 `baseUrl` 用示例的 `endpoint` 语义(searxng 的端点 = 那家的基地址), +// 名字跟可变源的字段名走,免得面板字段名与文件字段名对不上。 +func AdminPanels() []sdk.Panel { + return []sdk.Panel{{ + ID: PanelIDProviders, + Title: "搜索提供商", + Description: "为 web-search 能力配置多个第三方搜索后端:每条独立凭据与基地址," + + "可单独启停并测试连通。改动落 $DATA_DIR/config/plugins/web-search.json,重启后仍然生效。", + Group: sdk.GroupPlugins, + Order: 20, + Icon: "search", + // 未启用的插件 ⇒ 入口不出现(design/09 §5.2.1 nav.item)。web-search 是编进二进制的 + // 内置能力,没有卸载路径 ⇒ 恒为启用;这里写条件是为了让「启用事实」由插件管理器 + // 给出(单一事实来源),而不是由面板猜。 + When: &sdk.When{Enabled: boolFlag(true)}, + Sections: []sdk.Section{ + { + Type: sdk.SectionForm, + ID: "add", + Title: "添加 / 修改提供商", + Fields: []sdk.Field{ + {Key: "provider", Label: "提供商", Type: sdk.FieldSelect, + OptionsFrom: "plugin:providers", Required: true}, + // secret 字段刻意不标 required:它同时承担「编辑既有条目」的输入, + // 而编辑时不重填凭据是常态(写侧哨兵语义,见 ActionReauthProvider)。 + {Key: "apiKey", Label: "API Key", Type: sdk.FieldSecret, + Placeholder: "留空 = 保持原值"}, + {Key: "baseUrl", Label: "接口地址", Type: sdk.FieldText, + Placeholder: "https://..."}, + {Key: "enabled", Label: "启用", Type: sdk.FieldSwitch}, + }, + Submit: &sdk.Submit{Action: ActionSaveProvider}, + }, + { + Type: sdk.SectionTable, + ID: "list", + Title: "已配置的提供商", + Source: &sdk.Source{Action: ActionListProviders}, + // 列 key 与 panelrow.go 的行字段同源;凭据不进任何列(读侧无明文)。 + Columns: []sdk.Column{ + {Key: "provider", Label: "提供商"}, + {Key: "state", Label: "状态"}, + }, + RowActions: []sdk.RowAction{ + {ID: "test", Label: "测试连通", Action: ActionTestProvider}, + {ID: "reauth", Label: "重配凭据", Action: ActionReauthProvider}, + }, + }, + }, + }} +} + +// boolFlag 是 `&true` / `&false` 的简写(sdk.When.Enabled 是三态指针)。 +func boolFlag(v bool) *bool { return &v } diff --git a/plugins/tools/io/search/panel_test.go b/plugins/tools/io/search/panel_test.go new file mode 100644 index 0000000..8a767b8 --- /dev/null +++ b/plugins/tools/io/search/panel_test.go @@ -0,0 +1,218 @@ +package search + +import ( + "encoding/json" + "sort" + "strings" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/paneldecl" + "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" + "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" +) + +// 本文件钉住 web-search 插件向面板声明的管理页面(design/09 §5.2 / §5.2.1): +// 声明必须能过宿主的**闭集校验**(否则 §5.2.1 的「非法声明 ⇒ 该面板不注册」会真的 +// 把它挡在门外),动作名必须与 ActionNames() 同源,表格状态的语义必须可断言。 + +// 判据 ①:声明必须过宿主校验(这是「面板真的会出现」的唯一凭据)。 +func TestAdminPanelsPassHostValidation(t *testing.T) { + t.Parallel() + if err := paneldecl.Validate(PanelPluginID, AdminPanels()); err != nil { + t.Fatalf("宿主校验不通过(面板会被整体拒掉): %v", err) + } +} + +// 判据 ②:面板里的每一个动作名都必须是 ActionNames() 里的成员(声明与动作表同源), +// 且 ActionNames() 本身无重复 —— 否则「声明了却没实现」或「白名单漏一个」都会变成 +// 前端点了没反应的半渲染。 +func TestAdminPanelsActionsAreDeclaredOnce(t *testing.T) { + t.Parallel() + declared := make(map[string]bool) + for _, n := range ActionNames() { + if declared[n] { + t.Fatalf("ActionNames() 里 %q 重复", n) + } + declared[n] = true + } + used := make([]string, 0, len(declared)) + for _, p := range AdminPanels() { + for _, s := range p.Sections { + if s.Submit != nil { + used = append(used, s.Submit.Action) + } + if s.Source != nil { + used = append(used, s.Source.Action) + } + for _, a := range s.RowActions { + used = append(used, a.Action) + } + } + } + if len(used) == 0 { + t.Fatal("声明里一个动作都没有:这张表点不动") + } + for _, a := range used { + if !declared[a] { + t.Errorf("面板声明了动作 %q,但它不在 ActionNames() 里(动作白名单会把它拒掉)", a) + } + } + // 反向:声明的动作都必须真的出现在面板里(否则白名单里挂着一个点不到的名字)。 + seen := make(map[string]bool, len(used)) + for _, a := range used { + seen[a] = true + } + for _, n := range ActionNames() { + if !seen[n] { + t.Errorf("ActionNames() 里的 %q 在面板声明里没有用到", n) + } + } +} + +// 判据 ③:面板 id 与可变源的文件名同源(design/13 §2.1:文件名 = 插件 id)。 +// 这两个字面量分处两个包,只有这条断言能把「改了插件 id 却忘了改文件落点」拦下来。 +func TestPanelPluginIDMatchesConfigFileName(t *testing.T) { + t.Parallel() + got := runtimeconfig.WebSearchConfigPath("/data") + if !strings.HasSuffix(got, PanelPluginID+".json") { + t.Fatalf("可变源落点 %q 的文件名必须等于插件 id %q", got, PanelPluginID) + } +} + +// 判据 ④:表格区块必须声明 source + columns + 行内动作(§5.2.1 的形状矩阵由宿主校验 +// 兜底,这里再钉一层语义:这张表就是用来看状态与点动作的)。 +func TestAdminPanelsTableShapeIsUsable(t *testing.T) { + t.Parallel() + var table *sdk.Section + for _, p := range AdminPanels() { + for i := range p.Sections { + if p.Sections[i].Type == sdk.SectionTable { + table = &p.Sections[i] + } + } + } + if table == nil { + t.Fatal("声明里没有 table 区块:多实例列表无处可看") + } + if table.Source == nil || table.Source.Action != ActionListProviders { + t.Fatalf("表格数据源 = %+v, want %s", table.Source, ActionListProviders) + } + keys := make([]string, 0, len(table.Columns)) + for _, c := range table.Columns { + keys = append(keys, c.Key) + } + for _, want := range []string{"provider", "state"} { + if !contains(keys, want) { + t.Errorf("表格列里缺 %q(行数据必须能显示出来): %v", want, keys) + } + } + // 行内动作至少要有「测试连通」(本片任务书点名的动作)。 + found := false + for _, a := range table.RowActions { + if a.Action == ActionTestProvider { + found = true + } + } + if !found { + t.Fatalf("表格行内动作里没有 %s: %+v", ActionTestProvider, table.RowActions) + } +} + +// 判据 ⑤:表格行与列同源(列 key 都能在行数据里取到)。 +func TestBuildRowsFieldsCoverDeclaredColumns(t *testing.T) { + t.Parallel() + rows := BuildRows([]ProviderInput{ + {Provider: NameBocha, APIKey: "sk-x", BaseURL: "https://api.bocha.cn", Enabled: true}, + }) + if len(rows) != 1 { + t.Fatalf("rows = %d, want 1", len(rows)) + } + raw, err := json.Marshal(rows[0]) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var obj map[string]any + if err := json.Unmarshal(raw, &obj); err != nil { + t.Fatalf("unmarshal: %v", err) + } + for _, want := range []string{"provider", "state"} { + if _, ok := obj[want]; !ok { + t.Errorf("行数据缺列 key %q: %s", want, raw) + } + } + if strings.Contains(string(raw), "sk-x") { + t.Fatalf("行数据泄漏了凭据: %s", raw) + } +} + +// 判据 ⑥:state 的判定顺序(根因在前):未登记 > 停用 > 未配齐 > 可用。 +func TestBuildRowsStates(t *testing.T) { + t.Parallel() + cases := []struct { + name string + in ProviderInput + state string + }{ + {"已登记已配齐", ProviderInput{Provider: NameBocha, APIKey: "k", BaseURL: "https://b", Enabled: true}, StateOK}, + {"名字没登记", ProviderInput{Provider: "tavily", APIKey: "k", BaseURL: "https://t", Enabled: true}, StateUnknown}, + {"名字没登记且停用(名字错了才是根因)", ProviderInput{Provider: "tavily", Enabled: false}, StateUnknown}, + {"显式停用", ProviderInput{Provider: NameBocha, APIKey: "k", BaseURL: "https://b", Enabled: false}, StateDisabled}, + {"云家缺 key", ProviderInput{Provider: NameBocha, BaseURL: "https://b", Enabled: true}, StateNotConfigured}, + {"searxng 缺地址", ProviderInput{Provider: NameSearXNG, Enabled: true}, StateNotConfigured}, + {"searxng 有地址", ProviderInput{Provider: NameSearXNG, BaseURL: "http://127.0.0.1:38471", Enabled: true}, StateOK}, + {"名字大小写与空白不敏感", ProviderInput{Provider: " BOCHA ", APIKey: "k", BaseURL: "https://b", Enabled: true}, StateOK}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := BuildRows([]ProviderInput{tc.in})[0] + if got.State != tc.state { + t.Fatalf("state = %q, want %q", got.State, tc.state) + } + }) + } +} + +// 判据 ⑦:下拉选项 = 本期内置的全部已登记家(稳定排序),且**含尚未配置的家** +// —— 表单的用途就是第一次配置,只列已配置的会让用户无从选起。 +func TestProviderOptionsAreAllKnownNames(t *testing.T) { + t.Parallel() + got := ProviderOptions() + want := append([]string(nil), KnownNames()...) + sort.Strings(want) + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("ProviderOptions() = %v, want %v", got, want) + } + if len(got) == 0 { + t.Fatal("选项为空:表单的下拉一上来就选不了东西") + } +} + +// 判据 ⑧:动作名是与插件管理器的**对外契约**(设计示例里的那四个字面量)。 +// 改字号就是改契约:这里钉住字面量,免得一次「顺手重命名」让前端与设计文档对不上。 +func TestDeclaredActionNamesMatchDesignExample(t *testing.T) { + t.Parallel() + want := []string{ + "plugin.listProviders", "plugin.saveProvider", "plugin.testProvider", "plugin.reauthProvider", + } + got := ActionNames() + if len(got) != len(want) { + t.Fatalf("ActionNames() = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("ActionNames()[%d] = %q, want %q", i, got[i], want[i]) + } + } +} + +// contains 是字符串切片成员判定(测试里只此一处,不值得抽包)。 +func contains(list []string, want string) bool { + for _, s := range list { + if s == want { + return true + } + } + return false +} diff --git a/plugins/tools/io/search/paneldata.go b/plugins/tools/io/search/paneldata.go new file mode 100644 index 0000000..e01bb34 --- /dev/null +++ b/plugins/tools/io/search/paneldata.go @@ -0,0 +1,87 @@ +package search + +import "strings" + +// 本文件是「搜索提供商」页的**数据面**:表格行与下拉选项的来源。 +// +// 它只做**翻译**(事实 → 面板要的形状),不读文件、不开路由、不鉴权 —— 那些是插件管理器 +// 与宿主端点的事(design/09 §5.2.1 B)。放这里的理由只有一个:`state` 与选项值的语义 +// 属于 provider 领域(要问 `Configured` / `KnownNames`),散到宿主里就会与面板声明各写一套。 + +// 表格 `state` 列的取值(design/09 §5.2 示例:「ok | 未配置 | 凭据失效 | 未知」)。 +// +// 「凭据失效」不在这里:它只能由一次真实调用得知(那是 plugin.testProvider 的结论, +// 见 probe.go),列表接口不该为了它去逐家发请求。 +const ( + // StateOK 表示这家可以用了(名字已登记且已配齐且启用)。 + StateOK = "ok" + // StateNotConfigured 表示这个名字已登记,但没配齐(云家缺 key、searxng 缺地址)。 + StateNotConfigured = "not_configured" + // StateDisabled 表示这条已被显式停用(enabled=false)。 + StateDisabled = "disabled" + // StateUnknown 表示这个名字本期没登记(写错了 / 还没做,如 tavily)。 + StateUnknown = "unknown" +) + +// ProviderInput 是构造一行所需的最小事实(一条 provider 配置)。 +// +// 刻意用平铺字段而不是引用 runtimeconfig 的类型:search 包是插件实现,不该认识宿主的 +// 配置文件形状(那份形状属于宿主,见 internal/config/runtimeconfig/websearch.go)。 +type ProviderInput struct { + // Provider 是 provider 名。 + Provider string + // APIKey 是上游凭据(判定是否配齐用,**绝不进行数据**)。 + APIKey string + // BaseURL 是上游基地址(同上,判定用)。 + BaseURL string + // Enabled 是启用态。 + Enabled bool +} + +// ProviderRow 是「已配置的提供商」表格的一行。字段名即表格列的 key(panel.go 的 columns)。 +// +// 凭据与基地址**都不进行数据**:列表只需要状态,凭据读侧无明文(design/09 §5.4.2), +// 而基地址可能带用户名口令。要看地址就点「重配凭据」,由表单一对一带回。 +type ProviderRow struct { + Provider string `json:"provider"` + State string `json:"state"` + Enabled bool `json:"enabled"` +} + +// BuildRows 把各条配置翻译成表格行。 +// +// 判定顺序是把「根因」排在前面:名字没登记(写错了)> 显式停用 > 没配齐 > 可用。 +// 一个停用的陌生名字报 `unknown` —— 名字错了才是要修的那处。 +func BuildRows(inputs []ProviderInput) []ProviderRow { + out := make([]ProviderRow, 0, len(inputs)) + for _, in := range inputs { + out = append(out, ProviderRow{ + Provider: strings.ToLower(strings.TrimSpace(in.Provider)), + State: providerState(in), + Enabled: in.Enabled, + }) + } + return out +} + +// providerState 判定一条配置的表格状态。 +func providerState(in ProviderInput) string { + name := strings.ToLower(strings.TrimSpace(in.Provider)) + if !Known(name) { + return StateUnknown + } + if !in.Enabled { + return StateDisabled + } + if !Configured(name, in.APIKey, in.BaseURL) { + return StateNotConfigured + } + return StateOK +} + +// ProviderOptions 返回 `optionsFrom: plugin:providers` 的选项值 —— 本期**全部已登记**的 +// provider 名(含尚未配置的家),稳定排序。 +// +// 为什么给全部而不是「已配置的家」:表单的用途就是**新增**一家,只列已配置的会让 +// 「第一次配置」无从选起。可用性不靠下拉限制,靠列表的 `state` 列如实呈现。 +func ProviderOptions() []string { return KnownNames() } diff --git a/plugins/tools/io/search/probe.go b/plugins/tools/io/search/probe.go new file mode 100644 index 0000000..cb8f638 --- /dev/null +++ b/plugins/tools/io/search/probe.go @@ -0,0 +1,189 @@ +package search + +import ( + "context" + "errors" + "fmt" + "strings" + "time" +) + +// 本文件是「测试连通」动作(design/09 §5.2 示例的 plugin.testProvider)的数据面: +// 对**一条** provider 配置真发一次最小检索,把结论分成「通了」与「为什么没通」。 +// +// 三条口径: +// +// 1. **真发一次请求**:连通性不靠 ping 主机、也不靠「配置看起来齐了」来假装 —— +// 那会给出「连通 OK 但一搜就报错」的假信号。这里走的就是工具运行时走的那条通路 +// (同一个 provider 构造、同一个共享归一化)。 +// 2. **空结果 ≠ 不通**(design/27 §1.4):上游正常应答但一条都没查到,如实报 +// no_results —— 既不冒充成功,也不冤枉成「该家不可用」。 +// 3. **密钥绝不进结果**:只回结构化错误码 + 固定文案,**绝不回上游原始报文** +// (错误体可能回显请求 URL 或凭据)。文案与错误码分离,前端可本地化(§5.4.6)。 +// +// 本文件不读配置文件、不开路由、不落审计 —— 那些是插件管理器与宿主端点的事。 + +// 探针的查询词与条数:一次**最小但真实**的检索(只要 1 条,省上游额度)。 +const ( + probeQuery = "cyrene" + probeCount = 1 +) + +// 探针结论的错误码(稳定标识;design/09 §5.4.6「错误码与文案分离」)。 +const ( + // ProbeUnknownProvider 报告名字本期没登记(写错了 / 还没做)。 + ProbeUnknownProvider = "unknown_provider" + // ProbeNotConfigured 报告这条没配齐(云家缺 API Key,或任何一家缺接口地址)。 + ProbeNotConfigured = "not_configured" + // ProbeUnauthorized 报告上游拒了凭据(401/403)。 + ProbeUnauthorized = "unauthorized" + // ProbeRateLimited 报告被上游限流(429)。 + ProbeRateLimited = "rate_limited" + // ProbeQuotaExceeded 报告额度用尽 / 欠费 / 未开通。 + ProbeQuotaExceeded = "quota_exceeded" + // ProbeNetwork 报告网络层失败(连不上 / DNS / 超时)。 + ProbeNetwork = "network" + // ProbeUpstream 报告上游 5xx 或其它无法进一步分类的失败状态。 + ProbeUpstream = "upstream" + // ProbeBadResponse 报告上游应答不是预期结构(或超过读取上限)。 + ProbeBadResponse = "bad_response" + // ProbeNoResults 报告上游正常应答但没有任何结果(**不算失败**,只是没内容)。 + ProbeNoResults = "no_results" + // ProbeUnknown 是兜底:未分类的失败。 + ProbeUnknown = "unknown" +) + +// ProbeResult 是「测试连通」的结论(面板行内动作的返回体)。 +type ProbeResult struct { + // OK 报告这次探针真的拿到结果了(空结果不是 OK)。 + OK bool `json:"ok"` + // Provider 是被探的那一家(小写归一后的名字)。 + Provider string `json:"provider"` + // Count 是这次探针拿到的结果条数(OK 时 > 0)。 + Count int `json:"count"` + // LatencyMS 是这次探针的耗时(毫秒),含建连与读体。 + LatencyMS int64 `json:"latencyMs"` + // ErrorCode 是失败时的稳定错误码(OK 时为空)。 + ErrorCode string `json:"errorCode,omitempty"` + // Error 是与错误码配对的固定文案(**不含上游原文、不含凭据**)。 + Error string `json:"error,omitempty"` +} + +// Probe 对 name 这一家做一次真实的最小检索(count ≤ 0 时用 probeCount)。 +// +// cfg 由调用方填好(凭据 / 基地址 / 共享 HTTP 客户端 / SSRF 守卫),与工具运行时用的是 +// 同一套接线;cfg.Client 为 nil 时如实报 not_configured(发不出请求就是没配齐)。 +func Probe(ctx context.Context, name string, cfg Config, count int) (res ProbeResult) { + res.Provider = strings.ToLower(strings.TrimSpace(name)) + if count <= 0 { + count = probeCount + } + start := time.Now() + defer func() { res.LatencyMS = time.Since(start).Milliseconds() }() + + res.ErrorCode, res.Error = probePreflight(res.Provider, cfg) + if res.ErrorCode != "" { + return res + } + items, err := probeSearch(ctx, res.Provider, cfg, count) + if err != nil { + res.ErrorCode = ProbeErrorCode(err) + res.Error = probeMessage(res.ErrorCode) + return res + } + if len(items) == 0 { + res.ErrorCode = ProbeNoResults + res.Error = probeMessage(ProbeNoResults) + return res + } + res.OK = true + res.Count = len(items) + return res +} + +// probePreflight 做发请求前的判定,返回 (错误码, 文案);都为空表示可以发。 +// +// 为什么比运行时更严(运行时云家只看 API Key):运行时的基地址有代码默认值兜底 +// (`WEB_SEARCH__BASE_URL` 的 tag 默认值),而面板里存下来的那条**可能真的没有地址** +// —— 那种情况下探针必须说「没配齐」,而不是发一个相对 URL 出去报一句看不懂的错。 +func probePreflight(name string, cfg Config) (string, string) { + if !Known(name) { + return ProbeUnknownProvider, "未登记的搜索提供商(本期接入位:" + + strings.Join(KnownNames(), ", ") + ")" + } + if !Configured(name, cfg.APIKey, cfg.BaseURL) || strings.TrimSpace(cfg.BaseURL) == "" { + return ProbeNotConfigured, probeMessage(ProbeNotConfigured) + } + return "", "" +} + +// probeSearch 建一家 provider 并真发一次检索。error 为 nil 时 items 可能为空 +// (空结果由调用方按「空 ≠ 不通」处置)。 +func probeSearch(ctx context.Context, name string, cfg Config, count int) ([]Result, error) { + reg := Build( + []Wire{{Name: name, APIKey: cfg.APIKey, BaseURL: cfg.BaseURL, Configured: true}}, + cfg.Client, cfg.MaxBytes, cfg.Guard) + p, ok := reg.Lookup(name) + if !ok { + // 只有「已登记却构造不出来」才走到这里(与 checkSearchSelection 的 ErrNotConfigured 同源)。 + return nil, fmt.Errorf("%w: probe: provider %q cannot be built", ErrNotConfigured, name) + } + return p.Search(ctx, Query{Text: probeQuery, Count: count}) +} + +// ProbeErrorCode 把一次探针失败归类成稳定错误码(errors.Is 判定)。 +func ProbeErrorCode(err error) string { + switch { + case err == nil: + return "" + case errors.Is(err, ErrUnknownProvider): + return ProbeUnknownProvider + case errors.Is(err, ErrNotConfigured): + return ProbeNotConfigured + case errors.Is(err, ErrUnauthorized): + return ProbeUnauthorized + case errors.Is(err, ErrRateLimited): + return ProbeRateLimited + case errors.Is(err, ErrQuotaExceeded): + return ProbeQuotaExceeded + case errors.Is(err, ErrNetwork): + return ProbeNetwork + case errors.Is(err, ErrResponseTooLarge), errors.Is(err, ErrBadResponse): + return ProbeBadResponse + case errors.Is(err, ErrNoResults): + return ProbeNoResults + case errors.Is(err, ErrUpstream): + return ProbeUpstream + default: + return ProbeUnknown + } +} + +// probeMessage 给出与错误码配对的固定中文文案。 +// +// 刻意**不回上游原文**:错误体与网络错误都可能回显请求 URL(某些上游把 key 放在查询串里) +// 或凭据,而这份文案会进面板响应。要看原文就走服务端日志(那里也不含凭据)。 +func probeMessage(code string) string { + switch code { + case ProbeUnknownProvider: + return "未登记的搜索提供商:名字写错了,或本期还没做这一家" + case ProbeNotConfigured: + return "这条没配齐:云家需要 API Key,任何一家都需要接口地址" + case ProbeUnauthorized: + return "上游拒绝了凭据(401/403):请核对 API Key 是否有效" + case ProbeRateLimited: + return "被上游限流(429):稍后重试" + case ProbeQuotaExceeded: + return "上游额度用尽 / 欠费 / 未开通该服务" + case ProbeNetwork: + return "连不上上游:网络/域名/超时问题" + case ProbeUpstream: + return "上游返回了失败状态(5xx 或其它)" + case ProbeBadResponse: + return "上游应答不是预期结构(或超过读取上限)" + case ProbeNoResults: + return "连通正常,但这次探针没有查到结果(空结果不等于失败,换别的查询词再试)" + default: + return "探针失败,原因未分类(详见服务端日志)" + } +} diff --git a/plugins/tools/io/search/probe_test.go b/plugins/tools/io/search/probe_test.go new file mode 100644 index 0000000..bd527d0 --- /dev/null +++ b/plugins/tools/io/search/probe_test.go @@ -0,0 +1,189 @@ +package search + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// 本文件钉住「测试连通」探针(design/09 §5.2 示例的 plugin.testProvider)的结论语义: +// 真发一次请求、空结果 ≠ 不通、凭据绝不进结果、失败必须可分类。 + +// wsProbeServer 起一个只说博查形状的假上游,把收到的请求送进 got 供断言。 +func wsProbeServer(t *testing.T, status int, body string) (*httptest.Server, chan *http.Request) { + t.Helper() + got := make(chan *http.Request, 1) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got <- r + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(srv.Close) + return srv, got +} + +// 判据 ①:通了 ⇒ OK + 真实条数;且真的打到了那家的固定路径上,凭据真的带上了。 +func TestProbeOKHitsProviderWithCredential(t *testing.T) { + t.Parallel() + const key = "probe-secret-key" + srv, got := wsProbeServer(t, http.StatusOK, + `{"webPages":{"value":[{"name":"标题","url":"https://a.example/1","summary":"正文"}]}}`) + + res := Probe(context.Background(), NameBocha, Config{ + APIKey: key, BaseURL: srv.URL, Client: srv.Client(), MaxBytes: 1 << 20, + }, 1) + if !res.OK || res.Count != 1 { + t.Fatalf("探针结论 = %+v, want OK/count=1", res) + } + if res.Provider != NameBocha { + t.Fatalf("provider = %q, want %q", res.Provider, NameBocha) + } + if res.ErrorCode != "" || res.Error != "" { + t.Fatalf("成功时不该带错误码/文案:%+v", res) + } + select { + case r := <-got: + if r.URL.Path != "/v1/web-search" { + t.Errorf("请求路径 = %q, want /v1/web-search(各家固定路径)", r.URL.Path) + } + if r.Header.Get("Authorization") != "Bearer "+key { + t.Errorf("Authorization = %q(凭据必须真的被用上)", r.Header.Get("Authorization")) + } + default: + t.Fatal("探针一个请求都没发 —— 连通性是发出来的,不是算出来的") + } + wsAssertNoSecret(t, res, key, srv.URL) +} + +// 判据 ②:空结果 ≠ 不通 —— 报 no_results,既不算成功、也不算「这家不可用」。 +func TestProbeEmptyResultsIsNoResultsNotFailure(t *testing.T) { + t.Parallel() + srv, _ := wsProbeServer(t, http.StatusOK, `{"webPages":{"value":[]}}`) + res := Probe(context.Background(), NameBocha, Config{ + APIKey: "k", BaseURL: srv.URL, Client: srv.Client(), + }, 1) + if res.OK { + t.Fatalf("空结果不得算成功:%+v", res) + } + if res.ErrorCode != ProbeNoResults { + t.Fatalf("ErrorCode = %q, want %q", res.ErrorCode, ProbeNoResults) + } +} + +// 判据 ③:失败必须可分类,且**文案里不许出现上游 URL/凭据**。 +func TestProbeFailureClassesAndNoLeak(t *testing.T) { + t.Parallel() + cases := []struct { + name string + status int + body string + want string + }{ + {"凭据被拒", http.StatusUnauthorized, `{"error":{"code":"unauthorized"}}`, ProbeUnauthorized}, + {"限流", http.StatusTooManyRequests, `{}`, ProbeRateLimited}, + {"上游 5xx", http.StatusBadGateway, `{}`, ProbeUpstream}, + {"应答不是预期结构", http.StatusOK, `{"unexpected":true}`, ProbeBadResponse}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + const key = "probe-secret-key" + srv, _ := wsProbeServer(t, tc.status, tc.body) + res := Probe(context.Background(), NameBocha, Config{ + APIKey: key, BaseURL: srv.URL, Client: srv.Client(), + }, 1) + if res.OK { + t.Fatalf("失败不得报 OK:%+v", res) + } + if res.ErrorCode != tc.want { + t.Fatalf("ErrorCode = %q, want %q(文案=%q)", res.ErrorCode, tc.want, res.Error) + } + if res.Error == "" { + t.Fatal("失败必须带一句可读文案(错误码与文案分离,前端可本地化)") + } + wsAssertNoSecret(t, res, key, srv.URL) + }) + } +} + +// 判据 ④:名字没登记 / 没配齐 / 没客户端,三类都别去发请求,且分类准确。 +func TestProbePreflightRejectsBeforeSending(t *testing.T) { + t.Parallel() + cases := []struct { + name string + prov string + cfg Config + want string + }{ + {"没登记的名字", "tavily", Config{APIKey: "k", BaseURL: "https://t.example"}, ProbeUnknownProvider}, + {"云家缺 key", NameBocha, Config{BaseURL: "https://b.example"}, ProbeNotConfigured}, + {"缺接口地址", NameBocha, Config{APIKey: "k"}, ProbeNotConfigured}, + {"没有 http 客户端", NameBocha, Config{APIKey: "k", BaseURL: "https://b.example"}, ProbeNotConfigured}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + res := Probe(context.Background(), tc.prov, tc.cfg, 1) + if res.ErrorCode != tc.want { + t.Fatalf("ErrorCode = %q, want %q(文案=%q)", res.ErrorCode, tc.want, res.Error) + } + if res.OK || res.Count != 0 { + t.Fatalf("发不出去的探针不得报成功:%+v", res) + } + }) + } +} + +// 判据 ⑤:网络层失败归 network(连不上也有稳定错误码,不是一团未知)。 +func TestProbeNetworkFailureIsClassified(t *testing.T) { + t.Parallel() + srv, _ := wsProbeServer(t, http.StatusOK, `{}`) + url := srv.URL + srv.Close() // 关掉:后续请求必然连不上 + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + res := Probe(ctx, NameBocha, Config{APIKey: "k", BaseURL: url, Client: &http.Client{}}, 1) + if res.ErrorCode != ProbeNetwork { + t.Fatalf("ErrorCode = %q, want %q", res.ErrorCode, ProbeNetwork) + } +} + +// 判据 ⑥:错误码 ↔ 文案一一对应,且未分类有兜底(不许出现「空文案的失败」)。 +func TestProbeMessagesCoverEveryCode(t *testing.T) { + t.Parallel() + for _, code := range []string{ + ProbeUnknownProvider, ProbeNotConfigured, ProbeUnauthorized, ProbeRateLimited, + ProbeQuotaExceeded, ProbeNetwork, ProbeUpstream, ProbeBadResponse, + ProbeNoResults, ProbeUnknown, + } { + if msg := probeMessage(code); strings.TrimSpace(msg) == "" { + t.Errorf("错误码 %q 没有文案", code) + } + } + if ProbeErrorCode(nil) != "" { + t.Fatal("nil 错误没有错误码") + } +} + +// wsAssertNoSecret 断言探针结论里既没有凭据、也没有上游 URL(两者都可能出现在上游 +// 错误体或网络错误里,而这份结构会进面板响应)。 +func wsAssertNoSecret(t *testing.T, res ProbeResult, secret, upstreamURL string) { + t.Helper() + raw, err := json.Marshal(res) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if strings.Contains(string(raw), secret) { + t.Fatalf("探针结论泄漏了凭据: %s", raw) + } + if strings.Contains(string(raw), upstreamURL) { + t.Fatalf("探针结论泄漏了上游地址: %s", raw) + } +}