Files
Cyrene_v2/cmd/cyrene/websearch_providers.go
AskaEth ce9af42945
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(search): 新增腾讯云·元宝搜索与秘塔搜索两家 provider
接入位表六家扩到八家,端点形状与错误码一字未改(契约 v0.87,仅两处名单)。

- tencent_wsa:POST {base}/SearchPro + Authorization Bearer,零签名。
  两条官方口径决定实现:HTTP 状态码恒 200(判失败只能读 Response.Error.Code);
  Pages 是「JSON 字符串数组」需二次解析。错误码按官方表分类
  (UnauthorizedOperation/AuthFailure.* = 凭据、ResourceUnavailable = 欠费、
  RequestLimitExceeded* = 限流),文案只带 code 不带上游 Message。
- metaso:POST {base}/api/v1/search,响应 schema 仅第三方来源且互相打架,
  实现走宽松解析(数组候选与字段名回退),逐处标「未实测」。
- 零新 env 键:两家只走 $DATA_DIR/config/plugins/web-search.json,
  .env / .env.example 未动;组合根 websearch_providers.go 零功能改动。
- 契约 v0.87:values.provider 合法闭集与 plugin.providers 候选各加两条,
  计数不变(14 + 25 + 40 = 79)。
2026-10-07 13:42:41 +08:00

