Fix web search API key config preservation
This commit is contained in:
parent
a4574f72a3
commit
60499def99
7 changed files with 321 additions and 7 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> = {
|
||||
enabled: form.execEnabled,
|
||||
}
|
||||
const webConfigPatch: Record<string, unknown> = {}
|
||||
|
||||
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<string, unknown> = {
|
||||
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() {
|
|||
|
||||
<CronSection form={form} onFieldChange={updateField} />
|
||||
|
||||
<WebSearchSection form={form} onFieldChange={updateField} />
|
||||
|
||||
<LauncherSection
|
||||
launcherForm={launcherForm}
|
||||
onFieldChange={updateLauncherField}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
launcherForm: LauncherForm
|
||||
onFieldChange: UpdateLauncherField
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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": "设备"
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue