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:
parent
bb953b788b
commit
2784223ad5
11 changed files with 375 additions and 21 deletions
|
|
@ -269,7 +269,7 @@
|
||||||
"base_url": "",
|
"base_url": "",
|
||||||
"max_results": 0
|
"max_results": 0
|
||||||
},
|
},
|
||||||
"provider": "sogou",
|
"provider": "auto",
|
||||||
"sogou": {
|
"sogou": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"max_results": 5
|
"max_results": 5
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func TestDefaultConfig_ToolFeedbackDisabled(t *testing.T) {
|
||||||
cfg := DefaultConfig()
|
cfg := DefaultConfig()
|
||||||
if cfg.Agents.Defaults.ToolFeedback.Enabled {
|
if cfg.Agents.Defaults.ToolFeedback.Enabled {
|
||||||
|
|
|
||||||
135
pkg/tools/web.go
135
pkg/tools/web.go
|
|
@ -15,6 +15,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
|
@ -57,6 +58,8 @@ 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
|
||||||
|
|
@ -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 {
|
type BraveSearchProvider struct {
|
||||||
keyPool *APIKeyPool
|
keyPool *APIKeyPool
|
||||||
proxy string
|
proxy string
|
||||||
|
|
@ -1050,6 +1074,7 @@ func (p *BaiduSearchProvider) Search(
|
||||||
type WebSearchTool struct {
|
type WebSearchTool struct {
|
||||||
provider SearchProvider
|
provider SearchProvider
|
||||||
maxResults int
|
maxResults int
|
||||||
|
providerResolver func(query string) (SearchProvider, int)
|
||||||
}
|
}
|
||||||
|
|
||||||
type WebSearchToolOptions struct {
|
type WebSearchToolOptions struct {
|
||||||
|
|
@ -1228,23 +1253,103 @@ func (opts WebSearchToolOptions) providerByName(name string) (SearchProvider, in
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) {
|
func containsHan(text string) bool {
|
||||||
provider, maxResults, err := opts.providerByName(opts.Provider)
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if provider == nil {
|
if provider == nil {
|
||||||
for _, name := range []string{"perplexity", "brave", "searxng", "tavily", "sogou", "duckduckgo", "baidu_search", "glm_search"} {
|
return func(string) (SearchProvider, int) { return nil, 0 }, nil
|
||||||
provider, maxResults, err = opts.providerByName(name)
|
}
|
||||||
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if provider != nil {
|
if provider != nil {
|
||||||
break
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
if provider == nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
@ -1252,6 +1357,7 @@ func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) {
|
||||||
return &WebSearchTool{
|
return &WebSearchTool{
|
||||||
provider: provider,
|
provider: provider,
|
||||||
maxResults: maxResults,
|
maxResults: maxResults,
|
||||||
|
providerResolver: resolver,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1294,13 +1400,22 @@ func (t *WebSearchTool) Execute(ctx context.Context, args map[string]any) *ToolR
|
||||||
}
|
}
|
||||||
query = strings.TrimSpace(query)
|
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 {
|
if err != nil {
|
||||||
return ErrorResult(err.Error())
|
return ErrorResult(err.Error())
|
||||||
}
|
}
|
||||||
count := t.maxResults
|
count := maxResults
|
||||||
if count64 > 0 && count64 <= 10 {
|
if count64 > 0 && count64 <= 10 {
|
||||||
count = int(count64)
|
count = min(int(count64), maxResults)
|
||||||
}
|
}
|
||||||
|
|
||||||
rangeCode, err := normalizeSearchRange("")
|
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 {
|
if err != nil {
|
||||||
return ErrorResult(fmt.Sprintf("search failed: %v", err))
|
return ErrorResult(fmt.Sprintf("search failed: %v", err))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func TestWebTool_SogouPriorityAndExplicitProvider(t *testing.T) {
|
||||||
tool, err := NewWebSearchTool(WebSearchToolOptions{
|
tool, err := NewWebSearchTool(WebSearchToolOptions{
|
||||||
SogouEnabled: true,
|
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)
|
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||||
|
|
||||||
func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,7 @@ 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)
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
picotools "github.com/sipeed/picoclaw/pkg/tools"
|
||||||
)
|
)
|
||||||
|
|
||||||
type toolCatalogEntry struct {
|
type toolCatalogEntry struct {
|
||||||
|
|
@ -640,7 +641,27 @@ func resolveCurrentWebSearchProvider(cfg *config.Config) string {
|
||||||
if selected != "" && selected != "auto" && webSearchProviderConfigured(cfg, selected) {
|
if selected != "" && selected != "auto" && webSearchProviderConfigured(cfg, selected) {
|
||||||
return 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) {
|
if webSearchProviderConfigured(cfg, name) {
|
||||||
return name
|
return name
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ 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) {
|
||||||
|
|
@ -391,3 +392,24 @@ func TestResolveCurrentWebSearchProvider_PrefersConfiguredProvidersBeforeSogou(t
|
||||||
t.Fatalf("resolveCurrentWebSearchProvider() = %q, want brave", got)
|
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
27
web/backend/api/ui.go
Normal 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)
|
||||||
|
}
|
||||||
48
web/backend/api/ui_test.go
Normal file
48
web/backend/api/ui_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -29,6 +29,7 @@ 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"
|
||||||
|
|
@ -404,6 +405,7 @@ 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,6 +7,8 @@ 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"
|
||||||
|
|
||||||
|
|
@ -44,6 +46,14 @@ 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