Fix web search API key config preservation

This commit is contained in:
Alix-007 2026-03-26 22:49:30 +08:00
parent a4574f72a3
commit 60499def99
7 changed files with 321 additions and 7 deletions

View file

@ -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) http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
return return
} }
webAPIKeyPatch := parseWebSearchAPIKeyPatch(body)
if execAllowRemoteOmitted(body) { if execAllowRemoteOmitted(body) {
cfg.Tools.Exec.AllowRemote = config.DefaultConfig().Tools.Exec.AllowRemote cfg.Tools.Exec.AllowRemote = config.DefaultConfig().Tools.Exec.AllowRemote
} }
@ -69,6 +70,7 @@ func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
return return
} }
applyConfigSecretsFromMap(&cfg, raw) applyConfigSecretsFromMap(&cfg, raw)
applyWebSearchAPIKeyPatch(&cfg, webAPIKeyPatch)
if errs := validateConfig(&cfg); len(errs) > 0 { if errs := validateConfig(&cfg); len(errs) > 0 {
w.Header().Set("Content-Type", "application/json") 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) http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
return return
} }
webAPIKeyPatch := parseWebSearchAPIKeyPatch(patchBody)
// Load existing config and marshal to a map for merging // Load existing config and marshal to a map for merging
cfg, err := config.LoadConfig(h.configPath) cfg, err := config.LoadConfig(h.configPath)
@ -166,6 +169,7 @@ func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) {
return return
} }
applyConfigSecretsFromMap(&newCfg, base) applyConfigSecretsFromMap(&newCfg, base)
applyWebSearchAPIKeyPatch(&newCfg, webAPIKeyPatch)
if errs := validateConfig(&newCfg); len(errs) > 0 { if errs := validateConfig(&newCfg); len(errs) > 0 {
w.Header().Set("Content-Type", "application/json") 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"}) 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. // handleTestCommandPatterns tests a command against whitelist and blacklist patterns.
// //
// POST /api/config/test-command-patterns // POST /api/config/test-command-patterns

View file

@ -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 // setupPicoEnabledEnv creates a test environment with Pico channel enabled and
// its token stored only in .security.yml (not in the JSON payload). // its token stored only in .security.yml (not in the JSON payload).
func setupPicoEnabledEnv(t *testing.T) (string, func()) { func setupPicoEnabledEnv(t *testing.T) (string, func()) {

View file

@ -20,6 +20,7 @@ import {
ExecSection, ExecSection,
LauncherSection, LauncherSection,
RuntimeSection, RuntimeSection,
WebSearchSection,
} from "@/components/config/config-sections" } from "@/components/config/config-sections"
import { import {
type CoreConfigForm, type CoreConfigForm,
@ -182,9 +183,12 @@ export function ConfigPage() {
"Cron exec timeout", "Cron exec timeout",
{ min: 0 }, { min: 0 },
) )
const glmSearchAPIKey = form.glmSearchAPIKey.trim()
const baiduSearchAPIKey = form.baiduSearchAPIKey.trim()
const execConfigPatch: Record<string, unknown> = { const execConfigPatch: Record<string, unknown> = {
enabled: form.execEnabled, enabled: form.execEnabled,
} }
const webConfigPatch: Record<string, unknown> = {}
if (form.execEnabled) { if (form.execEnabled) {
execConfigPatch.allow_remote = form.allowRemote 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<string, unknown> = {
cron: {
allow_command: form.allowCommand,
exec_timeout_minutes: cronExecTimeoutMinutes,
},
exec: execConfigPatch,
}
if (Object.keys(webConfigPatch).length > 0) {
toolsPatch.web = webConfigPatch
}
await patchAppConfig({ await patchAppConfig({
agents: { agents: {
defaults: { defaults: {
@ -225,13 +247,7 @@ export function ConfigPage() {
session: { session: {
dm_scope: dmScope, dm_scope: dmScope,
}, },
tools: { tools: toolsPatch,
cron: {
allow_command: form.allowCommand,
exec_timeout_minutes: cronExecTimeoutMinutes,
},
exec: execConfigPatch,
},
heartbeat: { heartbeat: {
enabled: form.heartbeatEnabled, enabled: form.heartbeatEnabled,
interval: heartbeatInterval, interval: heartbeatInterval,
@ -332,6 +348,8 @@ export function ConfigPage() {
<CronSection form={form} onFieldChange={updateField} /> <CronSection form={form} onFieldChange={updateField} />
<WebSearchSection form={form} onFieldChange={updateField} />
<LauncherSection <LauncherSection
launcherForm={launcherForm} launcherForm={launcherForm}
onFieldChange={updateLauncherField} onFieldChange={updateLauncherField}

View file

@ -505,6 +505,51 @@ export function CronSection({ form, onFieldChange }: CronSectionProps) {
) )
} }
interface WebSearchSectionProps {
form: CoreConfigForm
onFieldChange: UpdateCoreField
}
export function WebSearchSection({
form,
onFieldChange,
}: WebSearchSectionProps) {
const { t } = useTranslation()
return (
<ConfigSectionCard
title={t("pages.config.sections.web_search")}
description={t("pages.config.web_search_section_hint")}
>
<Field
label={t("pages.config.glm_search_api_key")}
hint={t("pages.config.glm_search_api_key_hint")}
layout="setting-row"
>
<Input
type="password"
value={form.glmSearchAPIKey}
placeholder={t("pages.config.secret_placeholder")}
onChange={(e) => onFieldChange("glmSearchAPIKey", e.target.value)}
/>
</Field>
<Field
label={t("pages.config.baidu_search_api_key")}
hint={t("pages.config.baidu_search_api_key_hint")}
layout="setting-row"
>
<Input
type="password"
value={form.baiduSearchAPIKey}
placeholder={t("pages.config.secret_placeholder")}
onChange={(e) => onFieldChange("baiduSearchAPIKey", e.target.value)}
/>
</Field>
</ConfigSectionCard>
)
}
interface LauncherSectionProps { interface LauncherSectionProps {
launcherForm: LauncherForm launcherForm: LauncherForm
onFieldChange: UpdateLauncherField onFieldChange: UpdateLauncherField

View file

@ -14,6 +14,8 @@ export interface CoreConfigForm {
execTimeoutSeconds: string execTimeoutSeconds: string
allowCommand: boolean allowCommand: boolean
cronExecTimeoutMinutes: string cronExecTimeoutMinutes: string
glmSearchAPIKey: string
baiduSearchAPIKey: string
maxTokens: string maxTokens: string
contextWindow: string contextWindow: string
maxToolIterations: string maxToolIterations: string
@ -77,6 +79,8 @@ export const EMPTY_FORM: CoreConfigForm = {
execTimeoutSeconds: "0", execTimeoutSeconds: "0",
allowCommand: true, allowCommand: true,
cronExecTimeoutMinutes: "5", cronExecTimeoutMinutes: "5",
glmSearchAPIKey: "",
baiduSearchAPIKey: "",
maxTokens: "32768", maxTokens: "32768",
contextWindow: "", contextWindow: "",
maxToolIterations: "50", maxToolIterations: "50",
@ -128,6 +132,9 @@ export function buildFormFromConfig(config: unknown): CoreConfigForm {
const heartbeat = asRecord(root.heartbeat) const heartbeat = asRecord(root.heartbeat)
const devices = asRecord(root.devices) const devices = asRecord(root.devices)
const tools = asRecord(root.tools) 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 cron = asRecord(tools.cron)
const exec = asRecord(tools.exec) const exec = asRecord(tools.exec)
const toolFeedback = asRecord(defaults.tool_feedback) const toolFeedback = asRecord(defaults.tool_feedback)
@ -184,6 +191,8 @@ export function buildFormFromConfig(config: unknown): CoreConfigForm {
cron.exec_timeout_minutes, cron.exec_timeout_minutes,
EMPTY_FORM.cronExecTimeoutMinutes, EMPTY_FORM.cronExecTimeoutMinutes,
), ),
glmSearchAPIKey: asString(glmSearch.api_key),
baiduSearchAPIKey: asString(baiduSearch.api_key),
maxTokens: asNumberString(defaults.max_tokens, EMPTY_FORM.maxTokens), maxTokens: asNumberString(defaults.max_tokens, EMPTY_FORM.maxTokens),
contextWindow: asNumberString( contextWindow: asNumberString(
defaults.context_window, defaults.context_window,

View file

@ -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.", "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": "Scheduled Command Timeout (minutes)",
"cron_exec_timeout_hint": "Maximum runtime for scheduled commands. Set to 0 to disable the timeout.", "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": "Max Tokens",
"max_tokens_hint": "Upper token limit per model response.", "max_tokens_hint": "Upper token limit per model response.",
"context_window": "Context Window", "context_window": "Context Window",
@ -522,6 +528,7 @@
"runtime": "Runtime", "runtime": "Runtime",
"exec": "Run Commands", "exec": "Run Commands",
"cron": "Cron Tasks", "cron": "Cron Tasks",
"web_search": "Web Search",
"launcher": "Service", "launcher": "Service",
"devices": "Devices" "devices": "Devices"
}, },

View file

@ -478,6 +478,12 @@
"allow_shell_execution_hint": "开启后,定时任务默认允许运行命令。关闭后,必须显式传入 command_confirm=true 才能创建运行命令的定时任务。", "allow_shell_execution_hint": "开启后,定时任务默认允许运行命令。关闭后,必须显式传入 command_confirm=true 才能创建运行命令的定时任务。",
"cron_exec_timeout": "定时命令超时(分钟)", "cron_exec_timeout": "定时命令超时(分钟)",
"cron_exec_timeout_hint": "定时任务中命令的最长运行时间。设置为 0 表示不限制超时。", "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": "最大 Token 数",
"max_tokens_hint": "单次模型响应允许的最大 Token 数。", "max_tokens_hint": "单次模型响应允许的最大 Token 数。",
"context_window": "上下文窗口", "context_window": "上下文窗口",
@ -522,6 +528,7 @@
"runtime": "运行时", "runtime": "运行时",
"exec": "运行命令", "exec": "运行命令",
"cron": "定时任务", "cron": "定时任务",
"web_search": "网页搜索",
"launcher": "服务参数", "launcher": "服务参数",
"devices": "设备" "devices": "设备"
}, },