mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
Add configurable Sogou-backed web search
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
@@ -33,6 +34,38 @@ type toolStateRequest struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type webSearchProviderOption struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Configured bool `json:"configured"`
|
||||
Current bool `json:"current"`
|
||||
RequiresAuth bool `json:"requires_auth"`
|
||||
}
|
||||
|
||||
type webSearchProviderConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
MaxResults int `json:"max_results"`
|
||||
BaseURL string `json:"base_url,omitempty"`
|
||||
APIKey string `json:"api_key,omitempty"`
|
||||
APIKeySet bool `json:"api_key_set,omitempty"`
|
||||
}
|
||||
|
||||
type webSearchConfigResponse struct {
|
||||
Provider string `json:"provider"`
|
||||
CurrentService string `json:"current_service"`
|
||||
PreferNative bool `json:"prefer_native"`
|
||||
Proxy string `json:"proxy,omitempty"`
|
||||
Providers []webSearchProviderOption `json:"providers"`
|
||||
Settings map[string]webSearchProviderConfig `json:"settings"`
|
||||
}
|
||||
|
||||
type webSearchConfigRequest struct {
|
||||
Provider string `json:"provider"`
|
||||
PreferNative bool `json:"prefer_native"`
|
||||
Proxy string `json:"proxy"`
|
||||
Settings map[string]webSearchProviderConfig `json:"settings"`
|
||||
}
|
||||
|
||||
var toolCatalog = []toolCatalogEntry{
|
||||
{
|
||||
Name: "read_file",
|
||||
@@ -153,6 +186,8 @@ var toolCatalog = []toolCatalogEntry{
|
||||
func (h *Handler) registerToolRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /api/tools", h.handleListTools)
|
||||
mux.HandleFunc("PUT /api/tools/{name}/state", h.handleUpdateToolState)
|
||||
mux.HandleFunc("GET /api/tools/web-search-config", h.handleGetWebSearchConfig)
|
||||
mux.HandleFunc("PUT /api/tools/web-search-config", h.handleUpdateWebSearchConfig)
|
||||
}
|
||||
|
||||
func (h *Handler) handleListTools(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -333,3 +368,222 @@ func applyToolState(cfg *config.Config, toolName string, enabled bool) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Handler) handleGetWebSearchConfig(w http.ResponseWriter, r *http.Request) {
|
||||
cfg, err := config.LoadConfig(h.configPath)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(buildWebSearchConfigResponse(cfg)); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) handleUpdateWebSearchConfig(w http.ResponseWriter, r *http.Request) {
|
||||
cfg, err := config.LoadConfig(h.configPath)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var req webSearchConfigRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
provider := normalizeWebSearchProvider(req.Provider)
|
||||
if provider == "" {
|
||||
http.Error(w, "invalid web search provider", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
cfg.Tools.Web.Provider = provider
|
||||
cfg.Tools.Web.PreferNative = req.PreferNative
|
||||
cfg.Tools.Web.Proxy = strings.TrimSpace(req.Proxy)
|
||||
|
||||
if settings, ok := req.Settings["sogou"]; ok {
|
||||
cfg.Tools.Web.Sogou.Enabled = settings.Enabled
|
||||
cfg.Tools.Web.Sogou.MaxResults = settings.MaxResults
|
||||
}
|
||||
if settings, ok := req.Settings["duckduckgo"]; ok {
|
||||
cfg.Tools.Web.DuckDuckGo.Enabled = settings.Enabled
|
||||
cfg.Tools.Web.DuckDuckGo.MaxResults = settings.MaxResults
|
||||
}
|
||||
if settings, ok := req.Settings["brave"]; ok {
|
||||
cfg.Tools.Web.Brave.Enabled = settings.Enabled
|
||||
cfg.Tools.Web.Brave.MaxResults = settings.MaxResults
|
||||
if key := strings.TrimSpace(settings.APIKey); key != "" {
|
||||
cfg.Tools.Web.Brave.SetAPIKey(key)
|
||||
}
|
||||
}
|
||||
if settings, ok := req.Settings["tavily"]; ok {
|
||||
cfg.Tools.Web.Tavily.Enabled = settings.Enabled
|
||||
cfg.Tools.Web.Tavily.MaxResults = settings.MaxResults
|
||||
cfg.Tools.Web.Tavily.BaseURL = strings.TrimSpace(settings.BaseURL)
|
||||
if key := strings.TrimSpace(settings.APIKey); key != "" {
|
||||
cfg.Tools.Web.Tavily.SetAPIKey(key)
|
||||
}
|
||||
}
|
||||
if settings, ok := req.Settings["perplexity"]; ok {
|
||||
cfg.Tools.Web.Perplexity.Enabled = settings.Enabled
|
||||
cfg.Tools.Web.Perplexity.MaxResults = settings.MaxResults
|
||||
if key := strings.TrimSpace(settings.APIKey); key != "" {
|
||||
cfg.Tools.Web.Perplexity.SetAPIKey(key)
|
||||
}
|
||||
}
|
||||
if settings, ok := req.Settings["searxng"]; ok {
|
||||
cfg.Tools.Web.SearXNG.Enabled = settings.Enabled
|
||||
cfg.Tools.Web.SearXNG.MaxResults = settings.MaxResults
|
||||
cfg.Tools.Web.SearXNG.BaseURL = strings.TrimSpace(settings.BaseURL)
|
||||
}
|
||||
if settings, ok := req.Settings["glm_search"]; ok {
|
||||
cfg.Tools.Web.GLMSearch.Enabled = settings.Enabled
|
||||
cfg.Tools.Web.GLMSearch.MaxResults = settings.MaxResults
|
||||
cfg.Tools.Web.GLMSearch.BaseURL = strings.TrimSpace(settings.BaseURL)
|
||||
if key := strings.TrimSpace(settings.APIKey); key != "" {
|
||||
cfg.Tools.Web.GLMSearch.APIKey = *config.NewSecureString(key)
|
||||
}
|
||||
}
|
||||
if settings, ok := req.Settings["baidu_search"]; ok {
|
||||
cfg.Tools.Web.BaiduSearch.Enabled = settings.Enabled
|
||||
cfg.Tools.Web.BaiduSearch.MaxResults = settings.MaxResults
|
||||
cfg.Tools.Web.BaiduSearch.BaseURL = strings.TrimSpace(settings.BaseURL)
|
||||
if key := strings.TrimSpace(settings.APIKey); key != "" {
|
||||
cfg.Tools.Web.BaiduSearch.APIKey = *config.NewSecureString(key)
|
||||
}
|
||||
}
|
||||
|
||||
if err := config.SaveConfig(h.configPath, cfg); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(buildWebSearchConfigResponse(cfg)); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeWebSearchProvider(provider string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(provider)) {
|
||||
case "", "auto":
|
||||
return "auto"
|
||||
case "sogou", "brave", "tavily", "duckduckgo", "perplexity", "searxng", "glm_search", "baidu_search":
|
||||
return strings.ToLower(strings.TrimSpace(provider))
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func buildWebSearchConfigResponse(cfg *config.Config) webSearchConfigResponse {
|
||||
current := resolveCurrentWebSearchProvider(cfg)
|
||||
settings := map[string]webSearchProviderConfig{
|
||||
"sogou": {
|
||||
Enabled: cfg.Tools.Web.Sogou.Enabled,
|
||||
MaxResults: cfg.Tools.Web.Sogou.MaxResults,
|
||||
},
|
||||
"duckduckgo": {
|
||||
Enabled: cfg.Tools.Web.DuckDuckGo.Enabled,
|
||||
MaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults,
|
||||
},
|
||||
"brave": {
|
||||
Enabled: cfg.Tools.Web.Brave.Enabled,
|
||||
MaxResults: cfg.Tools.Web.Brave.MaxResults,
|
||||
APIKeySet: len(cfg.Tools.Web.Brave.APIKeys.Values()) > 0,
|
||||
},
|
||||
"tavily": {
|
||||
Enabled: cfg.Tools.Web.Tavily.Enabled,
|
||||
MaxResults: cfg.Tools.Web.Tavily.MaxResults,
|
||||
BaseURL: cfg.Tools.Web.Tavily.BaseURL,
|
||||
APIKeySet: len(cfg.Tools.Web.Tavily.APIKeys.Values()) > 0,
|
||||
},
|
||||
"perplexity": {
|
||||
Enabled: cfg.Tools.Web.Perplexity.Enabled,
|
||||
MaxResults: cfg.Tools.Web.Perplexity.MaxResults,
|
||||
APIKeySet: len(cfg.Tools.Web.Perplexity.APIKeys.Values()) > 0,
|
||||
},
|
||||
"searxng": {
|
||||
Enabled: cfg.Tools.Web.SearXNG.Enabled,
|
||||
MaxResults: cfg.Tools.Web.SearXNG.MaxResults,
|
||||
BaseURL: cfg.Tools.Web.SearXNG.BaseURL,
|
||||
},
|
||||
"glm_search": {
|
||||
Enabled: cfg.Tools.Web.GLMSearch.Enabled,
|
||||
MaxResults: cfg.Tools.Web.GLMSearch.MaxResults,
|
||||
BaseURL: cfg.Tools.Web.GLMSearch.BaseURL,
|
||||
APIKeySet: cfg.Tools.Web.GLMSearch.APIKey.String() != "",
|
||||
},
|
||||
"baidu_search": {
|
||||
Enabled: cfg.Tools.Web.BaiduSearch.Enabled,
|
||||
MaxResults: cfg.Tools.Web.BaiduSearch.MaxResults,
|
||||
BaseURL: cfg.Tools.Web.BaiduSearch.BaseURL,
|
||||
APIKeySet: cfg.Tools.Web.BaiduSearch.APIKey.String() != "",
|
||||
},
|
||||
}
|
||||
|
||||
providers := []webSearchProviderOption{
|
||||
{ID: "auto", Label: "Auto", Configured: current != "", Current: cfg.Tools.Web.Provider == "" || cfg.Tools.Web.Provider == "auto"},
|
||||
{ID: "sogou", Label: "Sogou", Configured: cfg.Tools.Web.Sogou.Enabled, Current: current == "sogou"},
|
||||
{ID: "duckduckgo", Label: "DuckDuckGo", Configured: cfg.Tools.Web.DuckDuckGo.Enabled, Current: current == "duckduckgo"},
|
||||
{ID: "brave", Label: "Brave Search", Configured: cfg.Tools.Web.Brave.Enabled && len(cfg.Tools.Web.Brave.APIKeys.Values()) > 0, Current: current == "brave", RequiresAuth: true},
|
||||
{ID: "tavily", Label: "Tavily", Configured: cfg.Tools.Web.Tavily.Enabled && len(cfg.Tools.Web.Tavily.APIKeys.Values()) > 0, Current: current == "tavily", RequiresAuth: true},
|
||||
{ID: "perplexity", Label: "Perplexity", Configured: cfg.Tools.Web.Perplexity.Enabled && len(cfg.Tools.Web.Perplexity.APIKeys.Values()) > 0, Current: current == "perplexity", RequiresAuth: true},
|
||||
{ID: "searxng", Label: "SearXNG", Configured: cfg.Tools.Web.SearXNG.Enabled && strings.TrimSpace(cfg.Tools.Web.SearXNG.BaseURL) != "", Current: current == "searxng"},
|
||||
{ID: "glm_search", Label: "GLM Search", Configured: cfg.Tools.Web.GLMSearch.Enabled && cfg.Tools.Web.GLMSearch.APIKey.String() != "", Current: current == "glm_search", RequiresAuth: true},
|
||||
{ID: "baidu_search", Label: "Baidu Search", Configured: cfg.Tools.Web.BaiduSearch.Enabled && cfg.Tools.Web.BaiduSearch.APIKey.String() != "", Current: current == "baidu_search", RequiresAuth: true},
|
||||
}
|
||||
|
||||
provider := cfg.Tools.Web.Provider
|
||||
if provider == "" {
|
||||
provider = "auto"
|
||||
}
|
||||
|
||||
return webSearchConfigResponse{
|
||||
Provider: provider,
|
||||
CurrentService: current,
|
||||
PreferNative: cfg.Tools.Web.PreferNative,
|
||||
Proxy: cfg.Tools.Web.Proxy,
|
||||
Providers: providers,
|
||||
Settings: settings,
|
||||
}
|
||||
}
|
||||
|
||||
func resolveCurrentWebSearchProvider(cfg *config.Config) string {
|
||||
selected := normalizeWebSearchProvider(cfg.Tools.Web.Provider)
|
||||
if selected != "" && selected != "auto" && webSearchProviderConfigured(cfg, selected) {
|
||||
return selected
|
||||
}
|
||||
for _, name := range []string{"sogou", "perplexity", "brave", "searxng", "tavily", "duckduckgo", "baidu_search", "glm_search"} {
|
||||
if webSearchProviderConfigured(cfg, name) {
|
||||
return name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func webSearchProviderConfigured(cfg *config.Config, name string) bool {
|
||||
switch name {
|
||||
case "sogou":
|
||||
return cfg.Tools.Web.Sogou.Enabled
|
||||
case "duckduckgo":
|
||||
return cfg.Tools.Web.DuckDuckGo.Enabled
|
||||
case "brave":
|
||||
return cfg.Tools.Web.Brave.Enabled && len(cfg.Tools.Web.Brave.APIKeys.Values()) > 0
|
||||
case "tavily":
|
||||
return cfg.Tools.Web.Tavily.Enabled && len(cfg.Tools.Web.Tavily.APIKeys.Values()) > 0
|
||||
case "perplexity":
|
||||
return cfg.Tools.Web.Perplexity.Enabled && len(cfg.Tools.Web.Perplexity.APIKeys.Values()) > 0
|
||||
case "searxng":
|
||||
return cfg.Tools.Web.SearXNG.Enabled && strings.TrimSpace(cfg.Tools.Web.SearXNG.BaseURL) != ""
|
||||
case "glm_search":
|
||||
return cfg.Tools.Web.GLMSearch.Enabled && cfg.Tools.Web.GLMSearch.APIKey.String() != ""
|
||||
case "baidu_search":
|
||||
return cfg.Tools.Web.BaiduSearch.Enabled && cfg.Tools.Web.BaiduSearch.APIKey.String() != ""
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,3 +196,101 @@ func TestHandleUpdateToolState(t *testing.T) {
|
||||
t.Fatalf("cron should be enabled: %#v", updated.Tools.Cron)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGetWebSearchConfig(t *testing.T) {
|
||||
configPath, cleanup := setupOAuthTestEnv(t)
|
||||
defer cleanup()
|
||||
|
||||
cfg, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error = %v", err)
|
||||
}
|
||||
cfg.Tools.Web.Provider = "sogou"
|
||||
cfg.Tools.Web.Sogou.Enabled = true
|
||||
cfg.Tools.Web.Sogou.MaxResults = 6
|
||||
cfg.Tools.Web.Brave.Enabled = true
|
||||
cfg.Tools.Web.Brave.SetAPIKey("brave-test-key")
|
||||
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||
t.Fatalf("SaveConfig() error = %v", err)
|
||||
}
|
||||
|
||||
h := NewHandler(configPath)
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/tools/web-search-config", nil)
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp webSearchConfigResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v", err)
|
||||
}
|
||||
if resp.Provider != "sogou" {
|
||||
t.Fatalf("provider = %q, want sogou", resp.Provider)
|
||||
}
|
||||
if resp.CurrentService != "sogou" {
|
||||
t.Fatalf("current_service = %q, want sogou", resp.CurrentService)
|
||||
}
|
||||
if !resp.Settings["brave"].APIKeySet {
|
||||
t.Fatalf("brave api_key_set should be true: %#v", resp.Settings["brave"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUpdateWebSearchConfig(t *testing.T) {
|
||||
configPath, cleanup := setupOAuthTestEnv(t)
|
||||
defer cleanup()
|
||||
|
||||
h := NewHandler(configPath)
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPut,
|
||||
"/api/tools/web-search-config",
|
||||
bytes.NewBufferString(`{
|
||||
"provider":"brave",
|
||||
"prefer_native":false,
|
||||
"proxy":"http://127.0.0.1:7890",
|
||||
"settings":{
|
||||
"sogou":{"enabled":true,"max_results":4},
|
||||
"brave":{"enabled":true,"max_results":7,"api_key":"brave-new-key"},
|
||||
"duckduckgo":{"enabled":false,"max_results":3}
|
||||
}
|
||||
}`),
|
||||
)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
|
||||
updated, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error = %v", err)
|
||||
}
|
||||
if updated.Tools.Web.Provider != "brave" {
|
||||
t.Fatalf("provider = %q, want brave", updated.Tools.Web.Provider)
|
||||
}
|
||||
if updated.Tools.Web.PreferNative {
|
||||
t.Fatal("prefer_native should be false after update")
|
||||
}
|
||||
if updated.Tools.Web.Proxy != "http://127.0.0.1:7890" {
|
||||
t.Fatalf("proxy = %q", updated.Tools.Web.Proxy)
|
||||
}
|
||||
if !updated.Tools.Web.Sogou.Enabled || updated.Tools.Web.Sogou.MaxResults != 4 {
|
||||
t.Fatalf("sogou config not updated: %#v", updated.Tools.Web.Sogou)
|
||||
}
|
||||
if !updated.Tools.Web.Brave.Enabled || updated.Tools.Web.Brave.MaxResults != 7 {
|
||||
t.Fatalf("brave config not updated: %#v", updated.Tools.Web.Brave)
|
||||
}
|
||||
if updated.Tools.Web.Brave.APIKey() != "brave-new-key" {
|
||||
t.Fatalf("brave api key not updated")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user