diff --git a/web/backend/api/config.go b/web/backend/api/config.go index 06391b8fc..fb2f01f25 100644 --- a/web/backend/api/config.go +++ b/web/backend/api/config.go @@ -57,6 +57,7 @@ func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) { http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) return } + webAPIKeyPatch := parseWebSearchAPIKeyPatch(body) if execAllowRemoteOmitted(body) { cfg.Tools.Exec.AllowRemote = config.DefaultConfig().Tools.Exec.AllowRemote } @@ -69,6 +70,7 @@ func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) { return } applyConfigSecretsFromMap(&cfg, raw) + applyWebSearchAPIKeyPatch(&cfg, webAPIKeyPatch) if errs := validateConfig(&cfg); len(errs) > 0 { w.Header().Set("Content-Type", "application/json") @@ -123,6 +125,7 @@ func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) { http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) return } + webAPIKeyPatch := parseWebSearchAPIKeyPatch(patchBody) // Load existing config and marshal to a map for merging cfg, err := config.LoadConfig(h.configPath) @@ -166,6 +169,7 @@ func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) { return } applyConfigSecretsFromMap(&newCfg, base) + applyWebSearchAPIKeyPatch(&newCfg, webAPIKeyPatch) if errs := validateConfig(&newCfg); len(errs) > 0 { w.Header().Set("Content-Type", "application/json") @@ -186,6 +190,49 @@ func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) } +type webSearchAPIKeyPatch struct { + glmSearchAPIKey *string + baiduSearchAPIKey *string +} + +func parseWebSearchAPIKeyPatch(body []byte) webSearchAPIKeyPatch { + var raw struct { + Tools *struct { + Web *struct { + GLMSearch *struct { + APIKey *string `json:"api_key"` + } `json:"glm_search"` + BaiduSearch *struct { + APIKey *string `json:"api_key"` + } `json:"baidu_search"` + } `json:"web"` + } `json:"tools"` + } + if err := json.Unmarshal(body, &raw); err != nil { + return webSearchAPIKeyPatch{} + } + + patch := webSearchAPIKeyPatch{} + if raw.Tools != nil && raw.Tools.Web != nil { + if raw.Tools.Web.GLMSearch != nil && raw.Tools.Web.GLMSearch.APIKey != nil { + patch.glmSearchAPIKey = raw.Tools.Web.GLMSearch.APIKey + } + if raw.Tools.Web.BaiduSearch != nil && raw.Tools.Web.BaiduSearch.APIKey != nil { + patch.baiduSearchAPIKey = raw.Tools.Web.BaiduSearch.APIKey + } + } + return patch +} + +func applyWebSearchAPIKeyPatch(cfg *config.Config, patch webSearchAPIKeyPatch) { + if patch.glmSearchAPIKey != nil { + cfg.Tools.Web.GLMSearch.SetAPIKey(strings.TrimSpace(*patch.glmSearchAPIKey)) + } + if patch.baiduSearchAPIKey != nil { + cfg.Tools.Web.BaiduSearch.SetAPIKey(strings.TrimSpace(*patch.baiduSearchAPIKey)) + } +} + // handleTestCommandPatterns tests a command against whitelist and blacklist patterns. // // POST /api/config/test-command-patterns diff --git a/web/backend/api/config_test.go b/web/backend/api/config_test.go index d3e25a7f9..ea6f5ef0b 100644 --- a/web/backend/api/config_test.go +++ b/web/backend/api/config_test.go @@ -143,6 +143,187 @@ func TestHandlePatchConfig_AllowsInvalidExecRegexPatternsWhenExecDisabled(t *tes } } +func TestHandlePatchConfig_PreservesWebSearchAPIKeysWhenOmitted(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + seedWebSearchAPIKeys(t, configPath, "glm-existing-key", "baidu-existing-key") + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{ + "gateway": { + "log_level": "info" + } + }`)) + req.Header.Set("Content-Type", "application/json") + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if got := cfg.Tools.Web.GLMSearch.APIKey(); got != "glm-existing-key" { + t.Fatalf("tools.web.glm_search.api_key = %q, want %q", got, "glm-existing-key") + } + if got := cfg.Tools.Web.BaiduSearch.APIKey(); got != "baidu-existing-key" { + t.Fatalf("tools.web.baidu_search.api_key = %q, want %q", got, "baidu-existing-key") + } +} + +func TestHandlePatchConfig_UpdatesWebSearchAPIKeys(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{ + "tools": { + "web": { + "glm_search": { "api_key": "glm-updated-key" }, + "baidu_search": { "api_key": "baidu-updated-key" } + } + } + }`)) + req.Header.Set("Content-Type", "application/json") + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if got := cfg.Tools.Web.GLMSearch.APIKey(); got != "glm-updated-key" { + t.Fatalf("tools.web.glm_search.api_key = %q, want %q", got, "glm-updated-key") + } + if got := cfg.Tools.Web.BaiduSearch.APIKey(); got != "baidu-updated-key" { + t.Fatalf("tools.web.baidu_search.api_key = %q, want %q", got, "baidu-updated-key") + } +} + +func TestHandleUpdateConfig_PreservesWebSearchAPIKeysWhenOmitted(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + seedWebSearchAPIKeys(t, configPath, "glm-existing-key-via-put", "baidu-existing-key-via-put") + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodPut, "/api/config", bytes.NewBufferString(`{ + "version": 1, + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "model_name": "custom-default" + } + }, + "model_list": [ + { + "model_name": "custom-default", + "model": "openai/gpt-4o", + "api_keys": ["sk-default"] + } + ] + }`)) + req.Header.Set("Content-Type", "application/json") + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if got := cfg.Tools.Web.GLMSearch.APIKey(); got != "glm-existing-key-via-put" { + t.Fatalf("tools.web.glm_search.api_key = %q, want %q", got, "glm-existing-key-via-put") + } + if got := cfg.Tools.Web.BaiduSearch.APIKey(); got != "baidu-existing-key-via-put" { + t.Fatalf("tools.web.baidu_search.api_key = %q, want %q", got, "baidu-existing-key-via-put") + } +} + +func TestHandleUpdateConfig_UpdatesWebSearchAPIKeys(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodPut, "/api/config", bytes.NewBufferString(`{ + "version": 1, + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "model_name": "custom-default" + } + }, + "model_list": [ + { + "model_name": "custom-default", + "model": "openai/gpt-4o", + "api_keys": ["sk-default"] + } + ], + "tools": { + "web": { + "glm_search": { "api_key": "glm-updated-key-via-put" }, + "baidu_search": { "api_key": "baidu-updated-key-via-put" } + } + } + }`)) + req.Header.Set("Content-Type", "application/json") + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if got := cfg.Tools.Web.GLMSearch.APIKey(); got != "glm-updated-key-via-put" { + t.Fatalf("tools.web.glm_search.api_key = %q, want %q", got, "glm-updated-key-via-put") + } + if got := cfg.Tools.Web.BaiduSearch.APIKey(); got != "baidu-updated-key-via-put" { + t.Fatalf("tools.web.baidu_search.api_key = %q, want %q", got, "baidu-updated-key-via-put") + } +} + +func seedWebSearchAPIKeys(t *testing.T, configPath, glmAPIKey, baiduAPIKey string) { + t.Helper() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + + cfg.Tools.Web.GLMSearch.SetAPIKey(glmAPIKey) + cfg.Tools.Web.BaiduSearch.SetAPIKey(baiduAPIKey) + + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } +} + // setupPicoEnabledEnv creates a test environment with Pico channel enabled and // its token stored only in .security.yml (not in the JSON payload). func setupPicoEnabledEnv(t *testing.T) (string, func()) { diff --git a/web/frontend/src/components/config/config-page.tsx b/web/frontend/src/components/config/config-page.tsx index cbe4d8e91..2a246ab3e 100644 --- a/web/frontend/src/components/config/config-page.tsx +++ b/web/frontend/src/components/config/config-page.tsx @@ -20,6 +20,7 @@ import { ExecSection, LauncherSection, RuntimeSection, + WebSearchSection, } from "@/components/config/config-sections" import { type CoreConfigForm, @@ -182,9 +183,12 @@ export function ConfigPage() { "Cron exec timeout", { min: 0 }, ) + const glmSearchAPIKey = form.glmSearchAPIKey.trim() + const baiduSearchAPIKey = form.baiduSearchAPIKey.trim() const execConfigPatch: Record = { enabled: form.execEnabled, } + const webConfigPatch: Record = {} if (form.execEnabled) { execConfigPatch.allow_remote = form.allowRemote @@ -205,6 +209,24 @@ export function ConfigPage() { } } + if (glmSearchAPIKey !== "") { + webConfigPatch.glm_search = { api_key: glmSearchAPIKey } + } + if (baiduSearchAPIKey !== "") { + webConfigPatch.baidu_search = { api_key: baiduSearchAPIKey } + } + + const toolsPatch: Record = { + cron: { + allow_command: form.allowCommand, + exec_timeout_minutes: cronExecTimeoutMinutes, + }, + exec: execConfigPatch, + } + if (Object.keys(webConfigPatch).length > 0) { + toolsPatch.web = webConfigPatch + } + await patchAppConfig({ agents: { defaults: { @@ -225,13 +247,7 @@ export function ConfigPage() { session: { dm_scope: dmScope, }, - tools: { - cron: { - allow_command: form.allowCommand, - exec_timeout_minutes: cronExecTimeoutMinutes, - }, - exec: execConfigPatch, - }, + tools: toolsPatch, heartbeat: { enabled: form.heartbeatEnabled, interval: heartbeatInterval, @@ -332,6 +348,8 @@ export function ConfigPage() { + + + + onFieldChange("glmSearchAPIKey", e.target.value)} + /> + + + + onFieldChange("baiduSearchAPIKey", e.target.value)} + /> + + + ) +} + interface LauncherSectionProps { launcherForm: LauncherForm onFieldChange: UpdateLauncherField diff --git a/web/frontend/src/components/config/form-model.ts b/web/frontend/src/components/config/form-model.ts index c5542a3b2..f37766f5b 100644 --- a/web/frontend/src/components/config/form-model.ts +++ b/web/frontend/src/components/config/form-model.ts @@ -14,6 +14,8 @@ export interface CoreConfigForm { execTimeoutSeconds: string allowCommand: boolean cronExecTimeoutMinutes: string + glmSearchAPIKey: string + baiduSearchAPIKey: string maxTokens: string contextWindow: string maxToolIterations: string @@ -77,6 +79,8 @@ export const EMPTY_FORM: CoreConfigForm = { execTimeoutSeconds: "0", allowCommand: true, cronExecTimeoutMinutes: "5", + glmSearchAPIKey: "", + baiduSearchAPIKey: "", maxTokens: "32768", contextWindow: "", maxToolIterations: "50", @@ -128,6 +132,9 @@ export function buildFormFromConfig(config: unknown): CoreConfigForm { const heartbeat = asRecord(root.heartbeat) const devices = asRecord(root.devices) const tools = asRecord(root.tools) + const web = asRecord(tools.web) + const glmSearch = asRecord(web.glm_search) + const baiduSearch = asRecord(web.baidu_search) const cron = asRecord(tools.cron) const exec = asRecord(tools.exec) const toolFeedback = asRecord(defaults.tool_feedback) @@ -184,6 +191,8 @@ export function buildFormFromConfig(config: unknown): CoreConfigForm { cron.exec_timeout_minutes, EMPTY_FORM.cronExecTimeoutMinutes, ), + glmSearchAPIKey: asString(glmSearch.api_key), + baiduSearchAPIKey: asString(baiduSearch.api_key), maxTokens: asNumberString(defaults.max_tokens, EMPTY_FORM.maxTokens), contextWindow: asNumberString( defaults.context_window, diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json index 38cdeb324..6a280aaf2 100644 --- a/web/frontend/src/i18n/locales/en.json +++ b/web/frontend/src/i18n/locales/en.json @@ -478,6 +478,12 @@ "allow_shell_execution_hint": "Allow scheduled tasks to run commands by default. When disabled, users must pass command_confirm=true to schedule a command task.", "cron_exec_timeout": "Scheduled Command Timeout (minutes)", "cron_exec_timeout_hint": "Maximum runtime for scheduled commands. Set to 0 to disable the timeout.", + "web_search_section_hint": "Manage API keys for built-in web search providers. Leave a field blank to keep the current key unchanged.", + "glm_search_api_key": "GLM Search API Key", + "glm_search_api_key_hint": "Used by the built-in GLM web search provider.", + "baidu_search_api_key": "Baidu Search API Key", + "baidu_search_api_key_hint": "Used by the built-in Baidu web search provider.", + "secret_placeholder": "Leave blank to keep the current key", "max_tokens": "Max Tokens", "max_tokens_hint": "Upper token limit per model response.", "context_window": "Context Window", @@ -522,6 +528,7 @@ "runtime": "Runtime", "exec": "Run Commands", "cron": "Cron Tasks", + "web_search": "Web Search", "launcher": "Service", "devices": "Devices" }, diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json index 9ec4ec967..a5638ba22 100644 --- a/web/frontend/src/i18n/locales/zh.json +++ b/web/frontend/src/i18n/locales/zh.json @@ -478,6 +478,12 @@ "allow_shell_execution_hint": "开启后,定时任务默认允许运行命令。关闭后,必须显式传入 command_confirm=true 才能创建运行命令的定时任务。", "cron_exec_timeout": "定时命令超时(分钟)", "cron_exec_timeout_hint": "定时任务中命令的最长运行时间。设置为 0 表示不限制超时。", + "web_search_section_hint": "管理内置网页搜索服务的 API Key。留空表示保持当前 Key 不变。", + "glm_search_api_key": "GLM 搜索 API Key", + "glm_search_api_key_hint": "用于内置的 GLM 网页搜索服务。", + "baidu_search_api_key": "百度搜索 API Key", + "baidu_search_api_key_hint": "用于内置的百度网页搜索服务。", + "secret_placeholder": "留空保持当前 Key 不变", "max_tokens": "最大 Token 数", "max_tokens_hint": "单次模型响应允许的最大 Token 数。", "context_window": "上下文窗口", @@ -522,6 +528,7 @@ "runtime": "运行时", "exec": "运行命令", "cron": "定时任务", + "web_search": "网页搜索", "launcher": "服务参数", "devices": "设备" },