feat(search): 网络搜索新增 DeepSeek 官方来源

不单独配置凭据,直接复用当前激活的 LLM 配置(base_url/api_key/model)。

这个来源与 ddgs/brave/tavily 性质不同:DeepSeek 没有可直接调用的搜索接口,
搜索只存在于其 Anthropic 兼容 messages 接口内部,以 web_search_20250305
服务端工具的形式由 DeepSeek 服务端执行。由此带来几个必须让用户知道的差异,
已在设置页写明:

- 仅支持 DeepSeek 官方端点 + anthropic 协议。实测其 OpenAI 协议端点会直接以
  "unknown variant, expected `function`" 拒绝一切服务端工具,web_search_options
  之类的顶层字段则被静默忽略,没有可用的替代写法。
- 每次搜索额外消耗一次模型调用。
- 搜索请求由 DeepSeek 服务端发出,不经过搜索出口代理,也不计入流量留痕。
- 返回结果只有标题与链接,没有摘要(DeepSeek 只给 encrypted_content),需要正文
  时仍由 WebFetch 抓取。

上述条件不做程序校验,仅在界面说明,由用户自行确认——配置不满足时搜索会在实际
调用时报错,与其它来源填错 Key 的反馈路径一致,可用「测试搜索」按钮实跑验证。

依赖 norma v0.3.8。
This commit is contained in:
Autumn-27 committed 2026-09-12 07:13:46 -04:00
1 parent abf701e03c
commit e4c744a318
11 files changed
+133 -47

No files matched your search

+23 -1
View File
@@ -273,6 +273,9 @@ const (
settingConcurrencyLimit = "task_concurrency_limit"
// defaultWebSearchBackend is used when web search is on but no backend was picked.
defaultWebSearchBackend = "ddgs"
// deepSeekWebSearchBackend borrows the active LLM profile instead of its own
// key, so it only works on an anthropic-format profile pointed at DeepSeek.
deepSeekWebSearchBackend = "deepseek"
// defaultWorkers is the concurrent work-agent count when the setting is unset.
defaultWorkers = 3
// defaultConcurrencyLimit is the simultaneous-running-task cap when the feature
@@ -539,7 +542,26 @@ func (m *Manager) WebSearchOpts() agent.WebSearchOpts {
if on && backend == "tavily" && strings.TrimSpace(tavilyKey) == "" {
on = false
}
return agent.WebSearchOpts{Enabled: on, Backend: backend, BraveKey: braveKey, TavilyKey: tavilyKey, Proxy: proxy}
o := agent.WebSearchOpts{Enabled: on, Backend: backend, BraveKey: braveKey, TavilyKey: tavilyKey, Proxy: proxy}
if backend == deepSeekWebSearchBackend {
o.DeepSeekBaseURL, o.DeepSeekAPIKey, o.DeepSeekModel = m.deepSeekSearchCreds()
}
return o
}
// deepSeekSearchCreds resolves the credentials the "deepseek" search backend
// borrows from the active LLM profile (it has no key of its own). Whether that
// profile can actually drive server-side search — DeepSeek exposes it only on
// the Anthropic-format endpoint — is deliberately NOT validated here: the UI
// states the requirement and the user decides. A profile that can't serve it
// simply fails at search time (or at the settings page's 测试 button), which is
// the same feedback every other backend gives for a bad key.
func (m *Manager) deepSeekSearchCreds() (baseURL, apiKey, model string) {
p, err := m.pg.ActiveProfile()
if err != nil || p == nil {
return "", "", ""
}
return p.BaseURL, p.APIKey, p.Model
}
// SetWebSearch persists and applies the web-search settings. braveKey, tavilyKey, and
+14 -2
View File
@@ -3413,10 +3413,22 @@ func (s *Server) testWebSearch(w http.ResponseWriter, r *http.Request) {
if strings.TrimSpace(req.TavilyKey) != "" {
tavilyKey = req.TavilyKey
}
cfg := actool.WebSearchConfig{Backend: backend, BraveAPIKey: braveKey, TavilyAPIKey: tavilyKey, Proxy: proxy}
// Hard cap so a slow/blocked proxy can't hang the request.
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
wall := 30 * time.Second
// deepseek 的凭据不在表单里,来自当前激活的 LLM 配置;同时它每次搜索都跑一次
// 模型推理,30s 的通用上限偏紧,单独放宽。这里不预判配置能不能用——测这一下
// 本来就是给用户自己确认的手段,真跑不通时下面的报错比预判更有信息量。
probeQuery := "test"
if strings.TrimSpace(backend) == deepSeekWebSearchBackend {
cfg.DeepSeekBaseURL, cfg.DeepSeekAPIKey, cfg.DeepSeekModel = s.m.deepSeekSearchCreds()
wall = 120 * time.Second
// 搜索词由 DeepSeek 端的模型自行决定,"test" 太空泛会让它跳过搜索直接作答。
probeQuery = "DeepSeek company official website"
}
ctx, cancel := context.WithTimeout(r.Context(), wall)
defer cancel()
results, err := actool.WebSearchProbe(ctx, actool.WebSearchConfig{Backend: backend, BraveAPIKey: braveKey, TavilyAPIKey: tavilyKey, Proxy: proxy}, "test", 3)
results, err := actool.WebSearchProbe(ctx, cfg, probeQuery, 3)
if err != nil {
writeJSON(w, 200, map[string]any{"ok": false, "error": err.Error(), "backend": backend})
return