feat(search): web-search 接线本机 SearXNG 兜底 + 显式开关(她不再拿一把用不了的锤子)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

主人报的故障:她调 web-search 必失败(no search endpoint configured)。根因两条:组合根从未注入 WithSearchEndpoint + 配置层根本没有搜索配置项。
- internal/config/websearch.go:WEB_SEARCH_ENDPOINT(默认本机兜底 http://127.0.0.1:38471)+ WEB_SEARCH_TIMEOUT;ResolveEndpoint() 三态裁决(可用/显式关闭/非法),非法值启动即报错
- cmd/cyrene/wire_io_tools.go:组合根唯一的 io 选项装配点;端点不可用 ⇒ WithoutSearch() + 带 source 的 WARN(只记键名与原因,不打印 URL 值)
- plugins/tools/io:新增 WithoutSearch()(**根本不进注册面** ⇒ 模型看不见)+ WithSearchTimeout();协议解析逻辑一字未动
- 隐蔽坑(额外查出并修):SearXNG 对 /?q= 回 308 → /search,重定向那一跳走**共享 SSRF 白名单** ⇒ 只注入端点照样被拦。改为**只给 web-search 一件**加宽自己的客户端与白名单,共享白名单与 http/web-fetch 客户端一字未动(有测试钉住它仍拒绝本机网关地址)
- .env.example 新增 2 键 + 注释说明「空串/off/none = 不注册」
- 真机 e2e 两次绿(SearXNG 返回 9 条);变异:不注入端点 / 关闭态仍注册 / 默认值改错 / 不并入端点主机 / 重定向用共享白名单 ⇒ 5 条全红
This commit is contained in:
AskaEth committed 2026-10-05 20:29:21 +08:00
1 parent 8e56163418
commit 8e6c77ad10
11 files changed
+1137 -20

No files matched your search

+72
View File
@@ -0,0 +1,72 @@
package config
import (
"net/url"
"strings"
"time"
)
// WebSearchConfig 是 web-search 工具的搜索端点配置(design/09-插件生态;切片 WS1-A)。
// 写法照既有 VoiceConfig 惯例:一组 provider 键 + 未配置则如实降级,绝不硬编码。
type WebSearchConfig struct {
// Endpoint 是 SearXNG 兼容搜索端点(支持 `format=json` 的 GET 接口)。
//
// 默认值指向**本机兜底**:`scripts/searxng/settings.yml` 配套的 SearXNG 实例
// (宿主机 http://127.0.0.1:38471)。默认指向本机而不是某个云搜索 API,是因为
// 兜底实例不需要任何第三方 key、开箱即用;要换成云 provider 请在本键里写新地址。
//
// **显式关闭**(值写成空串 / 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"`
}
// 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 把配置值解析成端点裁决:
//
// - 显式关闭(空串 / 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)
// 显式关闭的三个哨兵词(大小写不敏感):空串、off、none。
switch strings.ToLower(raw) {
case "", "off", "none":
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}
}