201 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
// 本文件是 web-search「**各家 provider 的配置投影**」—— 组合根里唯一把配置翻译成各家接线
// 的地方。它存在的理由是一条硬约束:**`.env` 已冻结**(基础键不加/不改/不删)。
//
// 旧形状是「一家一个 env 字段」:`config.WebSearchConfig` 有 `ZhipuAPIKey` / `BochaAPIKey` / …,
// 组合根再逐家读这些字段(外加一个逐家写死的 switch 把面板文件的值搬回去)。那个形状的
// 后果是**加一家 provider 就要先加一个 env 键** —— 直接破 `.env` 冻结红线。
//
// 新形状把**名字表收回到插件自己手里**(`plugins/tools/io/search` 的接入位表):
//
// - 各家叫什么、要不要 key、没给基地址时回落哪个默认地址 ⇒ 全在 `search.Descriptors()`;
// - 组合根只做一件事:**按名字**在面板文件里找同名条目,有就取文件的值,没有就是没配;
// - `.env` 退居**播种层**:只有「文件缺失/为空」时才由 `seedWebSearchFile` 落盘一次
// (既有 6 家的键与值逐字不变,主人已在用的配置不受影响)。
//
// ⇒ 加一家 provider = `plugins/tools/io/search` 加一个接入位文件 + 表里一行,**本文件一行
// 都不用改**,`.env` 一个键都不用加。`tencent_wsa` / `metaso` 就是这么进来的(两家都
// **没有** env 字段:`.env` 已冻结,它们的 key / 基地址只从面板文件取)。
import (
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
iotools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
)
// effectiveWebSearchConfig 是一次装配的**生效配置**,分两层:
//
// - Scalar:文件级标量(主 provider / 降级链 / 条数上限 / 超时)与 searxng 的端点。
// 它沿用 config.WebSearchConfig,因为那几个裁决函数都挂在它上面
// (ResolveProvider / ResolveEndpoint),而它们是 `.env` 与面板**共用**的一套语义。
// - Providers:各家(**不含 searxng**)的生效投影,按**接入位表**逐家给出。
//
// 为什么各家的 key/基地址不再写回 Scalar 的逐家字段:那张字段表是 `.env` 的形状,碰它就要
// 动 env 键(见文件头)。逐家字段在这个结构里**不再承载生效值**(清零),免得被读成
// 「生效配置里的凭据」—— 那正是旧形状最容易被误用的地方。
type effectiveWebSearchConfig struct {
// Scalar 是文件级标量与 searxng 端点(逐家字段一律清零,见上方说明)。
Scalar config.WebSearchConfig
// Providers 是各家的生效投影(不含 searxng),顺序 = 接入位表的展示顺序。
Providers []searchProviderWire
}
// searchProviderWire 是「一家云 provider 的配置投影」:名字 + 凭据 + 基地址。
// searxng 不在这张表里(它的「基地址」就是端点,由 ResolveEndpoint 裁决整把工具的存亡)。
type searchProviderWire struct {
Name string
APIKey string
BaseURL string
}
// searchProviderVector 是 **`.env` 播种层**给出的各家投影(既有 6 家的 env 字段表,
// 云家部分)。它只服务两处:`.env` 缺失时的播种内容(seedWebSearchFile),以及
// 「没有可用文件」时的生效投影(envEffectiveWebSearch)。
//
// ⚠️ 它**不是**「本期内置几家」的名单:登记表里还有两家**没有 env 字段**的家
// (tencent_wsa / metaso,`.env` 已冻结 ⇒ 不给它们加键),它们只从面板文件生效
// (`resolveSearchProviders` 按 `search.Descriptors()` 逐家投影,不经过本函数)。
// 它也**不是**逐条的兜底层:文件存在时,文件里没有的家就是没有(见 resolveSearchProviders)。
// 这条口径由 `wire_web_search_panel_test.go` 的
// `TestWireIOTools_PanelConfigClearedProviderDoesNotReviveFromEnv` 钉住。
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},
}
}
// envSearchProviders 是 `.env` 播种层的**全量**投影:searxng(基地址 = WEB_SEARCH_ENDPOINT)
// 打头,其余各家跟随 —— 顺序与既有播种逐字一致。
func envSearchProviders(ws config.WebSearchConfig) []searchProviderWire {
out := make([]searchProviderWire, 0, len(searchProviderVector(ws))+1)
out = append(out, searchProviderWire{Name: search.NameSearXNG, BaseURL: seedEndpoint(ws.Endpoint)})
return append(out, searchProviderVector(ws)...)
}
// effectiveSearchProviders 是各家(**不含 searxng**)在本次装配里的生效投影。
//
// 生产路径只有薄薄这一层:接入位表从插件自己那里取(`search.Descriptors()`)。加一家
// provider 时这里**零改动** —— 这正是本片「去 env 化」的核心。
func effectiveSearchProviders(f runtimeconfig.WebSearchFile) []searchProviderWire {
return resolveSearchProviders(search.Descriptors(), f)
}
// resolveSearchProviders 逐家算出生效投影。**判定顺序(文件权威)**:
//
// 1. 面板文件里有同名条目且**启用** ⇒ 取条目里的 apiKey / baseUrl(凭据以文件为准:
// 条目里 key 为空 = 这家没配,绝不拿 `.env` 的旧 key 顶上,否则「我在面板里删了它」
// 会变成一句假话);
// 2. 面板文件里有同名条目但**被显式停用** ⇒ 这家不生效(停用是显式选择,不回落别处);
// 3. 面板文件里**没有**该条目 ⇒ 这家不生效(`.env` 只在**文件缺失/为空**时播种,文件一旦
// 存在就是权威;见 searchProviderVector 的注释与那条既有回归用例)。
//
// ⚠️ **一处经核对的偏差(记在这里,免得后人踩)**:任务书里有一句「既有 6 家在文件里没有该
// 条目时仍按现状回落 `.env` 值」。本仓现状**没有**这层回落,且有两条既有约束钉着它:
// ① `runtimeconfig.WebSearchFile.Providers` 的形状契约写明「允许为空数组(= 一家都没配 ⇒
// 组合根不注册 web-search)」;② 回归用例 `TestWireIOTools_PanelConfigClearedProviderDoesNot
// ReviveFromEnv` 明确要求「文件里没有的家不得从 `.env` 复活」。本片按「**不改变既有行为**」
// 实现(既有 websearch 系列用例逐条未改语义、全绿),需要那层回落时改的就是下面这个循环。
//
// 被跳过与未配置的家**仍带着基地址**(回落到接入位的默认值):接线表的 BaseURL 会被组合根
// 逐个加进 SSRF 白名单(io 包的 withSearchEndpointHost),这里悄悄少一家等于改了那份白名单。
//
// defs 由调用方给(生产 = `search.Descriptors()`):测试可注入一个**假接入位**,从而驱动
// 「没有 env 字段的家也能只认文件生效」这条判据 —— 那是本片存在的理由,必须可证。
func resolveSearchProviders(defs []search.Descriptor, f runtimeconfig.WebSearchFile) []searchProviderWire {
out := make([]searchProviderWire, 0, len(defs))
for _, d := range defs {
// searxng 的「基地址」是端点(`WEB_SEARCH_ENDPOINT` / 文件里那条的 baseUrl),
// 由 effectiveSearchConfig 单独裁决:空地址 = 关掉这家,语义与云家不同。
if strings.EqualFold(d.Name, search.NameSearXNG) {
continue
}
entry, found := findProviderEntry(f, d.Name)
if !found || !entry.IsEnabled() {
out = append(out, searchProviderWire{Name: d.Name, BaseURL: baseURLChain("", d.DefaultBaseURL)})
continue
}
out = append(out, searchProviderWire{
Name: d.Name,
APIKey: strings.TrimSpace(entry.APIKey),
BaseURL: baseURLChain(entry.BaseURL, d.DefaultBaseURL),
})
}
return out
}
// baseURLChain 是「一家的基地址」的**唯一进位链**:条目里的值 → 该接入位的代码默认基地址
// → 空。
//
// 空只可能出现在没有默认地址的家上(searxng):它的地址为空本身就是「关掉这家」的开关语义,
// 回落一个本机地址会让「清空地址」变成假话。
func baseURLChain(fromEntry, accessSlotDefault string) string {
if s := strings.TrimSpace(fromEntry); s != "" {
return s
}
return accessSlotDefault
}
// resolveBaseURL 按**名字**走同一条进位链(动作面只拿得到名字与条目):默认基地址由接入位表
// 给出(`search.DefaultBaseURL`,未登记的名字 ⇒ 空)。
//
// 装配期**不用**这个入口而是直接用 baseURLChain:那里手里已经有接入位描述,而「没有 env
// 字段的家」在登记之前按名字查不到默认地址 —— 那正是本片要打通的那一类家。
func resolveBaseURL(name, fromEntry string) string {
return baseURLChain(fromEntry, search.DefaultBaseURL(name))
}
// searchProviderSpecs 把生效配置翻译成各家接线。
//
// searxng 单独一行:它的基地址是端点、可用性由 ResolveEndpoint 裁决(不是「有没有 key」);
// 其余各家按接入位表逐家给出,Configured 的口径仍由 `search.Configured` 统一判定
// (searxng 看端点、云家看 key;false ⇒ 该家不进注册表、不出现在可用清单里)。
//
// **密钥只在这里流转,绝不写日志。**
func searchProviderSpecs(ws config.WebSearchConfig, providers []searchProviderWire) []iotools.SearchProviderSpec {
endpoint := ws.ResolveEndpoint()
specs := make([]iotools.SearchProviderSpec, 0, len(providers)+1)
specs = append(specs, iotools.SearchProviderSpec{
Name: search.NameSearXNG,
BaseURL: endpoint.URL,
Configured: endpoint.Enabled,
})
for _, p := range providers {
specs = append(specs, iotools.SearchProviderSpec{
Name: p.Name,
APIKey: p.APIKey,
BaseURL: p.BaseURL,
Configured: search.Configured(p.Name, p.APIKey, p.BaseURL),
})
}
return specs
}
// availableProviderNames 返回已配置的家(即「可用清单」),供启动日志如实展示。
// **不含任何密钥** —— 只回名字。
func availableProviderNames(specs []iotools.SearchProviderSpec) []string {
out := make([]string, 0, len(specs))
for _, s := range specs {
if s.Configured {
out = append(out, s.Name)
}
}
return out
}
// seedEndpoint 把 `WEB_SEARCH_ENDPOINT` 的取值播进 searxng 条目:显式关闭的哨兵落成空串
// (两者语义相同:都表示不出这家,但面板里不该显示一个不是地址的 "off")。
func seedEndpoint(endpoint string) string {
if config.IsWebSearchOffSentinel(endpoint) {
return ""
}
return strings.TrimSpace(endpoint)
}