Files
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

156 lines
8.5 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 config
import (
"net/url"
"strings"
"time"
)
// WebSearchConfig 是 web-search 工具的搜索配置(design/27-网络搜索与检索提供商;切片 WS1-A/WS1-B)。
// 写法照既有 VoiceConfig 惯例:一组 provider 键 + 未配置则如实降级,绝不硬编码。
type WebSearchConfig struct {
// Provider 选谁当后端(design/27 §3):`searxng`(默认)/ `zhipu` / `aliyun_iqs` /
// `bocha` / `volcengine` / `baidu_qianfan` / `tencent_wsa` / `metaso`。
//
// ⚠️ 后两家(`tencent_wsa` / `metaso`)**没有对应的 env 字段**(`.env` 已冻结:不加键)
// —— 它们只在 `$DATA_DIR/config/plugins/web-search.json` 里配(组合根按插件侧的接入位表
// 逐家投影,见 cmd/cyrene/websearch_providers.go)。本结构里的逐家字段仍然**只**服务
// 已有的 6 家播种/覆盖。
//
// **语义硬约束**(design/27 §3,由组合根的 io 工具集在启动期执行):
// - 指向**未登记**(如本期未做的 tavily)或**未配置**(缺 key/端点)的名字
// ⇒ **启动期显式报错**,绝不静默回落到兜底;
// - 写空串 / `off` / `none`(大小写不敏感)⇒ **不注册 `web-search` 工具**
// (模型根本看不见它),见 ResolveProvider。
Provider string `env:"WEB_SEARCH_PROVIDER" default:"searxng"`
// Fallback 是降级链(design/27 §3):从左到右试,空 = 只用手选的 provider。
// 链上的家**只在「不可用」时**才被跳过(无 key / 网络失败 / 额度错误…);
// 结果里会如实标注**实际应答**的那一家,绝不拿兜底冒充主力。
Fallback []string `env:"WEB_SEARCH_FALLBACK"`
// MaxResults 是单次检索返回的条数上限(主人 2026-10-07 裁定:默认由 5 提到 10;
// design/27 §3 记的仍是 5,待同步设计文档)。它是**真上限**:模型给的 count 只能要得
// 更少,顶不掉它(`plugins/tools/io/web_search.go` 的 resultLimit)。
MaxResults int `env:"WEB_SEARCH_MAX_RESULTS" default:"10"`
// Endpoint 是 **searxng provider** 的端点(SearXNG 兼容的 `format=json` GET 接口)。
//
// 默认值指向**本机兜底**:`scripts/searxng/settings.yml` 配套的 SearXNG 实例
// (宿主机 http://127.0.0.1:38471)。默认指向本机而不是某个云搜索 API,是因为
// 兜底实例不需要任何第三方 key、开箱即用。
//
// **显式关闭**(值写成空串 / off / none,大小写与首尾空白不敏感)⇒ 组合根
// **不注册** web-search 工具:模型根本看不见它,而不是注册后每次调用都报错
// (见 ResolveEndpoint)。
Endpoint string `env:"WEB_SEARCH_ENDPOINT" default:"http://127.0.0.1:38471"`
// Timeout 是单次搜索请求的超时(Go duration 字符串,如 "20s")。
//
// **未配置(零值)语义 = 「沿用 io 包既有超时」**:这里刻意不写第二个默认值,
// 否则「文档承诺的超时」与「工具真正用的超时」会各有一份而悄悄漂移。
Timeout time.Duration `env:"WEB_SEARCH_TIMEOUT"`
// ---- 各家独立一组键(design/27 §3)----
//
// 口径:`WEB_SEARCH_<NAME>_API_KEY` 给凭据、`WEB_SEARCH_<NAME>_BASE_URL` 给基地址
// (各自的固定路径由 provider 代码拼上)。**未配置的家不出现在可用清单里**
// (fail-closed:绝不挂着报错)。密钥只从配置读,**绝不进日志/响应/提示词**。
// Zhipu* 是智谱 BigModel(design/27 §2.1 国内首选)。
ZhipuAPIKey string `env:"WEB_SEARCH_ZHIPU_API_KEY"`
ZhipuBaseURL string `env:"WEB_SEARCH_ZHIPU_BASE_URL" default:"https://open.bigmodel.cn"`
// AliyunIQS* 是阿里云信息查询服务(IQS;HTTP + X-API-Key,免签名免 SDK)。
AliyunIQSAPIKey string `env:"WEB_SEARCH_ALIYUN_IQS_API_KEY"`
AliyunIQSBaseURL string `env:"WEB_SEARCH_ALIYUN_IQS_BASE_URL" default:"https://cloud-iqs.aliyuncs.com"`
// Bocha* 是博查(中文优先)。默认基地址取**官方文档现行口径** `api.bocha.cn`
// (分册 02:官网首页 curl 样例仍用 api.bochaai.com,两域关系官方未说明)。
BochaAPIKey string `env:"WEB_SEARCH_BOCHA_API_KEY"`
BochaBaseURL string `env:"WEB_SEARCH_BOCHA_BASE_URL" default:"https://api.bocha.cn"`
// Volcengine* 是火山引擎·豆包搜索(SearchInfinity;Bearer + open.feedcoopapi.com)。
VolcengineAPIKey string `env:"WEB_SEARCH_VOLCENGINE_API_KEY"`
VolcengineBaseURL string `env:"WEB_SEARCH_VOLCENGINE_BASE_URL" default:"https://open.feedcoopapi.com"`
// BaiduQianfan* 是百度·千帆 AI 搜索。
BaiduQianfanAPIKey string `env:"WEB_SEARCH_BAIDU_QIANFAN_API_KEY"`
BaiduQianfanBaseURL string `env:"WEB_SEARCH_BAIDU_QIANFAN_BASE_URL" default:"https://qianfan.baidubce.com"`
}
// ProviderDecision 是 WEB_SEARCH_PROVIDER 的解析结论。
type ProviderDecision struct {
// Selected 是选定的 provider 名(已去空白;大小写不敏感,比较时请再归一化)。
Selected string
// Disabled 为 true ⇒ 显式关闭(空串 / off / none)⇒ 不注册 web-search 工具。
Disabled bool
}
// ResolveProvider 把 WEB_SEARCH_PROVIDER 解析成选择结论:显式关闭(空串 / off / none,
// 大小写与首尾空白不敏感)⇒ Disabled=true(组合根据此**不注册** web-search 工具);
// 其余 ⇒ Selected=去空白后的名字(是否登记/是否配置齐由 io 工具集在启动期校验并报错)。
func (w WebSearchConfig) ResolveProvider() ProviderDecision {
raw := strings.TrimSpace(w.Provider)
if isOffSentinel(raw) {
return ProviderDecision{Disabled: true}
}
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 {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "", "off", "none":
return true
default:
return false
}
}
// EndpointDecision 是 WEB_SEARCH_ENDPOINT 的解析结论:要不要注册 web-search,
// 以及注册时它该指向哪里。它把「关闭」「非法」「可用」三种去处做成**显式**答案,
// 使组合根不必自己猜语义,也堵死「未配置却挂着报错」那种半开状态。
type EndpointDecision struct {
// Enabled 为 true ⇒ 注册 web-search 并把端点指向 URL。
Enabled bool
// URL 是校验通过的 http(s) 绝对地址(Enabled 为 true 时才有值)。
URL string
// Reason 在 Enabled 为 false 时说明原因(关闭 / 不是 http(s) 地址),
// 供组合根记一条带 env 键名的 WARN;Enabled 为 true 时为空。
Reason string
}
// Reason 文案常量:组合根直接把它们写进日志,避免两处各写一份措辞。
const (
// WebSearchReasonOff 表示端点被显式关闭(空串 / off / none)。
WebSearchReasonOff = "显式关闭(空串 / off / none)"
// WebSearchReasonInvalid 表示端点既不是关闭也不是可用的 http(s) 绝对地址。
WebSearchReasonInvalid = "不是可用的 http(s) 地址"
)
// ResolveEndpoint 把 WEB_SEARCH_ENDPOINT(= searxng provider 的基地址)解析成端点裁决:
//
// - 显式关闭(空串 / off / none,大小写与首尾空白不敏感)⇒ Enabled=false、
// Reason=WebSearchReasonOff —— 组合根据此**不注册**该工具;
// - 不是 http/https 绝对地址(含缺主机、写错 scheme,如 `ftp://x`、`127.0.0.1:38471`)
// ⇒ Enabled=false、Reason=WebSearchReasonInvalid —— 同样不注册,并记一条 WARN;
// 宁可让模型看不见这把锤子,也不给它一把抡起来必报错的锤子;
// - 其余 ⇒ Enabled=true、URL=去空白后的原值。
//
// 注意「未配置」不落到这里:没写 WEB_SEARCH_ENDPOINT 时字段已被标签默认值填好。
func (w WebSearchConfig) ResolveEndpoint() EndpointDecision {
raw := strings.TrimSpace(w.Endpoint)
if isOffSentinel(raw) {
return EndpointDecision{Reason: WebSearchReasonOff}
}
u, err := url.Parse(raw)
if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
return EndpointDecision{Reason: WebSearchReasonInvalid}
}
return EndpointDecision{Enabled: true, URL: raw}
}