Keep launcher locale changes from mutating shared web-search routing (#2573)

The launcher wired UI language changes into a process-global backend
switch that changed auto web-search provider selection and the
reported current service for every handler in the same process.

This narrows the fix to the validated leak: remove backend sync from
frontend locale changes, drop the now-unused UI endpoint, and make
auto selection fall back to a stable default when the query itself
does not contain a script hint.

Constraint: Keep the patch small and mergeable without redesigning per-user preference storage
Rejected: Add per-user backend language state | larger scope than the validated bug and unclear maintainer preference
Rejected: Persist preferred language in config | still shares mutable state across clients of the same instance
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: If locale-aware provider routing is reintroduced later, scope it to explicit config or request context instead of package-global state
Tested: go test ./web/backend/api ./pkg/tools -count=1; pnpm lint; pnpm build
Not-tested: Full make check; live multi-browser manual launcher run after the backend endpoint removal
This commit is contained in:
Junghwan 2026-04-24 14:45:25 +09:00 committed by GitHub
parent 47a881b11f
commit 293477b02a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 5 additions and 150 deletions

View file

@ -58,8 +58,6 @@ var (
reSogouRealURL = regexp.MustCompile(`url=([^&]+)`) reSogouRealURL = regexp.MustCompile(`url=([^&]+)`)
) )
var preferredWebSearchLanguage atomic.Value
type APIKeyPool struct { type APIKeyPool struct {
keys []string keys []string
current uint32 current uint32
@ -250,27 +248,6 @@ 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 { type BraveSearchProvider struct {
keyPool *APIKeyPool keyPool *APIKeyPool
proxy string proxy string
@ -1420,7 +1397,7 @@ func containsLatinLetter(text string) bool {
func prefersDuckDuckGoQuery(text string) bool { func prefersDuckDuckGoQuery(text string) bool {
trimmed := strings.TrimSpace(text) trimmed := strings.TrimSpace(text)
if trimmed == "" { if trimmed == "" {
return GetPreferredWebSearchLanguage() == "en" return false
} }
if containsHan(trimmed) { if containsHan(trimmed) {
return false return false
@ -1428,7 +1405,7 @@ func prefersDuckDuckGoQuery(text string) bool {
if containsLatinLetter(trimmed) { if containsLatinLetter(trimmed) {
return true return true
} }
return GetPreferredWebSearchLanguage() == "en" return false
} }
func (opts WebSearchToolOptions) buildProviderResolver() (func(query string) (SearchProvider, int), error) { func (opts WebSearchToolOptions) buildProviderResolver() (func(query string) (SearchProvider, int), error) {

View file

@ -1778,11 +1778,6 @@ func TestApplySogouRangeHint(t *testing.T) {
} }
func TestPrefersDuckDuckGoQuery(t *testing.T) { func TestPrefersDuckDuckGoQuery(t *testing.T) {
SetPreferredWebSearchLanguage("")
t.Cleanup(func() {
SetPreferredWebSearchLanguage("")
})
tests := []struct { tests := []struct {
name string name string
query string query string
@ -1805,19 +1800,9 @@ func TestPrefersDuckDuckGoQuery(t *testing.T) {
} }
} }
func TestPrefersDuckDuckGoQuery_FallsBackToPreferredLanguage(t *testing.T) { func TestPrefersDuckDuckGoQuery_DoesNotUseGlobalLanguageFallback(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") { if prefersDuckDuckGoQuery("2026 04 15") {
t.Fatal("numeric query should prefer Sogou when preferred language is Chinese") t.Fatal("numeric query should default to Sogou when no script-specific hint is present")
} }
} }

View file

@ -65,14 +65,6 @@ func NewAPIKeyPool(keys []string) *APIKeyPool {
return integrationtools.NewAPIKeyPool(keys) return integrationtools.NewAPIKeyPool(keys)
} }
func SetPreferredWebSearchLanguage(lang string) {
integrationtools.SetPreferredWebSearchLanguage(lang)
}
func GetPreferredWebSearchLanguage() string {
return integrationtools.GetPreferredWebSearchLanguage()
}
func WebSearchToolOptionsFromConfig(cfg *config.Config) WebSearchToolOptions { func WebSearchToolOptionsFromConfig(cfg *config.Config) WebSearchToolOptions {
return integrationtools.WebSearchToolOptionsFromConfig(cfg) return integrationtools.WebSearchToolOptionsFromConfig(cfg)
} }

View file

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

View file

@ -9,7 +9,6 @@ import (
"testing" "testing"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
picotools "github.com/sipeed/picoclaw/pkg/tools"
) )
func TestHandleListTools(t *testing.T) { func TestHandleListTools(t *testing.T) {
@ -517,22 +516,12 @@ func TestResolveCurrentWebSearchProvider_FallsBackWhenProviderIsUnknown(t *testi
} }
} }
func TestResolveCurrentWebSearchProvider_UsesPreferredLanguageForSogouAndDuckDuckGo(t *testing.T) { func TestResolveCurrentWebSearchProvider_PrefersStableDefaultForSogouAndDuckDuckGo(t *testing.T) {
cfg := config.DefaultConfig() cfg := config.DefaultConfig()
cfg.Tools.Web.Provider = "auto" cfg.Tools.Web.Provider = "auto"
cfg.Tools.Web.Sogou.Enabled = true cfg.Tools.Web.Sogou.Enabled = true
cfg.Tools.Web.DuckDuckGo.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" { if got := resolveCurrentWebSearchProvider(cfg); got != "sogou" {
t.Fatalf("resolveCurrentWebSearchProvider() = %q, want sogou", got) t.Fatalf("resolveCurrentWebSearchProvider() = %q, want sogou", got)
} }

View file

@ -1,27 +0,0 @@
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

@ -1,48 +0,0 @@
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,7 +29,6 @@ import (
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/netbind" "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/api"
"github.com/sipeed/picoclaw/web/backend/dashboardauth" "github.com/sipeed/picoclaw/web/backend/dashboardauth"
"github.com/sipeed/picoclaw/web/backend/launcherconfig" "github.com/sipeed/picoclaw/web/backend/launcherconfig"
@ -409,7 +408,6 @@ func main() {
if *lang != "" { if *lang != "" {
SetLanguage(*lang) SetLanguage(*lang)
} }
tools.SetPreferredWebSearchLanguage(string(GetLanguage()))
// Resolve config path // Resolve config path
configPath := utils.GetDefaultConfigPath() configPath := utils.GetDefaultConfigPath()

View file

@ -7,8 +7,6 @@ import i18n from "i18next"
import LanguageDetector from "i18next-browser-languagedetector" import LanguageDetector from "i18next-browser-languagedetector"
import { initReactI18next } from "react-i18next" import { initReactI18next } from "react-i18next"
import { launcherFetch } from "@/api/http"
import en from "./locales/en.json" import en from "./locales/en.json"
import zh from "./locales/zh.json" import zh from "./locales/zh.json"
@ -46,14 +44,6 @@ i18n.on("languageChanged", (lng) => {
} else { } else {
dayjs.locale("en") 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 export default i18n