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:
AskaEth committed 2026-10-06 09:04:04 +08:00
1 parent 2d21aa553e
commit 120d926cf8
11 files changed
+1811 -41

No files matched your search

+244 -41
View File
@@ -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
}
+286
View File
@@ -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)
}
}
+5
View File
@@ -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
}
+263
View File
@@ -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
}
@@ -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)
}
}
+5
View File
@@ -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 {
+109
View File
@@ -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 }
+218
View File
@@ -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
}
+87
View File
@@ -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() }
+189
View File
@@ -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_<NAME>_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 "探针失败,原因未分类(详见服务端日志)"
}
}
+189
View File
@@ -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)
}
}