Make web search auto-switch with UI language

Default the sample web search provider to auto, route Sogou vs DuckDuckGo dynamically based on query/UI language, and sync frontend language changes back to the backend so Current Service and runtime selection stay aligned.
This commit is contained in:
SiYue-ZO 2026-04-15 18:45:28 +08:00
parent bb953b788b
commit 2784223ad5
11 changed files with 375 additions and 21 deletions

View file

@ -269,7 +269,7 @@
"base_url": "",
"max_results": 0
},
"provider": "sogou",
"provider": "auto",
"sogou": {
"enabled": true,
"max_results": 5

View file

@ -767,6 +767,21 @@ func TestDefaultConfig_WebProviderIsAuto(t *testing.T) {
}
}
func TestConfigExample_WebProviderIsAuto(t *testing.T) {
data, err := os.ReadFile(filepath.Join("..", "..", "config", "config.example.json"))
if err != nil {
t.Fatalf("ReadFile(config.example.json) error: %v", err)
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
t.Fatalf("Unmarshal(config.example.json) error: %v", err)
}
if cfg.Tools.Web.Provider != "auto" {
t.Fatalf("config.example.json tools.web.provider = %q, want auto", cfg.Tools.Web.Provider)
}
}
func TestDefaultConfig_ToolFeedbackDisabled(t *testing.T) {
cfg := DefaultConfig()
if cfg.Agents.Defaults.ToolFeedback.Enabled {

View file

@ -15,6 +15,7 @@ import (
"strings"
"sync/atomic"
"time"
"unicode"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
@ -57,6 +58,8 @@ var (
reSogouRealURL = regexp.MustCompile(`url=([^&]+)`)
)
var preferredWebSearchLanguage atomic.Value
type APIKeyPool struct {
keys []string
current uint32
@ -247,6 +250,27 @@ func mapBaiduRecencyFilter(rangeCode string) string {
}
}
func normalizePreferredWebSearchLanguage(lang string) string {
lang = strings.ToLower(strings.TrimSpace(lang))
switch {
case strings.HasPrefix(lang, "zh"), lang == "chinese":
return "zh"
case strings.HasPrefix(lang, "en"), lang == "english":
return "en"
default:
return ""
}
}
func SetPreferredWebSearchLanguage(lang string) {
preferredWebSearchLanguage.Store(normalizePreferredWebSearchLanguage(lang))
}
func GetPreferredWebSearchLanguage() string {
lang, _ := preferredWebSearchLanguage.Load().(string)
return lang
}
type BraveSearchProvider struct {
keyPool *APIKeyPool
proxy string
@ -1048,8 +1072,9 @@ func (p *BaiduSearchProvider) Search(
}
type WebSearchTool struct {
provider SearchProvider
maxResults int
provider SearchProvider
maxResults int
providerResolver func(query string) (SearchProvider, int)
}
type WebSearchToolOptions struct {
@ -1228,30 +1253,111 @@ func (opts WebSearchToolOptions) providerByName(name string) (SearchProvider, in
}
}
func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) {
provider, maxResults, err := opts.providerByName(opts.Provider)
func containsHan(text string) bool {
for _, r := range text {
if unicode.Is(unicode.Han, r) {
return true
}
}
return false
}
func containsLatinLetter(text string) bool {
for _, r := range text {
if unicode.IsLetter(r) && unicode.In(r, unicode.Latin) {
return true
}
}
return false
}
func prefersDuckDuckGoQuery(text string) bool {
trimmed := strings.TrimSpace(text)
if trimmed == "" {
return GetPreferredWebSearchLanguage() == "en"
}
if containsHan(trimmed) {
return false
}
if containsLatinLetter(trimmed) {
return true
}
return GetPreferredWebSearchLanguage() == "en"
}
func (opts WebSearchToolOptions) buildProviderResolver() (func(query string) (SearchProvider, int), error) {
providerName := strings.ToLower(strings.TrimSpace(opts.Provider))
if providerName != "" && providerName != "auto" {
provider, maxResults, err := opts.providerByName(providerName)
if err != nil {
return nil, err
}
if provider == nil {
return func(string) (SearchProvider, int) { return nil, 0 }, nil
}
return func(string) (SearchProvider, int) { return provider, maxResults }, nil
}
for _, name := range []string{"perplexity", "brave", "searxng", "tavily"} {
provider, maxResults, err := opts.providerByName(name)
if err != nil {
return nil, err
}
if provider != nil {
return func(string) (SearchProvider, int) { return provider, maxResults }, nil
}
}
sogouProvider, sogouMaxResults, err := opts.providerByName("sogou")
if err != nil {
return nil, err
}
duckProvider, duckMaxResults, err := opts.providerByName("duckduckgo")
if err != nil {
return nil, err
}
if sogouProvider != nil && duckProvider != nil {
return func(query string) (SearchProvider, int) {
if prefersDuckDuckGoQuery(query) {
return duckProvider, duckMaxResults
}
return sogouProvider, sogouMaxResults
}, nil
}
if sogouProvider != nil {
return func(string) (SearchProvider, int) { return sogouProvider, sogouMaxResults }, nil
}
if duckProvider != nil {
return func(string) (SearchProvider, int) { return duckProvider, duckMaxResults }, nil
}
if provider == nil {
for _, name := range []string{"perplexity", "brave", "searxng", "tavily", "sogou", "duckduckgo", "baidu_search", "glm_search"} {
provider, maxResults, err = opts.providerByName(name)
if err != nil {
return nil, err
}
if provider != nil {
break
}
for _, name := range []string{"baidu_search", "glm_search"} {
provider, maxResults, err := opts.providerByName(name)
if err != nil {
return nil, err
}
if provider != nil {
return func(string) (SearchProvider, int) { return provider, maxResults }, nil
}
}
return func(string) (SearchProvider, int) { return nil, 0 }, nil
}
func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) {
resolver, err := opts.buildProviderResolver()
if err != nil {
return nil, err
}
provider, maxResults := resolver("")
if provider == nil {
return nil, nil
}
return &WebSearchTool{
provider: provider,
maxResults: maxResults,
provider: provider,
maxResults: maxResults,
providerResolver: resolver,
}, nil
}
@ -1294,13 +1400,22 @@ func (t *WebSearchTool) Execute(ctx context.Context, args map[string]any) *ToolR
}
query = strings.TrimSpace(query)
count64, err := getInt64Arg(args, "count", int64(t.maxResults))
provider := t.provider
maxResults := t.maxResults
if t.providerResolver != nil {
provider, maxResults = t.providerResolver(query)
}
if provider == nil {
return ErrorResult("search provider is not configured")
}
count64, err := getInt64Arg(args, "count", int64(maxResults))
if err != nil {
return ErrorResult(err.Error())
}
count := t.maxResults
count := maxResults
if count64 > 0 && count64 <= 10 {
count = int(count64)
count = min(int(count64), maxResults)
}
rangeCode, err := normalizeSearchRange("")
@ -1318,7 +1433,7 @@ func (t *WebSearchTool) Execute(ctx context.Context, args map[string]any) *ToolR
}
}
result, err := t.provider.Search(ctx, query, count, rangeCode)
result, err := provider.Search(ctx, query, count, rangeCode)
if err != nil {
return ErrorResult(fmt.Sprintf("search failed: %v", err))
}

View file

@ -1726,6 +1726,50 @@ func TestApplySogouRangeHint(t *testing.T) {
}
}
func TestPrefersDuckDuckGoQuery(t *testing.T) {
SetPreferredWebSearchLanguage("")
t.Cleanup(func() {
SetPreferredWebSearchLanguage("")
})
tests := []struct {
name string
query string
want bool
}{
{name: "english words", query: "golang web search", want: true},
{name: "english with numbers", query: "OpenAI o3 price 2026", want: true},
{name: "chinese", query: "今天上海天气", want: false},
{name: "mixed with han", query: "golang 中文 教程", want: false},
{name: "numbers only", query: "2026 04 15", want: false},
{name: "blank", query: " ", want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := prefersDuckDuckGoQuery(tt.query); got != tt.want {
t.Fatalf("prefersDuckDuckGoQuery(%q) = %v, want %v", tt.query, got, tt.want)
}
})
}
}
func TestPrefersDuckDuckGoQuery_FallsBackToPreferredLanguage(t *testing.T) {
SetPreferredWebSearchLanguage("en")
t.Cleanup(func() {
SetPreferredWebSearchLanguage("")
})
if !prefersDuckDuckGoQuery("2026 04 15") {
t.Fatal("numeric query should prefer DuckDuckGo when preferred language is English")
}
SetPreferredWebSearchLanguage("zh")
if prefersDuckDuckGoQuery("2026 04 15") {
t.Fatal("numeric query should prefer Sogou when preferred language is Chinese")
}
}
func TestWebTool_SogouPriorityAndExplicitProvider(t *testing.T) {
tool, err := NewWebSearchTool(WebSearchToolOptions{
SogouEnabled: true,
@ -1773,6 +1817,55 @@ func TestWebTool_AutoProviderPrefersConfiguredProvidersBeforeSogou(t *testing.T)
}
}
type stubSearchProvider struct {
result string
calls []string
}
func (p *stubSearchProvider) Search(
_ context.Context,
query string,
_ int,
_ string,
) (string, error) {
p.calls = append(p.calls, query)
return p.result, nil
}
func TestWebTool_AutoProviderRoutesQueryLanguageBetweenSogouAndDuckDuckGo(t *testing.T) {
sogouProvider := &stubSearchProvider{result: "via sogou"}
duckProvider := &stubSearchProvider{result: "via duckduckgo"}
tool := &WebSearchTool{
provider: sogouProvider,
maxResults: 5,
providerResolver: func(query string) (SearchProvider, int) {
if prefersDuckDuckGoQuery(query) {
return duckProvider, 3
}
return sogouProvider, 5
},
}
enResult := tool.Execute(context.Background(), map[string]any{"query": "golang concurrency", "count": 10})
if enResult.IsError {
t.Fatalf("english Execute() returned error: %s", enResult.ForLLM)
}
if len(duckProvider.calls) != 1 || duckProvider.calls[0] != "golang concurrency" {
t.Fatalf("english query should use DuckDuckGo provider, calls=%v", duckProvider.calls)
}
if len(sogouProvider.calls) != 0 {
t.Fatalf("english query should not call Sogou provider, calls=%v", sogouProvider.calls)
}
zhResult := tool.Execute(context.Background(), map[string]any{"query": "今天上海天气"})
if zhResult.IsError {
t.Fatalf("chinese Execute() returned error: %s", zhResult.ForLLM)
}
if len(sogouProvider.calls) != 1 || sogouProvider.calls[0] != "今天上海天气" {
t.Fatalf("chinese query should use Sogou provider, calls=%v", sogouProvider.calls)
}
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {

View file

@ -89,6 +89,7 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
// Skills and tools support/actions
h.registerSkillRoutes(mux)
h.registerToolRoutes(mux)
h.registerUIRoutes(mux)
// OS startup / launch-at-login
h.registerStartupRoutes(mux)

View file

@ -8,6 +8,7 @@ import (
"strings"
"github.com/sipeed/picoclaw/pkg/config"
picotools "github.com/sipeed/picoclaw/pkg/tools"
)
type toolCatalogEntry struct {
@ -640,7 +641,27 @@ func resolveCurrentWebSearchProvider(cfg *config.Config) string {
if selected != "" && selected != "auto" && webSearchProviderConfigured(cfg, selected) {
return selected
}
for _, name := range []string{"perplexity", "brave", "searxng", "tavily", "sogou", "duckduckgo", "baidu_search", "glm_search"} {
for _, name := range []string{"perplexity", "brave", "searxng", "tavily"} {
if webSearchProviderConfigured(cfg, name) {
return name
}
}
if webSearchProviderConfigured(cfg, "sogou") && webSearchProviderConfigured(cfg, "duckduckgo") {
if picotools.GetPreferredWebSearchLanguage() == "en" {
return "duckduckgo"
}
return "sogou"
}
if webSearchProviderConfigured(cfg, "sogou") {
return "sogou"
}
if webSearchProviderConfigured(cfg, "duckduckgo") {
return "duckduckgo"
}
for _, name := range []string{"baidu_search", "glm_search"} {
if webSearchProviderConfigured(cfg, name) {
return name
}

View file

@ -9,6 +9,7 @@ import (
"testing"
"github.com/sipeed/picoclaw/pkg/config"
picotools "github.com/sipeed/picoclaw/pkg/tools"
)
func TestHandleListTools(t *testing.T) {
@ -391,3 +392,24 @@ func TestResolveCurrentWebSearchProvider_PrefersConfiguredProvidersBeforeSogou(t
t.Fatalf("resolveCurrentWebSearchProvider() = %q, want brave", got)
}
}
func TestResolveCurrentWebSearchProvider_UsesPreferredLanguageForSogouAndDuckDuckGo(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Tools.Web.Provider = "auto"
cfg.Tools.Web.Sogou.Enabled = true
cfg.Tools.Web.DuckDuckGo.Enabled = true
picotools.SetPreferredWebSearchLanguage("en")
t.Cleanup(func() {
picotools.SetPreferredWebSearchLanguage("")
})
if got := resolveCurrentWebSearchProvider(cfg); got != "duckduckgo" {
t.Fatalf("resolveCurrentWebSearchProvider() = %q, want duckduckgo", got)
}
picotools.SetPreferredWebSearchLanguage("zh")
if got := resolveCurrentWebSearchProvider(cfg); got != "sogou" {
t.Fatalf("resolveCurrentWebSearchProvider() = %q, want sogou", got)
}
}

27
web/backend/api/ui.go Normal file
View file

@ -0,0 +1,27 @@
package api
import (
"encoding/json"
"net/http"
"github.com/sipeed/picoclaw/pkg/tools"
)
type uiLanguageRequest struct {
Language string `json:"language"`
}
func (h *Handler) registerUIRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /api/ui/language", h.handleSetUILanguage)
}
func (h *Handler) handleSetUILanguage(w http.ResponseWriter, r *http.Request) {
var req uiLanguageRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
tools.SetPreferredWebSearchLanguage(req.Language)
w.WriteHeader(http.StatusNoContent)
}

View file

@ -0,0 +1,48 @@
package api
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/sipeed/picoclaw/pkg/tools"
)
func TestHandleSetUILanguage(t *testing.T) {
tools.SetPreferredWebSearchLanguage("")
t.Cleanup(func() {
tools.SetPreferredWebSearchLanguage("")
})
h := NewHandler("")
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/ui/language", strings.NewReader(`{"language":"zh"}`))
req.Header.Set("Content-Type", "application/json")
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusNoContent, rec.Body.String())
}
if got := tools.GetPreferredWebSearchLanguage(); got != "zh" {
t.Fatalf("preferred web search language = %q, want zh", got)
}
}
func TestHandleSetUILanguage_RejectsInvalidJSON(t *testing.T) {
h := NewHandler("")
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/ui/language", strings.NewReader(`{`))
req.Header.Set("Content-Type", "application/json")
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
}
}

View file

@ -29,6 +29,7 @@ import (
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/netbind"
"github.com/sipeed/picoclaw/pkg/tools"
"github.com/sipeed/picoclaw/web/backend/api"
"github.com/sipeed/picoclaw/web/backend/dashboardauth"
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
@ -404,6 +405,7 @@ func main() {
if *lang != "" {
SetLanguage(*lang)
}
tools.SetPreferredWebSearchLanguage(string(GetLanguage()))
// Resolve config path
configPath := utils.GetDefaultConfigPath()

View file

@ -7,6 +7,8 @@ import i18n from "i18next"
import LanguageDetector from "i18next-browser-languagedetector"
import { initReactI18next } from "react-i18next"
import { launcherFetch } from "@/api/http"
import en from "./locales/en.json"
import zh from "./locales/zh.json"
@ -44,6 +46,14 @@ i18n.on("languageChanged", (lng) => {
} else {
dayjs.locale("en")
}
void launcherFetch("/api/ui/language", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ language: lng }),
}).catch(() => {
// Keep UI language changes responsive even if backend sync fails.
})
})
export default i18n