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:
parent
47a881b11f
commit
293477b02a
9 changed files with 5 additions and 150 deletions
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue