mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-11-03 19:23:51 +08:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7140d14a17 |
No files matched your search
+12
-15
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
-15
@@ -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, // 本任务工作目录 <workDir>/tasks/<taskID>
|
||||
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, // 本任务工作目录 <workDir>/tasks/<taskID>
|
||||
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
|
||||
|
||||
+12
-15
@@ -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, // 本任务工作目录 <workDir>/tasks/<taskID>
|
||||
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, // 本任务工作目录 <workDir>/tasks/<taskID>
|
||||
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、
|
||||
|
||||
+13
-26
@@ -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,
|
||||
|
||||
+13
-16
@@ -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
|
||||
|
||||
+31
-12
@@ -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()
|
||||
|
||||
@@ -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() {
|
||||
记录代理,独立于流量捕获。
|
||||
<br />
|
||||
来源可选 <b>DuckDuckGo(ddgs)</b>(无需 Key)、<b>Brave(免费版)</b>(需填写 Brave API Key)、{" "}
|
||||
<b>Tavily</b>(需填写 Tavily API Key)或 <b>DeepSeek</b>(复用当前 LLM 配置)。总开关关闭时,各 Agent
|
||||
<b>Tavily</b>(需填写 Tavily API Key)或 <b>DeepSeek</b>(需单独配置 Search API Key)。总开关关闭时,各 Agent
|
||||
的网络搜索开关不可用。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
@@ -1487,21 +1502,31 @@ export default function SystemSettingsPage() {
|
||||
|
||||
{webSearch && backend === "deepseek" && (
|
||||
<div className="border-border/60 bg-muted/30 flex flex-col gap-2 rounded-md border p-3">
|
||||
<p className="text-sm font-medium">DeepSeek 官方联网搜索</p>
|
||||
<p className="text-sm font-medium">DeepSeek 联网搜索</p>
|
||||
<p className="text-muted-foreground text-xs leading-relaxed">
|
||||
该来源直接复用<b>当前激活的 LLM 配置</b>。因此它
|
||||
<b>仅支持 DeepSeek 官方模型</b>,且该配置<b>必须使用 anthropic 协议</b>
|
||||
——DeepSeek 的 OpenAI 协议端点不支持服务端搜索。切换 LLM 配置后此来源可能失效。
|
||||
通过 DeepSeek 独立 Search / Fetch API 提供搜索与网页提取,使用当前 Agent 选择的模型生成查询和整理结果。
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs leading-relaxed">
|
||||
与其它来源不同,搜索由 <b>DeepSeek 服务端执行</b>:每次搜索会额外消耗一次模型调用(产生 Token
|
||||
费用),搜索请求<b>不经过上面的出口代理</b>,也<b>不计入流量留痕</b>;返回结果
|
||||
<b>只有标题和链接</b>
|
||||
(无摘要),需要正文时由 WebFetch 抓取。
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs leading-relaxed">
|
||||
是否满足上述条件由你自行确认,系统不做拦截;可用下方「测试搜索」按钮实际跑一次来验证。
|
||||
搜索请求由 ARTEX 服务端发往 DeepSeek,可使用上方独立出口代理。API Key 只保存于服务端设置,不会回显。
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
placeholder={deepSeekKeySet ? "已配置(留空则不变)" : "DeepSeek Search API Key"}
|
||||
value={deepSeekKeyInput}
|
||||
disabled={!loaded || savingDeepSeekKey}
|
||||
onChange={(e) => setDeepSeekKeyInput(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={saveDeepSeekKey}
|
||||
disabled={!loaded || savingDeepSeekKey || deepSeekKeyInput.trim() === ""}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
{deepSeekKeySet && <span className="text-xs text-emerald-500">API Key 已配置</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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 可覆盖默认探测地址(隔离网用)。
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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),所有目标流量走它。开启流量捕获时作为
|
||||
|
||||
Reference in new issue
Block a user