diff --git a/agent/chat.go b/agent/chat.go index c31e42a..86aaac0 100644 --- a/agent/chat.go +++ b/agent/chat.go @@ -134,7 +134,7 @@ func (c *ChatAgent) Chat(ctx context.Context, agentKey, sessionID, message strin Provider: c.prov, SystemPrompt: system, DynamicBoundary: boundary, - Tools: tools, + Tools: append(tools, deepSeekTools(ws)...), DeferredTools: def.Deferred, UnlockSet: def.Unlock, PermissionMode: permission.ModeBypass, @@ -143,20 +143,17 @@ func (c *ChatAgent) Chat(ctx context.Context, agentKey, sessionID, message strin WebFetchCACert: c.proxyCACert, // 联网搜索(可选)。ddgs 无需 key;brave-free 需 BraveKey;tavily 需 TavilyKey。 // WebSearchProxy 是独立出口代理(http/https/socks5),与记录流量的 MITM 代理无关;空则直连。 - EnableWebSearch: ws.Enabled, - WebSearchBackend: ws.Backend, - BraveSearchAPIKey: ws.BraveKey, - TavilySearchAPIKey: ws.TavilyKey, - DeepSeekSearchBaseURL: ws.DeepSeekBaseURL, - DeepSeekSearchAPIKey: ws.DeepSeekAPIKey, - DeepSeekSearchModel: ws.DeepSeekModel, - WebSearchProxy: ws.Proxy, - BashEnv: proxyEnv(proxyAddr, c.proxyCACert), // Bash 子命令默认走代理+信任 CA - WorkingDir: sessionWorkDir, - MaxTurns: maxTurns, - MaxDuration: maxDuration, - Compaction: compactionConfig(c.window), - Todos: actool.NewTodoStore(), + EnableWebSearch: ws.Enabled && ws.Backend != "deepseek", + WebSearchBackend: ws.Backend, + BraveSearchAPIKey: ws.BraveKey, + TavilySearchAPIKey: ws.TavilyKey, + WebSearchProxy: ws.Proxy, + BashEnv: proxyEnv(proxyAddr, c.proxyCACert), // Bash 子命令默认走代理+信任 CA + WorkingDir: sessionWorkDir, + MaxTurns: maxTurns, + MaxDuration: maxDuration, + Compaction: compactionConfig(c.window), + Todos: actool.NewTodoStore(), // large tool output spills to cmd-output/ under the session dir. // 截断上限用 SDK 默认(tool.Capture 的 30000 字符)。 ToolOutputDir: filepath.Join(sessionWorkDir, "cmd-output"), diff --git a/agent/deepseek_search.go b/agent/deepseek_search.go new file mode 100644 index 0000000..30f203b --- /dev/null +++ b/agent/deepseek_search.go @@ -0,0 +1,121 @@ +package agent + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/Autumn-27/norma/permission" + actool "github.com/Autumn-27/norma/tool" +) + +const deepSeekToolsURL = "https://api.deepseek.com/v1/tools/" + +func deepSeekTools(opts WebSearchOpts) []actool.CoreTool { + if !opts.Enabled || opts.Backend != "deepseek" { + return nil + } + key := strings.TrimSpace(opts.DeepSeekAPIKey) + if key == "" { + return nil + } + client := &http.Client{Timeout: 30 * time.Second} + if proxy := strings.TrimSpace(opts.Proxy); proxy != "" { + if parsed, err := url.Parse(proxy); err == nil && parsed.Scheme != "" && parsed.Host != "" { + client.Transport = &http.Transport{Proxy: http.ProxyURL(parsed)} + } else { + return nil + } + } + return []actool.CoreTool{ + deepSeekTool("deepseek_search", "Search the web with DeepSeek and return ranked titles, URLs, and snippets.", "search", key, client, map[string]any{ + "query": map[string]any{"type": "string", "description": "Search query"}, + "count": map[string]any{"type": "integer", "description": "Maximum results (1-10)"}, + }, "query"), + deepSeekTool("deepseek_fetch", "Fetch a web page with DeepSeek and return its extracted content.", "fetch", key, client, map[string]any{ + "url": map[string]any{"type": "string", "description": "Page URL to fetch"}, + }, "url"), + } +} + +func DeepSeekSearchProbe(ctx context.Context, key, proxy, query string, count int) (int, error) { + if strings.TrimSpace(key) == "" { + return 0, fmt.Errorf("DeepSeek Search API Key is required") + } + client := &http.Client{Timeout: 30 * time.Second} + if strings.TrimSpace(proxy) != "" { + parsed, err := url.Parse(proxy) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return 0, fmt.Errorf("invalid search proxy URL") + } + client.Transport = &http.Transport{Proxy: http.ProxyURL(parsed)} + } + body, _ := json.Marshal(map[string]any{"query": query, "count": count}) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, deepSeekToolsURL+"search", strings.NewReader(string(body))) + if err != nil { + return 0, err + } + req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(key)) + req.Header.Set("Content-Type", "application/json") + resp, err := client.Do(req) + if err != nil { + return 0, err + } + defer resp.Body.Close() + var result map[string]json.RawMessage + if err := json.NewDecoder(io.LimitReader(resp.Body, 2<<20)).Decode(&result); err != nil { + return 0, fmt.Errorf("DeepSeek returned invalid JSON: %w", err) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return 0, fmt.Errorf("DeepSeek search returned HTTP %d: %s", resp.StatusCode, string(result["error"])) + } + for _, field := range []string{"results", "data", "items"} { + var items []json.RawMessage + if json.Unmarshal(result[field], &items) == nil && len(items) > 0 { + return len(items), nil + } + } + return 0, fmt.Errorf("DeepSeek search response did not contain results") +} + +func deepSeekTool(name, description, endpoint, key string, client *http.Client, properties map[string]any, required string) actool.CoreTool { + return actool.Build(actool.Spec{ + Name: name, Description: description, + Schema: map[string]any{"type": "object", "properties": properties, "required": []any{required}}, + ReadOnly: func(json.RawMessage) bool { return true }, Concurrent: func(json.RawMessage) bool { return true }, + Permissions: func(context.Context, json.RawMessage, permission.Context) permission.Decision { + return permission.Allowed() + }, + Run: func(ctx context.Context, input json.RawMessage, _ *actool.ToolContext) (actool.Result, error) { + var args map[string]any + if err := json.Unmarshal(input, &args); err != nil { + return actool.Errorf("invalid input"), nil + } + body, _ := json.Marshal(args) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, deepSeekToolsURL+endpoint, strings.NewReader(string(body))) + if err != nil { + return actool.Errorf(err.Error()), nil + } + req.Header.Set("Authorization", "Bearer "+key) + req.Header.Set("Content-Type", "application/json") + resp, err := client.Do(req) + if err != nil { + return actool.Errorf("DeepSeek " + endpoint + " failed: " + err.Error()), nil + } + defer resp.Body.Close() + var result json.RawMessage + if err := json.NewDecoder(io.LimitReader(resp.Body, 2<<20)).Decode(&result); err != nil { + return actool.Errorf("DeepSeek returned invalid JSON"), nil + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return actool.Errorf(fmt.Sprintf("DeepSeek %s returned HTTP %d: %s", endpoint, resp.StatusCode, string(result))), nil + } + return actool.Text(string(result)), nil + }, + }) +} diff --git a/agent/deepseek_search_test.go b/agent/deepseek_search_test.go new file mode 100644 index 0000000..a26f6a1 --- /dev/null +++ b/agent/deepseek_search_test.go @@ -0,0 +1,51 @@ +package agent + +import ( + "context" + "io" + "net/http" + "strings" + "testing" +) + +type deepSeekRoundTripper func(*http.Request) (*http.Response, error) + +func (f deepSeekRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func TestDeepSeekSearchToolUsesConfiguredKeyAndQuery(t *testing.T) { + client := &http.Client{Transport: deepSeekRoundTripper(func(r *http.Request) (*http.Response, error) { + if r.URL.String() != deepSeekToolsURL+"search" { + t.Errorf("request URL = %q", r.URL) + } + if got := r.Header.Get("Authorization"); got != "Bearer test-key" { + t.Errorf("authorization = %q", got) + } + body, _ := io.ReadAll(r.Body) + if string(body) != `{"count":3,"query":"ARTEX"}` { + t.Errorf("request body = %s", body) + } + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"results":[{"title":"ARTEX","url":"https://example.test"}]}`)), Header: make(http.Header)}, nil + })} + tool := deepSeekTool("deepseek_search", "", "search", "test-key", client, map[string]any{ + "query": map[string]any{"type": "string"}, "count": map[string]any{"type": "integer"}, + }, "query") + result, err := tool.Call(context.Background(), []byte(`{"query":"ARTEX","count":3}`), nil) + if err != nil { + t.Fatal(err) + } + if result.IsError || !strings.Contains(result.Flatten(), `"results"`) { + t.Fatalf("unexpected result: %+v", result) + } +} + +func TestDeepSeekToolsRespectGlobalAndBackendSettings(t *testing.T) { + for _, opts := range []WebSearchOpts{ + {Enabled: false, Backend: "deepseek", DeepSeekAPIKey: "key"}, + {Enabled: true, Backend: "ddgs", DeepSeekAPIKey: "key"}, + {Enabled: true, Backend: "deepseek"}, + } { + if got := deepSeekTools(opts); len(got) != 0 { + t.Fatalf("deepseek tools enabled for opts %+v", opts) + } + } +} diff --git a/agent/mainagent.go b/agent/mainagent.go index e52a94c..e59cdf5 100644 --- a/agent/mainagent.go +++ b/agent/mainagent.go @@ -142,7 +142,7 @@ func (m *MainAgent) Chat(ctx context.Context, taskID int64, mainSeg int, as *db. Provider: m.prov, SystemPrompt: system, DynamicBoundary: boundary, - Tools: tools, + Tools: append(tools, deepSeekTools(m.webSearch)...), DeferredTools: def.Deferred, UnlockSet: def.Unlock, PermissionMode: permission.ModeBypass, @@ -151,20 +151,17 @@ func (m *MainAgent) Chat(ctx context.Context, taskID int64, mainSeg int, as *db. WebFetchCACert: m.proxyCACert, // 联网搜索(可选)。ddgs 无需 key;brave-free 需 BraveKey;tavily 需 TavilyKey。 // WebSearchProxy 是独立出口代理(http/https/socks5),与记录流量的 MITM 代理无关;空则直连。 - EnableWebSearch: m.webSearch.Enabled, - WebSearchBackend: m.webSearch.Backend, - BraveSearchAPIKey: m.webSearch.BraveKey, - TavilySearchAPIKey: m.webSearch.TavilyKey, - DeepSeekSearchBaseURL: m.webSearch.DeepSeekBaseURL, - DeepSeekSearchAPIKey: m.webSearch.DeepSeekAPIKey, - DeepSeekSearchModel: m.webSearch.DeepSeekModel, - WebSearchProxy: m.webSearch.Proxy, - BashEnv: proxyEnv(proxyAddr, m.proxyCACert), // Bash 子命令默认走代理+信任 CA - WorkingDir: mainDir, // 本任务工作目录 /tasks/ - ToolOutputDir: cmdOutDir(mainDir), - MaxTurns: m.maxTurns, // 0 = unlimited (configurable in agent management) - Compaction: compactionConfig(m.compactionWindow()), // long chats stay within the window - Todos: actool.NewTodoStore(), // 会话级临时待办(TodoWrite),纯规划用,退出即丢 + EnableWebSearch: m.webSearch.Enabled && m.webSearch.Backend != "deepseek", + WebSearchBackend: m.webSearch.Backend, + BraveSearchAPIKey: m.webSearch.BraveKey, + TavilySearchAPIKey: m.webSearch.TavilyKey, + WebSearchProxy: m.webSearch.Proxy, + BashEnv: proxyEnv(proxyAddr, m.proxyCACert), // Bash 子命令默认走代理+信任 CA + WorkingDir: mainDir, // 本任务工作目录 /tasks/ + ToolOutputDir: cmdOutDir(mainDir), + MaxTurns: m.maxTurns, // 0 = unlimited (configurable in agent management) + Compaction: compactionConfig(m.compactionWindow()), // long chats stay within the window + Todos: actool.NewTodoStore(), // 会话级临时待办(TodoWrite),纯规划用,退出即丢 // 命中预算(步数)→ SDK 跑收尾:向用户输出一句进展总结。Prompt 与收尾轮数可后台编辑(默认 10 轮)。 Settlement: wrapupSettlement("mainagent", nil), NonStreaming: m.nonStreaming(), // 该 profile 选非流式时走 Provider.Complete diff --git a/agent/planner.go b/agent/planner.go index b0b754d..cec6755 100644 --- a/agent/planner.go +++ b/agent/planner.go @@ -412,7 +412,7 @@ func (p *Planner) Plan(ctx context.Context, taskID int64, as *db.AssetStore, ts Provider: p.prov, SystemPrompt: system, DynamicBoundary: boundary, - Tools: tools, + Tools: append(tools, deepSeekTools(p.webSearch)...), DeferredTools: def.Deferred, UnlockSet: def.Unlock, PermissionMode: permission.ModeBypass, @@ -421,20 +421,17 @@ func (p *Planner) Plan(ctx context.Context, taskID int64, as *db.AssetStore, ts WebFetchCACert: p.proxyCACert, // 联网搜索(可选)。ddgs 无需 key;brave-free 需 BraveKey;tavily 需 TavilyKey。 // WebSearchProxy 是独立出口代理(http/https/socks5),与记录流量的 MITM 代理无关;空则直连。 - EnableWebSearch: p.webSearch.Enabled, - WebSearchBackend: p.webSearch.Backend, - BraveSearchAPIKey: p.webSearch.BraveKey, - TavilySearchAPIKey: p.webSearch.TavilyKey, - DeepSeekSearchBaseURL: p.webSearch.DeepSeekBaseURL, - DeepSeekSearchAPIKey: p.webSearch.DeepSeekAPIKey, - DeepSeekSearchModel: p.webSearch.DeepSeekModel, - WebSearchProxy: p.webSearch.Proxy, - BashEnv: proxyEnv(proxyAddr, p.proxyCACert), // Bash 子命令默认走代理+信任 CA - WorkingDir: taskDir, // 本任务工作目录 /tasks/ - ToolOutputDir: cmdOutDir(taskDir), - MaxTurns: p.maxTurns, // 0 = unlimited (configurable in agent management) - MaxDuration: maxDur, // 0=不限;有 deadline 时=距 deadline 剩余 - Compaction: compactionConfig(p.compactionWindow()), + EnableWebSearch: p.webSearch.Enabled && p.webSearch.Backend != "deepseek", + WebSearchBackend: p.webSearch.Backend, + BraveSearchAPIKey: p.webSearch.BraveKey, + TavilySearchAPIKey: p.webSearch.TavilyKey, + WebSearchProxy: p.webSearch.Proxy, + BashEnv: proxyEnv(proxyAddr, p.proxyCACert), // Bash 子命令默认走代理+信任 CA + WorkingDir: taskDir, // 本任务工作目录 /tasks/ + ToolOutputDir: cmdOutDir(taskDir), + MaxTurns: p.maxTurns, // 0 = unlimited (configurable in agent management) + MaxDuration: maxDur, // 0=不限;有 deadline 时=距 deadline 剩余 + Compaction: compactionConfig(p.compactionWindow()), // 跨唤醒共享的规划待办:让串行链在多轮之间保留(session 是新的,store 不是)。 Todos: p.todoFor(ts.ID()), // 命中【本轮】步数预算→ SDK 跑收尾:把本轮已想清楚的结论落地(该派的 add_intent、 diff --git a/agent/worker.go b/agent/worker.go index 12a0d5c..213c726 100644 --- a/agent/worker.go +++ b/agent/worker.go @@ -28,27 +28,17 @@ import ( // WebSearchOpts is the web-search backend selection the server pushes into each // agent (planner/worker/main). Enabled=false leaves the web_search tool off. // Backend is "ddgs" (no key), "brave-free" (BraveKey required), "tavily" -// (TavilyKey required), or "deepseek" (DeepSeek* required, filled from the -// active LLM profile). It maps directly onto agentcore.Options. +// (TavilyKey required), or "deepseek" (uses DeepSeek's independent Search API). // Proxy is a dedicated egress proxy for the search request (http/https/socks5), // independent of the traffic-recording MITM proxy — set it when the search endpoint // is only reachable via a VPN/SOCKS proxy. Empty = direct. -// -// 注意 deepseek 后端与其它三个的性质不同:DeepSeek 没有可直接调用的搜索接口, -// 搜索只存在于其 Anthropic 兼容 messages 接口内部(web_search_20250305 server -// tool),因此每次搜索会消耗一次模型调用,且搜索请求由 DeepSeek 服务端发出—— -// 不经过本机 Proxy,也不会进流量留痕。 type WebSearchOpts struct { - Enabled bool - Backend string - BraveKey string - TavilyKey string - Proxy string - // DeepSeek* 来自当前激活的 LLM 配置(仅 anthropic 格式的 DeepSeek 官方端点), - // 不单独配置,随 LLM 配置切换而变。 - DeepSeekBaseURL string - DeepSeekAPIKey string - DeepSeekModel string + Enabled bool + Backend string + BraveKey string + TavilyKey string + Proxy string + DeepSeekAPIKey string } type Worker struct { @@ -437,7 +427,7 @@ func (w *Worker) execute(ctx context.Context, name string, taskID int64, as *db. Provider: w.prov, SystemPrompt: system, DynamicBoundary: boundary, - Tools: tools, + Tools: append(tools, deepSeekTools(w.webSearch)...), DeferredTools: def.Deferred, UnlockSet: def.Unlock, PermissionMode: permission.ModeBypass, @@ -448,14 +438,11 @@ func (w *Worker) execute(ctx context.Context, name string, taskID int64, as *db. WebFetchCACert: w.proxyCACert, // 联网搜索(可选)。ddgs 无需 key;brave-free 需 BraveKey;tavily 需 TavilyKey。 // WebSearchProxy 是独立的出口代理(http/https/socks5),与记录流量的 MITM 代理无关;空则直连。 - EnableWebSearch: w.webSearch.Enabled, - WebSearchBackend: w.webSearch.Backend, - BraveSearchAPIKey: w.webSearch.BraveKey, - TavilySearchAPIKey: w.webSearch.TavilyKey, - DeepSeekSearchBaseURL: w.webSearch.DeepSeekBaseURL, - DeepSeekSearchAPIKey: w.webSearch.DeepSeekAPIKey, - DeepSeekSearchModel: w.webSearch.DeepSeekModel, - WebSearchProxy: w.webSearch.Proxy, + EnableWebSearch: w.webSearch.Enabled && w.webSearch.Backend != "deepseek", + WebSearchBackend: w.webSearch.Backend, + BraveSearchAPIKey: w.webSearch.BraveKey, + TavilySearchAPIKey: w.webSearch.TavilyKey, + WebSearchProxy: w.webSearch.Proxy, // Bash 子命令的 HTTP 默认走记录代理 + 信任其 CA(工具无需 -x/-k)。 BashEnv: proxyEnv(proxyAddr, w.proxyCACert), WorkingDir: runDir, diff --git a/server/manager.go b/server/manager.go index cc024af..9a3acb6 100644 --- a/server/manager.go +++ b/server/manager.go @@ -304,6 +304,7 @@ const ( settingWebSearchBackend = "web_search_backend" settingBraveKey = "brave_search_api_key" settingTavilyKey = "tavily_search_api_key" + settingDeepSeekSearchKey = "deepseek_search_api_key" settingWebSearchProxy = "web_search_proxy" // settingGlobalProxy is the global egress proxy for all target traffic // (http/https/socks5). Empty = direct. Distinct from web_search_proxy (which @@ -328,8 +329,7 @@ const ( settingNoaCompaction = "noa_compaction" // 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 calls DeepSeek's independent web-search API. deepSeekWebSearchBackend = "deepseek" // defaultWorkers is the concurrent work-agent count when the setting is unset. defaultWorkers = 3 @@ -783,26 +783,23 @@ func (m *Manager) WebSearchOpts() agent.WebSearchOpts { if on && backend == "tavily" && strings.TrimSpace(tavilyKey) == "" { on = false } + if on && backend == deepSeekWebSearchBackend && m.deepSeekSearchKey() == "" { + on = false + } o := agent.WebSearchOpts{Enabled: on, Backend: backend, BraveKey: braveKey, TavilyKey: tavilyKey, Proxy: proxy} if backend == deepSeekWebSearchBackend { - o.DeepSeekBaseURL, o.DeepSeekAPIKey, o.DeepSeekModel = m.deepSeekSearchCreds() + o.DeepSeekAPIKey = m.deepSeekSearchKey() } 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 +func (m *Manager) deepSeekSearchKey() string { + key, _, _ := m.pg.GetSetting(settingDeepSeekSearchKey) + return strings.TrimSpace(key) +} + +func (m *Manager) setDeepSeekSearchKey(key string) error { + return m.pg.SetSetting(settingDeepSeekSearchKey, strings.TrimSpace(key)) } // SetWebSearch persists and applies the web-search settings. braveKey, tavilyKey, and diff --git a/server/server.go b/server/server.go index 8be73ed..0c8e32a 100644 --- a/server/server.go +++ b/server/server.go @@ -3668,6 +3668,7 @@ func (s *Server) settingsPayload() map[string]any { "web_search_backend": backend, "brave_key_set": strings.TrimSpace(braveKey) != "", "tavily_key_set": strings.TrimSpace(tavilyKey) != "", + "deepseek_search_key_set": s.m.deepSeekSearchKey() != "", "web_search_proxy": maskSecrets(proxy), // 独立出口代理(http/https/socks5),空=直连 "global_proxy": maskSecrets(s.m.GlobalProxy()), // 全局出口代理(http/https/socks5),所有目标流量走它,空=直连 "proxy_pool": func() []ProxyPoolEntry { e, _, _ := s.m.ProxyPoolSnapshot(); return e }(), @@ -3768,6 +3769,7 @@ func (s *Server) putSettings(w http.ResponseWriter, r *http.Request) { WebSearchBackend *string `json:"web_search_backend"` BraveKey *string `json:"brave_search_api_key"` TavilyKey *string `json:"tavily_search_api_key"` + DeepSeekSearchKey *string `json:"deepseek_search_api_key"` WebSearchProxy *string `json:"web_search_proxy"` // 独立出口代理(http/https/socks5);null=不改,""=清空 GlobalProxy *string `json:"global_proxy"` // 全局出口代理(http/https/socks5);null=不改,""=清空(直连) ProxyRotateTimed *bool `json:"proxy_rotate_timed_enabled"` @@ -4055,7 +4057,7 @@ func (s *Server) putSettings(w http.ResponseWriter, r *http.Request) { return } } - if req.WebSearchEnabled != nil || req.WebSearchBackend != nil || req.BraveKey != nil || req.TavilyKey != nil || req.WebSearchProxy != nil { + if req.WebSearchEnabled != nil || req.WebSearchBackend != nil || req.BraveKey != nil || req.TavilyKey != nil || req.DeepSeekSearchKey != nil || req.WebSearchProxy != nil { // Fill unspecified fields from current state so a partial PUT doesn't reset them. on, backend, _, _, _ := s.m.WebSearch() if req.WebSearchEnabled != nil { @@ -4074,6 +4076,16 @@ func (s *Server) putSettings(w http.ResponseWriter, r *http.Request) { } changed = true } + if req.DeepSeekSearchKey != nil { + if strings.Contains(*req.DeepSeekSearchKey, "***") { + *req.DeepSeekSearchKey = s.m.deepSeekSearchKey() + } + if err := s.m.setDeepSeekSearchKey(*req.DeepSeekSearchKey); err != nil { + writeErr(w, 500, err.Error()) + return + } + changed = true + } if changed { // rebuild agents so the new proxy/tools/prompt/web-search take hold (only if LLM configured). s.cfgMu.Lock() @@ -4129,10 +4141,11 @@ func (s *Server) setReporterBrowserScreenshotVisibility(enabled bool) error { // not retype them. Always 200 with {ok, error?, count?, backend?}. func (s *Server) testWebSearch(w http.ResponseWriter, r *http.Request) { var req struct { - Backend string `json:"web_search_backend"` - Proxy string `json:"web_search_proxy"` - BraveKey string `json:"brave_search_api_key"` - TavilyKey string `json:"tavily_search_api_key"` + Backend string `json:"web_search_backend"` + Proxy string `json:"web_search_proxy"` + BraveKey string `json:"brave_search_api_key"` + TavilyKey string `json:"tavily_search_api_key"` + DeepSeekSearchKey string `json:"deepseek_search_api_key"` } // Empty body is fine — fall back entirely to the saved config below. if err := json.NewDecoder(r.Body).Decode(&req); err != nil && err != io.EOF { @@ -4155,18 +4168,24 @@ func (s *Server) testWebSearch(w http.ResponseWriter, r *http.Request) { if strings.TrimSpace(req.TavilyKey) != "" { tavilyKey = req.TavilyKey } + deepSeekKey := s.m.deepSeekSearchKey() + if strings.TrimSpace(req.DeepSeekSearchKey) != "" { + deepSeekKey = strings.TrimSpace(req.DeepSeekSearchKey) + } cfg := actool.WebSearchConfig{Backend: backend, BraveAPIKey: braveKey, TavilyAPIKey: tavilyKey, Proxy: proxy} // Hard cap so a slow/blocked proxy can't hang the request. 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() + count, err := agent.DeepSeekSearchProbe(ctx, deepSeekKey, proxy, "DeepSeek company official website", 3) + if err != nil { + writeJSON(w, 200, map[string]any{"ok": false, "error": err.Error(), "backend": backend}) + return + } + writeJSON(w, 200, map[string]any{"ok": count > 0, "count": count, "backend": backend}) + return } ctx, cancel := context.WithTimeout(r.Context(), wall) defer cancel() diff --git a/web/src/app/(main)/system/settings/page.tsx b/web/src/app/(main)/system/settings/page.tsx index 38b4e78..4690afa 100644 --- a/web/src/app/(main)/system/settings/page.tsx +++ b/web/src/app/(main)/system/settings/page.tsx @@ -51,6 +51,9 @@ export default function SystemSettingsPage() { const [braveKeyInput, setBraveKeyInput] = React.useState(""); const [tavilyKeySet, setTavilyKeySet] = React.useState(false); const [tavilyKeyInput, setTavilyKeyInput] = React.useState(""); + const [deepSeekKeySet, setDeepSeekKeySet] = React.useState(false); + const [deepSeekKeyInput, setDeepSeekKeyInput] = React.useState(""); + const [savingDeepSeekKey, setSavingDeepSeekKey] = React.useState(false); const [savingTavilyKey, setSavingTavilyKey] = React.useState(false); const [proxyInput, setProxyInput] = React.useState(""); const [savingProxy, setSavingProxy] = React.useState(false); @@ -124,6 +127,7 @@ export default function SystemSettingsPage() { setBackend(s.web_search_backend || "ddgs"); setBraveKeySet(!!s.brave_key_set); setTavilyKeySet(!!s.tavily_key_set); + setDeepSeekKeySet(!!s.deepseek_search_key_set); setProxyInput(s.web_search_proxy ?? ""); const savedProxy = s.global_proxy ?? ""; setGlobalProxyInput(savedProxy); @@ -437,6 +441,16 @@ export default function SystemSettingsPage() { .finally(() => setSavingTavilyKey(false)); }; + const saveDeepSeekKey = () => { + setSavingDeepSeekKey(true); + api.setSettings({ deepseek_search_api_key: deepSeekKeyInput }).then((s) => { + apply(s); + setDeepSeekKeyInput(""); + toast.success("已保存 DeepSeek Search API Key"); + }).catch((e) => toast.error(`保存失败:${(e as Error).message}`)) + .finally(() => setSavingDeepSeekKey(false)); + }; + const saveProxy = () => { setSavingProxy(true); api @@ -608,6 +622,7 @@ export default function SystemSettingsPage() { web_search_proxy: proxyInput.trim(), brave_search_api_key: braveKeyInput, tavily_search_api_key: tavilyKeyInput, + deepseek_search_api_key: deepSeekKeyInput, }) .then((r) => { if (r.ok) toast.success(`搜索测试成功 · ${r.backend} 返回 ${r.count} 条结果`); @@ -1441,7 +1456,7 @@ export default function SystemSettingsPage() { 记录代理,独立于流量捕获。
来源可选 DuckDuckGo(ddgs)(无需 Key)、Brave(免费版)(需填写 Brave API Key)、{" "} - Tavily(需填写 Tavily API Key)或 DeepSeek(复用当前 LLM 配置)。总开关关闭时,各 Agent + Tavily(需填写 Tavily API Key)或 DeepSeek(需单独配置 Search API Key)。总开关关闭时,各 Agent 的网络搜索开关不可用。 @@ -1487,21 +1502,31 @@ export default function SystemSettingsPage() { {webSearch && backend === "deepseek" && (
-

DeepSeek 官方联网搜索

+

DeepSeek 联网搜索

- 该来源直接复用当前激活的 LLM 配置。因此它 - 仅支持 DeepSeek 官方模型,且该配置必须使用 anthropic 协议 - ——DeepSeek 的 OpenAI 协议端点不支持服务端搜索。切换 LLM 配置后此来源可能失效。 + 通过 DeepSeek 独立 Search / Fetch API 提供搜索与网页提取,使用当前 Agent 选择的模型生成查询和整理结果。

- 与其它来源不同,搜索由 DeepSeek 服务端执行:每次搜索会额外消耗一次模型调用(产生 Token - 费用),搜索请求不经过上面的出口代理,也不计入流量留痕;返回结果 - 只有标题和链接 - (无摘要),需要正文时由 WebFetch 抓取。 -

-

- 是否满足上述条件由你自行确认,系统不做拦截;可用下方「测试搜索」按钮实际跑一次来验证。 + 搜索请求由 ARTEX 服务端发往 DeepSeek,可使用上方独立出口代理。API Key 只保存于服务端设置,不会回显。

+
+ setDeepSeekKeyInput(e.target.value)} + /> + +
+ {deepSeekKeySet && API Key 已配置}
)} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 5b60621..2651113 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -996,6 +996,7 @@ export const api = { web_search_proxy?: string; brave_search_api_key?: string; tavily_search_api_key?: string; + deepseek_search_api_key?: string; }) => post<{ ok: boolean; error?: string; count?: number; backend?: string }>(`/settings/web-search/test`, patch), // global_proxy 传空 = 测直连;probe_url 可覆盖默认探测地址(隔离网用)。 diff --git a/web/src/lib/mock/data.ts b/web/src/lib/mock/data.ts index 26f1dc5..bd56282 100644 --- a/web/src/lib/mock/data.ts +++ b/web/src/lib/mock/data.ts @@ -3348,6 +3348,7 @@ export const settings: Settings = { web_search_backend: "ddgs", brave_key_set: false, tavily_key_set: true, + deepseek_search_key_set: false, web_search_proxy: "", global_proxy: "", proxy_pool: [], diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 47e7f50..5fd92ef 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -996,9 +996,11 @@ export interface Settings { web_search_backend: string; // "ddgs" | "brave-free" | "tavily" | "deepseek" brave_key_set: boolean; tavily_key_set: boolean; + deepseek_search_key_set: boolean; // write-only: only sent on PUT to store/clear the key. brave_search_api_key?: string; tavily_search_api_key?: string; + deepseek_search_api_key?: string; // 独立出口代理(http/https/socks5),用于访问搜索端点;与记录流量的 MITM 代理无关。空=直连。 web_search_proxy?: string; // 全局出口代理(http/https/socks5,可带 user:pass),所有目标流量走它。开启流量捕获时作为